From f606335f973a8c5fac5d6d60f9fb4c8a5c637639 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 7 Jul 2021 22:14:17 -0700 Subject: [PATCH 01/76] Add simple app manifest for getting started --- README.md | 8 +++++++ examples/app_manifest/manifest.yml | 34 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 examples/app_manifest/manifest.yml diff --git a/README.md b/README.md index 61137f9..9b65c39 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ go get github.com/shomali11/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. + +## Manual Steps + Slacker works by communicating with the Slack [Events API](https://api.slack.com/apis/connections/events-api) using the [Socket Mode](https://api.slack.com/apis/connections/socket) connection protocol. To get started, you must have or create a [Slack App](https://api.slack.com/apps?new_app=1) and enable `Socket Mode`, which will generate your app token that will be needed to authenticate. @@ -42,6 +46,10 @@ Once installed, navigate back to the `OAuth & Permissions` section and retrieve 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. + # Examples ## Example 1 diff --git a/examples/app_manifest/manifest.yml b/examples/app_manifest/manifest.yml new file mode 100644 index 0000000..16e02d4 --- /dev/null +++ b/examples/app_manifest/manifest.yml @@ -0,0 +1,34 @@ +_metadata: + major_version: 1 + minor_version: 1 +display_information: + name: Slacker App +features: + app_home: + home_tab_enabled: false + messages_tab_enabled: true + messages_tab_read_only_enabled: true + bot_user: + display_name: Slacker App + always_online: false +oauth_config: + scopes: + bot: + - app_mentions:read + - channels:history + - chat:write + - groups:history + - im:history + - mpim:history +settings: + event_subscriptions: + bot_events: + - app_mention + - message.channels + - message.groups + - message.im + - message.mpim + interactivity: + is_enabled: true + org_deploy_enabled: false + socket_mode_enabled: true From cea1087f3d4aa383220a4043aa42b994e7be27bc Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 7 Jul 2021 22:19:26 -0700 Subject: [PATCH 02/76] Add more details on scopes --- README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9b65c39..f794696 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,20 @@ To use Slacker you'll need to create a Slack App, either [manually](#manual-step Slacker works by communicating with the Slack [Events API](https://api.slack.com/apis/connections/events-api) using the [Socket Mode](https://api.slack.com/apis/connections/socket) connection protocol. -To get started, you must have or create a [Slack App](https://api.slack.com/apps?new_app=1) and enable `Socket Mode`, which will generate your app token that will be needed to authenticate. +To get started, you must have or create a [Slack App](https://api.slack.com/apps?new_app=1) and enable `Socket Mode`, which will generate your app token (`SLACK_APP_TOKEN` in the examples) that will be needed to authenticate. Additionally, you need to subscribe to events for your bot to respond to under the `Event Subscriptions` section. Common event subscriptions for bots include `app_mention` or `message.im`. -After setting up your subscriptions, add additional scopes necessary to your bot in the `OAuth & Permissions` and install your app into your workspace. +After setting up your subscriptions, add scopes necessary to your bot in the `OAuth & Permissions`. The following scopes are recommended for getting started, though you may need to add/remove scopes depending on your bots purpose: -Once installed, navigate back to the `OAuth & Permissions` section and retrieve yor bot token from the top of the page. +* `app_mentions:read` +* `channels:history` +* `chat:write` +* `groups:history` +* `im:history` +* `mpim:history` + +Once you've selected your scopes install your app to the workspace and navigate back to the `OAuth & Permissions` section. Here you can retrieve yor bot's OAuth token (`SLACK_BOT_TOKEN` in the examples) from the top of the page. With both tokens in hand, you can now proceed with the examples below. From 17b13ef80edeb83fbd6e424da719df58e2a53c3b Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 7 Jul 2021 22:28:29 -0700 Subject: [PATCH 03/76] Add troubleshooting section --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index f794696..39d5405 100644 --- a/README.md +++ b/README.md @@ -752,3 +752,13 @@ func main() { } } ``` + +# Troubleshooting + +## My bot is not responding to events + +There are a few common issues that can cause this: + +* The OAuth (bot) Token may be incorrect. In this case authentication does not fail like it does if the App Token is incorrect, and the bot will simply have no scopes and be unable to respond. +* Required scopes are missing from the OAuth (bot) Token. Similar to the incorrect OAuth Token, without the necessary scopes, the bot cannot respond. +* The bot does not have the correct event subscriptions setup, and is not receiving events to respond to. From 26bc14cb8ba96fa621a0fde912f97d658fdd9fb9 Mon Sep 17 00:00:00 2001 From: Michael Kobaly Date: Mon, 2 Aug 2021 14:25:14 -0400 Subject: [PATCH 04/76] fix defaultCommand handler not being wired up --- slacker.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/slacker.go b/slacker.go index 9a4803c..050e0a0 100644 --- a/slacker.go +++ b/slacker.go @@ -292,6 +292,11 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}) { cmd.Execute(botCtx, request, response) return } + + if s.defaultMessageHandler != nil { + request := s.requestConstructor(botCtx, nil) + s.defaultMessageHandler(botCtx, request, response) + } } func newMessageEvent(evt interface{}) *MessageEvent { From 08d742e3c88604fdc3fc35afdb83a3150949d1ab Mon Sep 17 00:00:00 2001 From: Asaf Alima Date: Tue, 3 Aug 2021 14:59:00 +0300 Subject: [PATCH 05/76] Add support for passing interactive event handler --- slacker.go | 52 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/slacker.go b/slacker.go index 050e0a0..fd2263b 100644 --- a/slacker.go +++ b/slacker.go @@ -57,20 +57,21 @@ func NewClient(botToken, appToken string, options ...ClientOption) *Slacker { // Slacker contains the Slack API, botCommands, and handlers type Slacker struct { - client *slack.Client - socketModeClient *socketmode.Client - botCommands []BotCommand - botContextConstructor func(ctx context.Context, api *slack.Client, client *socketmode.Client, evt *MessageEvent) BotContext - requestConstructor func(botCtx BotContext, properties *proper.Properties) Request - responseConstructor func(botCtx BotContext) ResponseWriter - initHandler func() - errorHandler func(err string) - helpDefinition *CommandDefinition - defaultMessageHandler func(botCtx BotContext, request Request, response ResponseWriter) - defaultEventHandler func(interface{}) - unAuthorizedError error - commandChannel chan *CommandEvent - appID string + client *slack.Client + socketModeClient *socketmode.Client + botCommands []BotCommand + botContextConstructor func(ctx context.Context, api *slack.Client, client *socketmode.Client, evt *MessageEvent) BotContext + requestConstructor func(botCtx BotContext, properties *proper.Properties) Request + responseConstructor func(botCtx BotContext) ResponseWriter + initHandler func() + errorHandler func(err string) + interactiveEventHandler func(*socketmode.Client, socketmode.Event, slack.InteractionCallback) + helpDefinition *CommandDefinition + defaultMessageHandler func(botCtx BotContext, request Request, response ResponseWriter) + defaultEventHandler func(interface{}) + unAuthorizedError error + commandChannel chan *CommandEvent + appID string } // BotCommands returns Bot Commands @@ -98,6 +99,10 @@ func (s *Slacker) Err(errorHandler func(err string)) { s.errorHandler = errorHandler } +func (s *Slacker) Interactive(interactiveEventHandler func(*socketmode.Client, socketmode.Event, slack.InteractionCallback)) { + s.interactiveEventHandler = interactiveEventHandler +} + // CustomRequest creates a new request func (s *Slacker) CustomRequest(requestConstructor func(botCtx BotContext, properties *proper.Properties) Request) { s.requestConstructor = requestConstructor @@ -163,7 +168,6 @@ func (s *Slacker) Listen(ctx context.Context) error { fmt.Println("Connection failed. Retrying later...") case socketmode.EventTypeConnected: fmt.Println("Connected to Slack with Socket Mode.") - case socketmode.EventTypeEventsAPI: ev, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { @@ -180,9 +184,21 @@ func (s *Slacker) Listen(ctx context.Context) error { } s.socketModeClient.Ack(*evt.Request) + case socketmode.EventTypeInteractive: + if s.interactiveEventHandler == nil { + s.unsupportedEventReceived() + continue + } + + callback, ok := evt.Data.(slack.InteractionCallback) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + continue + } + s.interactiveEventHandler(s.socketModeClient, evt, callback) default: - s.socketModeClient.Debugf("unsupported Events API event received") + s.unsupportedEventReceived() } } } @@ -193,6 +209,10 @@ func (s *Slacker) Listen(ctx context.Context) error { return s.socketModeClient.Run() } +func (s *Slacker) unsupportedEventReceived() { + s.socketModeClient.Debugf("unsupported Events API event received") +} + // GetUserInfo retrieve complete user information func (s *Slacker) GetUserInfo(user string) (*slack.User, error) { return s.client.GetUserInfo(user) From 44486c5fd9ba0a8cca18b01db6238593bce4e667 Mon Sep 17 00:00:00 2001 From: Asaf Alima Date: Tue, 3 Aug 2021 16:16:52 +0300 Subject: [PATCH 06/76] Add example for interactive handler --- examples/15/example15.go | 71 ++++++++++++++++++++++++++++++++++++++++ slacker.go | 6 ++-- 2 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 examples/15/example15.go diff --git a/examples/15/example15.go b/examples/15/example15.go new file mode 100644 index 0000000..1f327c3 --- /dev/null +++ b/examples/15/example15.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "github.com/shomali11/slacker" + "github.com/slack-go/slack" + "github.com/slack-go/slack/socketmode" + "log" + "os" +) + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + + bot.Interactive(func(s *slacker.Slacker, event *socketmode.Event, callback *slack.InteractionCallback) { + 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..." + } + + _, _, _ = s.Client().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), + slack.MsgOptionReplaceOriginal(callback.ResponseURL)) + + s.SocketMode().Ack(*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 { + panic(err) + } + }, + } + + bot.Command("mood", definition) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} diff --git a/slacker.go b/slacker.go index fd2263b..7c13818 100644 --- a/slacker.go +++ b/slacker.go @@ -65,7 +65,7 @@ type Slacker struct { responseConstructor func(botCtx BotContext) ResponseWriter initHandler func() errorHandler func(err string) - interactiveEventHandler func(*socketmode.Client, socketmode.Event, slack.InteractionCallback) + interactiveEventHandler func(*Slacker, *socketmode.Event, *slack.InteractionCallback) helpDefinition *CommandDefinition defaultMessageHandler func(botCtx BotContext, request Request, response ResponseWriter) defaultEventHandler func(interface{}) @@ -99,7 +99,7 @@ func (s *Slacker) Err(errorHandler func(err string)) { s.errorHandler = errorHandler } -func (s *Slacker) Interactive(interactiveEventHandler func(*socketmode.Client, socketmode.Event, slack.InteractionCallback)) { +func (s *Slacker) Interactive(interactiveEventHandler func(*Slacker, *socketmode.Event, *slack.InteractionCallback)) { s.interactiveEventHandler = interactiveEventHandler } @@ -196,7 +196,7 @@ func (s *Slacker) Listen(ctx context.Context) error { continue } - s.interactiveEventHandler(s.socketModeClient, evt, callback) + go s.interactiveEventHandler(s, &evt, &callback) default: s.unsupportedEventReceived() } From 82d310ca4a34f0566b2c0f97f8c33c0375c4b1a1 Mon Sep 17 00:00:00 2001 From: Renato Aquino Date: Fri, 20 Aug 2021 16:54:07 -0300 Subject: [PATCH 07/76] Socket mode client with context --- slacker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slacker.go b/slacker.go index 7c13818..c8788c9 100644 --- a/slacker.go +++ b/slacker.go @@ -206,7 +206,7 @@ func (s *Slacker) Listen(ctx context.Context) error { // blocking call that handles listening for events and placing them in the // Events channel as well as handling outgoing events. - return s.socketModeClient.Run() + return s.socketModeClient.RunContext(ctx) } func (s *Slacker) unsupportedEventReceived() { From 1d852ce518dd8d237541937a039305e47e886392 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Tue, 24 Aug 2021 22:41:46 -0700 Subject: [PATCH 08/76] Remove app_mentions from manifest The manifest we ship sets up both message.im and message.channel in addition to app_mention, but if app_mention is included with either message event types then in the case where an app_mention is in a dm (message.im) or channel (message.channel) that the bot is in, the bot will receive two events and respond twice (as was seen in #82). While a user may want that for some reason, they would also need to account for that in their command code. To be friendlier for users attempting to get started, we'll remove the app_mention since message.im and message.channel will send an event whether the bot is mentioned or not as long as it is in the channel. --- examples/app_manifest/manifest.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/app_manifest/manifest.yml b/examples/app_manifest/manifest.yml index 16e02d4..576bd74 100644 --- a/examples/app_manifest/manifest.yml +++ b/examples/app_manifest/manifest.yml @@ -14,7 +14,6 @@ features: oauth_config: scopes: bot: - - app_mentions:read - channels:history - chat:write - groups:history @@ -23,7 +22,6 @@ oauth_config: settings: event_subscriptions: bot_events: - - app_mention - message.channels - message.groups - message.im From 8dc95d25751cccc31ad48915e02e5e199f955352 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Mon, 6 Sep 2021 19:48:43 -0700 Subject: [PATCH 09/76] Clarify BotID comment --- context.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/context.go b/context.go index 58ffeb9..f1083da 100644 --- a/context.go +++ b/context.go @@ -75,7 +75,8 @@ type MessageEvent struct { // `app_mention` or `message` Type string - // BotID holds the Slack User ID for our bot + // BotID of the bot that sent this message. If a bot did not send this + // message, this will be an empty string. BotID string } From 43a5a2ec5ee17b5697ca667314bc477e9a6fd467 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Mon, 6 Sep 2021 19:57:20 -0700 Subject: [PATCH 10/76] Support bot interactions response behavior This introduces the WithBotInteractionMode ClientOption that allows a user to specify how their bot should handle events from other bots. --- bots.go | 21 +++++++++++++++++++++ defaults.go | 14 ++++++++++++-- slacker.go | 45 +++++++++++++++++++++++++++++++++------------ 3 files changed, 66 insertions(+), 14 deletions(-) create mode 100644 bots.go diff --git a/bots.go b/bots.go new file mode 100644 index 0000000..b0db7ba --- /dev/null +++ b/bots.go @@ -0,0 +1,21 @@ +package slacker + +// BotInteractionMode instruct the bot on how to handle incoming events that +// originated from a bot. +type BotInteractionMode int + +const ( + // BotInteractionModeIgnoreAll instructs our bot to ignore any activity coming + // from other bots, including our self. + BotInteractionModeIgnoreAll BotInteractionMode = iota + + // BotInteractionModeIgnoreApp 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 + + // BotInteractionModeIgnoreNone 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 +) diff --git a/defaults.go b/defaults.go index e348880..5e6fece 100644 --- a/defaults.go +++ b/defaults.go @@ -12,14 +12,24 @@ func WithDebug(debug bool) ClientOption { } } +// 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 { - Debug bool + Debug bool + BotMode BotInteractionMode } func newClientDefaults(options ...ClientOption) *ClientDefaults { config := &ClientDefaults{ - Debug: false, + Debug: false, + BotMode: BotInteractionModeIgnoreAll, } for _, option := range options { diff --git a/slacker.go b/slacker.go index c8788c9..e26853d 100644 --- a/slacker.go +++ b/slacker.go @@ -47,10 +47,11 @@ func NewClient(botToken, appToken string, options ...ClientOption) *Slacker { socketmode.OptionDebug(defaults.Debug), ) slacker := &Slacker{ - client: api, - socketModeClient: smc, - commandChannel: make(chan *CommandEvent, 100), - unAuthorizedError: unAuthorizedError, + client: api, + socketModeClient: smc, + commandChannel: make(chan *CommandEvent, 100), + unAuthorizedError: unAuthorizedError, + botInteractionMode: defaults.BotMode, } return slacker } @@ -72,6 +73,7 @@ type Slacker struct { unAuthorizedError error commandChannel chan *CommandEvent appID string + botInteractionMode BotInteractionMode } // BotCommands returns Bot Commands @@ -168,6 +170,10 @@ func (s *Slacker) Listen(ctx context.Context) error { fmt.Println("Connection failed. Retrying later...") case socketmode.EventTypeConnected: fmt.Println("Connected to Slack with Socket Mode.") + case socketmode.EventTypeHello: + s.appID = evt.Request.ConnectionInfo.AppID + fmt.Printf("Connected as App ID %v\n", s.appID) + case socketmode.EventTypeEventsAPI: ev, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { @@ -286,6 +292,29 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}) { if ev == nil { // event doesn't appear to be a valid message type return + } else if ev.IsBot() { + switch s.botInteractionMode { + case BotInteractionModeIgnoreApp: + bot, err := s.client.GetBotInfo(ev.BotID) + if err != nil { + if err.Error() == "missing_scope" { + fmt.Println("unable to determine if bot response is from me -- please add users:read scope to your app") + } else { + fmt.Printf("unable to get bot that sent message information: %v", err) + } + return + } + if bot.AppID == s.appID { + fmt.Printf("Ignoring event that originated from my App ID: %v\n", bot.AppID) + return + } + case BotInteractionModeIgnoreAll: + fmt.Printf("Ignoring event that originated from Bot ID: %v\n", ev.BotID) + return + default: + // BotInteractionModeIgnoreNone is handled in the default case + } + } botCtx := s.botContextConstructor(ctx, s.client, s.socketModeClient, ev) @@ -349,13 +378,5 @@ func newMessageEvent(evt interface{}) *MessageEvent { } } - // Filter out other bots. At the very least this is needed for MessageEvent - // to prevent the bot from self-triggering and causing loops. However better - // logic should be in place to prevent repeated self-triggering / bot-storms - // if we want to enable this later. - if me.IsBot() { - return nil - } - return me } From 3d2d2051be23c06e747bd45a6c26ebd2c9b2dd03 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Mon, 6 Sep 2021 20:22:06 -0700 Subject: [PATCH 11/76] Add `users:read` scope to the example manifest --- examples/app_manifest/manifest.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/app_manifest/manifest.yml b/examples/app_manifest/manifest.yml index 16e02d4..fbb15bf 100644 --- a/examples/app_manifest/manifest.yml +++ b/examples/app_manifest/manifest.yml @@ -20,6 +20,7 @@ oauth_config: - groups:history - im:history - mpim:history + - users:read settings: event_subscriptions: bot_events: From 04e2fda54ddbf49dc3eb05fdbfe41f068a43ecf5 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Fri, 17 Sep 2021 13:18:57 -0700 Subject: [PATCH 12/76] Document Slacker.Interactive method --- slacker.go | 1 + 1 file changed, 1 insertion(+) diff --git a/slacker.go b/slacker.go index e26853d..955b23b 100644 --- a/slacker.go +++ b/slacker.go @@ -101,6 +101,7 @@ func (s *Slacker) Err(errorHandler func(err string)) { s.errorHandler = errorHandler } +// Interactive assigns an interactive event handler func (s *Slacker) Interactive(interactiveEventHandler func(*Slacker, *socketmode.Event, *slack.InteractionCallback)) { s.interactiveEventHandler = interactiveEventHandler } From 2ae685098d995230ec9855a5739f40976b7fbc72 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Fri, 17 Sep 2021 13:38:13 -0700 Subject: [PATCH 13/76] Clarify message event behavior for app manifest in README --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 39d5405..4de8148 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,22 @@ With both tokens in hand, you can now proceed with the examples below. 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. +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. + +If you wish to only have your bot respond to messages they are directly messaged in, you will need to add the `app_mentions:read` scope, and remove: + +- `im:history` # single-person dm +- `mpim:history` # multi-person dm +- `channels:history` # public channels +- `groups:history` # private channels + +You'll also need to adjust the event subscriptions, adding `app_mention` and removing: + +- `message.channels` +- `message.groups` +- `message.im` +- `message.mpim` + # Examples ## Example 1 From 136615017351c68b2d3e4df218432a89474a93ce Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 6 Oct 2021 20:24:16 -0700 Subject: [PATCH 14/76] Add example 16 for bot interaction mode --- README.md | 37 +++++++++++++++++++++++++++++++++++++ examples/16/example16.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 examples/16/example16.go diff --git a/README.md b/README.md index 39d5405..25598f8 100644 --- a/README.md +++ b/README.md @@ -753,6 +753,43 @@ func main() { } ``` +## 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) + } +}``` + # Troubleshooting ## My bot is not responding to events diff --git a/examples/16/example16.go b/examples/16/example16.go new file mode 100644 index 0000000..4d72065 --- /dev/null +++ b/examples/16/example16.go @@ -0,0 +1,31 @@ +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) + } +} From 67ccca803d04d43b7bb0a1aca0fa81dfb0505014 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 6 Oct 2021 20:25:48 -0700 Subject: [PATCH 15/76] Add example 15 (interactions) to README --- README.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 25598f8..132274e 100644 --- a/README.md +++ b/README.md @@ -753,6 +753,84 @@ func main() { } ``` +## Example 15 + +Slack interaction example + +```go +package main + +import ( + "context" + "github.com/shomali11/slacker" + "github.com/slack-go/slack" + "github.com/slack-go/slack/socketmode" + "log" + "os" +) + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + + bot.Interactive(func(s *slacker.Slacker, event *socketmode.Event, callback *slack.InteractionCallback) { + 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..." + } + + _, _, _ = s.Client().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), + slack.MsgOptionReplaceOriginal(callback.ResponseURL)) + + s.SocketMode().Ack(*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 { + panic(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 @@ -788,7 +866,8 @@ func main() { if err != nil { log.Fatal(err) } -}``` +} +``` # Troubleshooting From b2942993cfb6edcab4d5ed8c42ae4ac4aef9ba6e Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Fri, 8 Oct 2021 00:05:24 -0700 Subject: [PATCH 16/76] Add contribution guidelines Create some simple contribution guidelines to help us triage new issues and to give guidance to those wishing to successfully contribute to the project. --- CONTRIBUTING.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a50090d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,82 @@ +# Contributing to Slacker + +First of all, thank you for considering a contribution to Slacker! + +On this page you will get an overview of the contribution process for Slacker. +We want to ensure your pull request is merged so when in doubt, please talk to +us! + +## Issues + +This section addresses what we look for in a good issue, and helps us more +quickly identify and resolve your issue. + +### Submitting an Issue + +* Please test against the latest commit on the `master` branch. Sometimes + we've already solved your issue! + +* Provide steps and optionally a trivial example we can use to reproduce + the issue. Please include the actual results you get, and if possible the + expected results. + +* Any examples, errors, or other messages should be provided in text format + unless it is specifically related to the way Slack is rendering data. + +* Remove any sensitive information (tokens, passwords, ip addresses, etc.) from + your submission. + +### Issue Lifecycle + +1. Issue is reported + +2. A maintainer will triage the issue + +3. If it's not critical, the issue may stay inactive for a while until it gets + picked up. If you feel comfortable trying to address the issue please let us + know and take a look at the Pull Requests section below. + +4. The issue will be resolved via a pull request. We'll reference the issue in + the pull request to ensure a link exists between the issue and the code that + fixes it. + +5. Issues that we can't reproduce or where the reporter has gone inactive may + be closed after a period of time at the discretion of the maintainers. If the + issue is still relevant, we encourage re-opening the issue so it can be + revisited. + +## Pull Requests + +This section guides you through making a successful pull request. + +### Identifying an Issue + +* Pull Requests should address an existing issue. If one doesn't exist, please + create one. You don't need an issue for trivial PRs like fixing a typo. + +* Review the issue and check to see someone else hasn't already begun work on + this issue. Mention in the comments that you are interested in working on + this issue. + +* A maintainer will reach out to discuss your interest. In some cases there may + already be some progress towards this issue, or they may have a suggestion on + how they would like to see this implemented. + +* Someone will assign the issue to you to work on. There's no expectation on how + quickly you will complete this work. We may periodically ask for updates, + however. Typically we will only do this when there is other interest to + address the issue. In these situations we do expect you will respond in a + timely manner. If you fail to respond after a few requests for an update we + may re-assign the issue. + +### Submitting a PR + +* Before submitting your PR for review, run + [staticcheck](https://staticcheck.io/) against the repository and address any + identified issues. + +* Your pull request should clearly describe what it's accomplishing and how it + approaches the issue. + +* Someone will review your PR and ensure it meets these guidelines. If it does + not, we will ask you to fix the identified issues. From 8f167418caf41c1bcb7573b16e85a5f4ea6bc421 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Fri, 8 Oct 2021 00:18:35 -0700 Subject: [PATCH 17/76] Mention the contribution guidelines in the README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 63ba1c8..35e2fd6 100644 --- a/README.md +++ b/README.md @@ -885,6 +885,11 @@ func main() { } ``` +# Contributing / Submitting an Issue + +Please review our [Contribution Guidelines](CONTRIBUTING.md) if you have found +an issue with Slacker or wish to contribute to the project. + # Troubleshooting ## My bot is not responding to events From 2d310f354f7f603c91cc0dc2c686f25b6fe2ff25 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 6 Oct 2021 21:42:04 -0700 Subject: [PATCH 18/76] Use a type switch to remove multiple type assertions --- slacker.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/slacker.go b/slacker.go index 955b23b..52df934 100644 --- a/slacker.go +++ b/slacker.go @@ -352,9 +352,8 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}) { func newMessageEvent(evt interface{}) *MessageEvent { var me *MessageEvent - switch evt.(type) { + switch ev := evt.(type) { case *slackevents.MessageEvent: - ev := evt.(*slackevents.MessageEvent) me = &MessageEvent{ Channel: ev.Channel, User: ev.User, @@ -366,7 +365,6 @@ func newMessageEvent(evt interface{}) *MessageEvent { BotID: ev.BotID, } case *slackevents.AppMentionEvent: - ev := evt.(*slackevents.AppMentionEvent) me = &MessageEvent{ Channel: ev.Channel, User: ev.User, From 99e2b3b1c9dc199887e17ea86bd94a5ccac4afaf Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 6 Oct 2021 21:42:39 -0700 Subject: [PATCH 19/76] Simplify IsBot() --- context.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/context.go b/context.go index f1083da..d24a2e4 100644 --- a/context.go +++ b/context.go @@ -90,8 +90,5 @@ func (e *MessageEvent) IsThread() bool { // IsBot indicates if the message was sent by a bot func (e *MessageEvent) IsBot() bool { - if e.BotID == "" { - return false - } - return true + return e.BotID != "" } From 749d5bdbc892fe8009c8b824b628f5737e2120ff Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 7 Oct 2021 22:47:57 -0700 Subject: [PATCH 20/76] Address linting issues with errors Cleaning up issues encountered by staticcheck / golint in the code base related to errors: - Error variables should be prefixed with err or Err - Error strings should not be capitalized (except in certain cases) --- examples/10/example10.go | 4 ++-- examples/5/example5.go | 4 ++-- response.go | 2 +- slacker.go | 12 ++++++------ 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/examples/10/example10.go b/examples/10/example10.go index b98dc60..edc37cf 100644 --- a/examples/10/example10.go +++ b/examples/10/example10.go @@ -24,7 +24,7 @@ func main() { Description: "Custom!", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { response.Reply("custom") - response.ReportError(errors.New("oops")) + response.ReportError(errors.New("oops, an error occurred")) }, } @@ -76,7 +76,7 @@ func (r *MyCustomResponseWriter) Reply(message string, options ...slacker.ReplyO client := r.botCtx.Client() event := r.botCtx.Event() if event == nil { - return fmt.Errorf("Unable to get message event details") + return fmt.Errorf("unable to get message event details") } opts := []slack.MsgOption{ diff --git a/examples/5/example5.go b/examples/5/example5.go index 0e64492..2d622b6 100644 --- a/examples/5/example5.go +++ b/examples/5/example5.go @@ -15,14 +15,14 @@ func main() { messageReplyDefinition := &slacker.CommandDefinition{ Description: "Tests errors in new messages", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.ReportError(errors.New("Oops!")) + response.ReportError(errors.New("oops, an error occurred")) }, } threadReplyDefinition := &slacker.CommandDefinition{ Description: "Tests errors in threads", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.ReportError(errors.New("Oops!"), slacker.WithThreadError(true)) + response.ReportError(errors.New("oops, an error occurred"), slacker.WithThreadError(true)) }, } diff --git a/response.go b/response.go index 913cc94..84158fd 100644 --- a/response.go +++ b/response.go @@ -51,7 +51,7 @@ func (r *response) Reply(message string, options ...ReplyOption) error { client := r.botCtx.Client() ev := r.botCtx.Event() if ev == nil { - return fmt.Errorf("Unable to get message event details") + return fmt.Errorf("unable to get message event details") } opts := []slack.MsgOption{ diff --git a/slacker.go b/slacker.go index 52df934..0f0e81e 100644 --- a/slacker.go +++ b/slacker.go @@ -29,7 +29,7 @@ const ( ) var ( - unAuthorizedError = errors.New("You are not authorized to execute this command") + errUnauthorized = errors.New("you are not authorized to execute this command") ) // NewClient creates a new client using the Slack API @@ -50,7 +50,7 @@ func NewClient(botToken, appToken string, options ...ClientOption) *Slacker { client: api, socketModeClient: smc, commandChannel: make(chan *CommandEvent, 100), - unAuthorizedError: unAuthorizedError, + errUnauthorized: errUnauthorized, botInteractionMode: defaults.BotMode, } return slacker @@ -70,7 +70,7 @@ type Slacker struct { helpDefinition *CommandDefinition defaultMessageHandler func(botCtx BotContext, request Request, response ResponseWriter) defaultEventHandler func(interface{}) - unAuthorizedError error + errUnauthorized error commandChannel chan *CommandEvent appID string botInteractionMode BotInteractionMode @@ -127,8 +127,8 @@ func (s *Slacker) DefaultEvent(defaultEventHandler func(interface{})) { } // UnAuthorizedError error message -func (s *Slacker) UnAuthorizedError(unAuthorizedError error) { - s.unAuthorizedError = unAuthorizedError +func (s *Slacker) UnAuthorizedError(errUnauthorized error) { + s.errUnauthorized = errUnauthorized } // Help handle the help message, it will use the default if not set @@ -329,7 +329,7 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}) { request := s.requestConstructor(botCtx, parameters) if cmd.Definition().AuthorizationFunc != nil && !cmd.Definition().AuthorizationFunc(botCtx, request) { - response.ReportError(s.unAuthorizedError) + response.ReportError(s.errUnauthorized) return } From 43b42482249f8f5d183ae2901de05782723a396e Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Sun, 12 Dec 2021 20:51:33 -0800 Subject: [PATCH 21/76] Add staticcheck configuration --- staticcheck.conf | 1 + 1 file changed, 1 insertion(+) create mode 100644 staticcheck.conf diff --git a/staticcheck.conf b/staticcheck.conf new file mode 100644 index 0000000..5f0a348 --- /dev/null +++ b/staticcheck.conf @@ -0,0 +1 @@ +checks = ["all", "-ST1000"] From fdb99ee4777151905af90479943122b197ceac1c Mon Sep 17 00:00:00 2001 From: Marcelo Costa Date: Mon, 13 Dec 2021 09:03:46 -0600 Subject: [PATCH 22/76] allowing API consumer to override the default input sanitization function --- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ examples/17/example17.go | 33 +++++++++++++++++++++++++++++++++ slacker.go | 18 ++++++++++++++++-- staticcheck.conf | 1 + 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 examples/17/example17.go create mode 100644 staticcheck.conf diff --git a/README.md b/README.md index 35e2fd6..85a0bdb 100644 --- a/README.md +++ b/README.md @@ -885,6 +885,46 @@ func main() { } ``` +## Example 17 + +Override the default event input cleaning function (to sanitize the messages received by Slacker) + +``` +package main + +import ( + "context" + "log" + "os" + "fmt" + "strings" + + "github.com/shomali11/slacker" +) + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) + bot.CleanEventInput(func(in string) string { + fmt.Println("My slack bot does not like backticks!") + return strings.ReplaceAll(in, "`", "") + }) + + 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) + } +} +``` + # Contributing / Submitting an Issue Please review our [Contribution Guidelines](CONTRIBUTING.md) if you have found diff --git a/examples/17/example17.go b/examples/17/example17.go new file mode 100644 index 0000000..42b3efa --- /dev/null +++ b/examples/17/example17.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "log" + "os" + "fmt" + "strings" + + "github.com/shomali11/slacker" +) + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) + bot.CleanEventInput(func(in string) string { + fmt.Println("My slack bot does not like backticks!") + return strings.ReplaceAll(in, "`", "") + }) + + 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) + } +} diff --git a/slacker.go b/slacker.go index 0f0e81e..3fdfa8e 100644 --- a/slacker.go +++ b/slacker.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/shomali11/proper" "github.com/slack-go/slack" @@ -29,9 +30,13 @@ const ( ) var ( - errUnauthorized = errors.New("you are not authorized to execute this command") + 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...) @@ -52,6 +57,7 @@ func NewClient(botToken, appToken string, options ...ClientOption) *Slacker { commandChannel: make(chan *CommandEvent, 100), errUnauthorized: errUnauthorized, botInteractionMode: defaults.BotMode, + cleanEventInput: defaultCleanEventInput, } return slacker } @@ -74,6 +80,7 @@ type Slacker struct { commandChannel chan *CommandEvent appID string botInteractionMode BotInteractionMode + cleanEventInput func(in string) string } // BotCommands returns Bot Commands @@ -101,6 +108,11 @@ func (s *Slacker) Err(errorHandler func(err string)) { s.errorHandler = errorHandler } +// CleanEventInput allows the api consumer to override the default event input cleaning behavior +func (s *Slacker) CleanEventInput(cei func(in string) string) { + s.cleanEventInput = cei +} + // Interactive assigns an interactive event handler func (s *Slacker) Interactive(interactiveEventHandler func(*Slacker, *socketmode.Event, *slack.InteractionCallback)) { s.interactiveEventHandler = interactiveEventHandler @@ -321,8 +333,10 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}) { botCtx := s.botContextConstructor(ctx, s.client, s.socketModeClient, ev) response := s.responseConstructor(botCtx) + eventTxt := s.cleanEventInput(ev.Text) + for _, cmd := range s.botCommands { - parameters, isMatch := cmd.Match(ev.Text) + parameters, isMatch := cmd.Match(eventTxt) if !isMatch { continue } diff --git a/staticcheck.conf b/staticcheck.conf new file mode 100644 index 0000000..5f0a348 --- /dev/null +++ b/staticcheck.conf @@ -0,0 +1 @@ +checks = ["all", "-ST1000"] From 61c1daa26993282dab43a9a6e86e75b04baad59d Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 30 Dec 2021 12:49:08 -0800 Subject: [PATCH 23/76] Add reviewdog / staticcheck action for PR --- .github/workflows/reviewdog.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/reviewdog.yaml diff --git a/.github/workflows/reviewdog.yaml b/.github/workflows/reviewdog.yaml new file mode 100644 index 0000000..e1646fd --- /dev/null +++ b/.github/workflows/reviewdog.yaml @@ -0,0 +1,18 @@ +--- +name: reviewdog +on: [pull_request] +jobs: + staticcheck: + name: runner / staticcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: reviewdog/action-staticcheck@v1 + with: + github_token: ${{ secrets.github_token }} + # Change reviewdog reporter if you need [github-pr-check,github-check,github-pr-review]. + reporter: github-pr-review + # Report all results. + filter_mode: nofilter + # Exit with 1 when it find at least one finding. + fail_on_error: true From d3d36ce8fb8cf7a9110034b02acf671ad6a37146 Mon Sep 17 00:00:00 2001 From: Frank Ittermann Date: Thu, 24 Feb 2022 18:02:34 +0100 Subject: [PATCH 24/76] Add support for slash commands and interactive msg --- command.go | 13 +++++++++ context.go | 6 ++++ slacker.go | 80 +++++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/command.go b/command.go index b6d3220..7c4dc4c 100644 --- a/command.go +++ b/command.go @@ -3,14 +3,18 @@ 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 Example string + BlockID string AuthorizationFunc func(botCtx BotContext, request Request) bool Handler func(botCtx BotContext, request Request, response ResponseWriter) + Interactive func(*Slacker, *socketmode.Event, *slack.InteractionCallback, *socketmode.Request) } // NewBotCommand creates a new bot command object @@ -31,6 +35,7 @@ type BotCommand interface { Match(text string) (*proper.Properties, bool) Tokenize() []*commander.Token Execute(botCtx BotContext, request Request, response ResponseWriter) + Interactive(*Slacker, *socketmode.Event, *slack.InteractionCallback, *socketmode.Request) } // botCommand structure contains the bot's command, description and handler @@ -67,3 +72,11 @@ func (c *botCommand) Execute(botCtx BotContext, request Request, response Respon } c.definition.Handler(botCtx, request, response) } + +// Interactive executes the interactive logic +func (c *botCommand) Interactive(slacker *Slacker, evt *socketmode.Event, callback *slack.InteractionCallback, req *socketmode.Request) { + if c.definition == nil || c.definition.Interactive == nil { + return + } + c.definition.Interactive(slacker, evt, callback, req) +} diff --git a/context.go b/context.go index d24a2e4..860a804 100644 --- a/context.go +++ b/context.go @@ -55,9 +55,15 @@ type MessageEvent struct { // Channel ID where the message was sent Channel string + // ChannelName where the message was sent + ChannelName func() string + // User ID of the sender User string + // UserName of the the sender + UserName func() string + // Text is the unalterted text of the message, as returned by Slack Text string diff --git a/slacker.go b/slacker.go index 3fdfa8e..a3dda76 100644 --- a/slacker.go +++ b/slacker.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/shomali11/proper" "github.com/slack-go/slack" @@ -30,7 +31,7 @@ const ( ) var ( - errUnauthorized = errors.New("you are not authorized to execute this command") + errUnauthorized = errors.New("you are not authorized to execute this command") ) func defaultCleanEventInput(msg string) string { @@ -196,26 +197,29 @@ func (s *Slacker) Listen(ctx context.Context) error { switch ev.InnerEvent.Type { case "message", "app_mention": // message-based events - go s.handleMessageEvent(ctx, ev.InnerEvent.Data) + go s.handleMessageEvent(ctx, ev.InnerEvent.Data, nil) default: fmt.Printf("unsupported inner event: %+v\n", ev.InnerEvent.Type) } s.socketModeClient.Ack(*evt.Request) - case socketmode.EventTypeInteractive: - if s.interactiveEventHandler == nil { - s.unsupportedEventReceived() + case socketmode.EventTypeSlashCommand: + callback, ok := evt.Data.(slack.SlashCommand) + if !ok { + fmt.Printf("Ignored %+v\n", evt) continue } + go s.handleMessageEvent(ctx, &callback, evt.Request) + case socketmode.EventTypeInteractive: callback, ok := evt.Data.(slack.InteractionCallback) if !ok { fmt.Printf("Ignored %+v\n", evt) continue } - go s.interactiveEventHandler(s, &evt, &callback) + go s.handleInteractiveEvent(s, &evt, &callback, evt.Request) default: s.unsupportedEventReceived() } @@ -288,7 +292,20 @@ func (s *Slacker) prependHelpHandle() { s.botCommands = append([]BotCommand{NewBotCommand(helpCommand, s.helpDefinition)}, s.botCommands...) } -func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}) { +func (s *Slacker) handleInteractiveEvent(slacker *Slacker, evt *socketmode.Event, callback *slack.InteractionCallback, req *socketmode.Request) { + for _, cmd := range s.botCommands { + for _, action := range callback.ActionCallback.BlockActions { + if action.BlockID != cmd.Definition().BlockID { + continue + } + + cmd.Interactive(slacker, evt, callback, req) + return + } + } +} + +func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req *socketmode.Request) { if s.botContextConstructor == nil { s.botContextConstructor = NewBotContext } @@ -301,7 +318,7 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}) { s.responseConstructor = NewResponse } - ev := newMessageEvent(evt) + ev := newMessageEvent(s, evt, req) if ev == nil { // event doesn't appear to be a valid message type return @@ -363,14 +380,22 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}) { } } -func newMessageEvent(evt interface{}) *MessageEvent { +func newMessageEvent(slacker *Slacker, evt interface{}, req *socketmode.Request) *MessageEvent { var me *MessageEvent switch ev := evt.(type) { case *slackevents.MessageEvent: me = &MessageEvent{ - Channel: ev.Channel, - User: ev.User, + Channel: ev.Channel, + ChannelName: func() string { + channel, _ := slacker.client.GetConversationInfo(ev.Channel, true) + return channel.Name + }, + User: ev.User, + UserName: func() string { + username, _ := slacker.client.GetUserInfo(ev.User) + return username.Name + }, Text: ev.Text, Data: evt, Type: ev.Type, @@ -380,8 +405,16 @@ func newMessageEvent(evt interface{}) *MessageEvent { } case *slackevents.AppMentionEvent: me = &MessageEvent{ - Channel: ev.Channel, - User: ev.User, + Channel: ev.Channel, + ChannelName: func() string { + channel, _ := slacker.client.GetConversationInfo(ev.Channel, true) + return channel.Name + }, + User: ev.User, + UserName: func() string { + username, _ := slacker.client.GetUserInfo(ev.User) + return username.Name + }, Text: ev.Text, Data: evt, Type: ev.Type, @@ -389,6 +422,27 @@ func newMessageEvent(evt interface{}) *MessageEvent { ThreadTimeStamp: ev.ThreadTimeStamp, BotID: ev.BotID, } + case *slack.SlashCommand: + me = &MessageEvent{ + Channel: ev.ChannelID, + ChannelName: func() string { return ev.ChannelName }, + User: ev.UserID, + UserName: func() string { return ev.UserName }, + Text: fmt.Sprintf("%s %s", ev.Command[1:], ev.Text), + Data: req, + Type: req.Type, + //TODO get time from slash command + TimeStamp: fmt.Sprint(time.Now()), + ThreadTimeStamp: fmt.Sprint(time.Now()), + } + } + + // Filter out other bots. At the very least this is needed for MessageEvent + // to prevent the bot from self-triggering and causing loops. However better + // logic should be in place to prevent repeated self-triggering / bot-storms + // if we want to enable this later. + if me.IsBot() { + return nil } return me From df965fa6df3fb8903a6cbc19fc51cd3d23b1998d Mon Sep 17 00:00:00 2001 From: Frank Ittermann Date: Tue, 28 Jun 2022 11:23:13 +0200 Subject: [PATCH 25/76] Eager fetching of additional data for consistency The channel name and username is fetch eager as part of the slack message parsing to have a clear and consistent MessageEvent struct. --- context.go | 4 ++-- slacker.go | 50 ++++++++++++++++++++++++++++---------------------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/context.go b/context.go index 860a804..eb611fe 100644 --- a/context.go +++ b/context.go @@ -56,13 +56,13 @@ type MessageEvent struct { Channel string // ChannelName where the message was sent - ChannelName func() string + ChannelName string // User ID of the sender User string // UserName of the the sender - UserName func() string + UserName string // Text is the unalterted text of the message, as returned by Slack Text string diff --git a/slacker.go b/slacker.go index a3dda76..506bc6e 100644 --- a/slacker.go +++ b/slacker.go @@ -380,22 +380,34 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * } } +func getChannelName(slacker *Slacker, channelID string) string { + channel, err := slacker.client.GetConversationInfo(channelID, true) + if err != nil { + fmt.Printf("unable to get channel info for %s: %v", channelID, err) + return channelID + } + return channel.Name +} + +func getUserName(slacker *Slacker, userID string) string { + user, err := slacker.client.GetUserInfo(userID) + if err != nil { + fmt.Printf("unable to get user info for %s: %v", userID, err) + return userID + } + return user.Name +} + func newMessageEvent(slacker *Slacker, evt interface{}, req *socketmode.Request) *MessageEvent { var me *MessageEvent switch ev := evt.(type) { case *slackevents.MessageEvent: me = &MessageEvent{ - Channel: ev.Channel, - ChannelName: func() string { - channel, _ := slacker.client.GetConversationInfo(ev.Channel, true) - return channel.Name - }, - User: ev.User, - UserName: func() string { - username, _ := slacker.client.GetUserInfo(ev.User) - return username.Name - }, + Channel: ev.Channel, + ChannelName: getChannelName(slacker, ev.Channel), + User: ev.User, + UserName: getUserName(slacker, ev.User), Text: ev.Text, Data: evt, Type: ev.Type, @@ -405,16 +417,10 @@ func newMessageEvent(slacker *Slacker, evt interface{}, req *socketmode.Request) } case *slackevents.AppMentionEvent: me = &MessageEvent{ - Channel: ev.Channel, - ChannelName: func() string { - channel, _ := slacker.client.GetConversationInfo(ev.Channel, true) - return channel.Name - }, - User: ev.User, - UserName: func() string { - username, _ := slacker.client.GetUserInfo(ev.User) - return username.Name - }, + Channel: ev.Channel, + ChannelName: getChannelName(slacker, ev.Channel), + User: ev.User, + UserName: getUserName(slacker, ev.User), Text: ev.Text, Data: evt, Type: ev.Type, @@ -425,9 +431,9 @@ func newMessageEvent(slacker *Slacker, evt interface{}, req *socketmode.Request) case *slack.SlashCommand: me = &MessageEvent{ Channel: ev.ChannelID, - ChannelName: func() string { return ev.ChannelName }, + ChannelName: ev.ChannelName, User: ev.UserID, - UserName: func() string { return ev.UserName }, + UserName: ev.UserName, Text: fmt.Sprintf("%s %s", ev.Command[1:], ev.Text), Data: req, Type: req.Type, From 0abe5a0ac3db966c942481a4e791a1b4b90c5a79 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 30 Jun 2022 17:36:54 -0700 Subject: [PATCH 26/76] Ack slash commands on receipt --- slacker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slacker.go b/slacker.go index 506bc6e..61e6f60 100644 --- a/slacker.go +++ b/slacker.go @@ -210,7 +210,7 @@ func (s *Slacker) Listen(ctx context.Context) error { fmt.Printf("Ignored %+v\n", evt) continue } - + s.socketModeClient.Ack(*evt.Request) go s.handleMessageEvent(ctx, &callback, evt.Request) case socketmode.EventTypeInteractive: callback, ok := evt.Data.(slack.InteractionCallback) From fb417eec3e938eb15556bdd6d23cdfdd39c1c0c1 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 30 Jun 2022 17:38:22 -0700 Subject: [PATCH 27/76] Add newlines to log messages --- slacker.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/slacker.go b/slacker.go index 61e6f60..62b5f8c 100644 --- a/slacker.go +++ b/slacker.go @@ -330,7 +330,7 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * if err.Error() == "missing_scope" { fmt.Println("unable to determine if bot response is from me -- please add users:read scope to your app") } else { - fmt.Printf("unable to get bot that sent message information: %v", err) + fmt.Printf("unable to get bot that sent message information: %v\n", err) } return } @@ -383,7 +383,7 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * func getChannelName(slacker *Slacker, channelID string) string { channel, err := slacker.client.GetConversationInfo(channelID, true) if err != nil { - fmt.Printf("unable to get channel info for %s: %v", channelID, err) + fmt.Printf("unable to get channel info for %s: %v\n", channelID, err) return channelID } return channel.Name @@ -392,7 +392,7 @@ func getChannelName(slacker *Slacker, channelID string) string { func getUserName(slacker *Slacker, userID string) string { user, err := slacker.client.GetUserInfo(userID) if err != nil { - fmt.Printf("unable to get user info for %s: %v", userID, err) + fmt.Printf("unable to get user info for %s: %v\n", userID, err) return userID } return user.Name From 518032fea7969951ad0fd8f795ac6b97334cde65 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 30 Jun 2022 17:47:33 -0700 Subject: [PATCH 28/76] Add example18 exercising slash cmd / interactive handling --- examples/18/example18.go | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 examples/18/example18.go diff --git a/examples/18/example18.go b/examples/18/example18.go new file mode 100644 index 0000000..d6fd648 --- /dev/null +++ b/examples/18/example18.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/shomali11/slacker" + "github.com/slack-go/slack" + "github.com/slack-go/slack/socketmode" +) + +func slackerCmd(actionID string) func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + return func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + happyBtn := slack.NewButtonBlockElement("happy", "true", slack.NewTextBlockObject("plain_text", "Happy 🙂", true, false)) + happyBtn.Style = "primary" + sadBtn := slack.NewButtonBlockElement("sad", "false", slack.NewTextBlockObject("plain_text", "Sad â˜šī¸", true, false)) + sadBtn.Style = "danger" + + err := response.Reply("", slacker.WithBlocks([]slack.Block{ + slack.NewSectionBlock(slack.NewTextBlockObject(slack.PlainTextType, "What is your mood today?", true, false), nil, nil), + slack.NewActionBlock(actionID, happyBtn, sadBtn), + })) + + if err != nil { + fmt.Println(err) + } + } +} + +func slackerInteractive(s *slacker.Slacker, e *socketmode.Event, callback *slack.InteractionCallback, request *socketmode.Request) { + text := "" + action := callback.ActionCallback.BlockActions[0] + switch action.ActionID { + case "happy": + text = "I'm happy to hear you are happy!" + case "sad": + text = "I'm sorry to hear you are sad." + default: + text = "I don't understand your mood..." + } + + _, _, _ = s.Client().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), + slack.MsgOptionReplaceOriginal(callback.ResponseURL)) +} + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot.Command("slacker-cmd", &slacker.CommandDefinition{ + BlockID: "slacker_cmd", + Handler: slackerCmd("slacker_cmd"), + Interactive: slackerInteractive, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} From 953ab55d6b6bbe48fa5a8600b1b9180cd00a5c18 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 30 Jun 2022 20:50:26 -0700 Subject: [PATCH 29/76] chore: Add dependabot --- .github/dependabot.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..36b24f2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: daily From da1a1f55cc770c52f2b04c54c60f87574cd2e36a Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 30 Jun 2022 21:12:21 -0700 Subject: [PATCH 30/76] fix: Slacker.Interactive is not honored If no command definitions have a handler matching the interactive block id, the global will be called if it is set. --- slacker.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/slacker.go b/slacker.go index 62b5f8c..e582728 100644 --- a/slacker.go +++ b/slacker.go @@ -303,6 +303,10 @@ func (s *Slacker) handleInteractiveEvent(slacker *Slacker, evt *socketmode.Event return } } + + if s.interactiveEventHandler != nil { + s.interactiveEventHandler(slacker, evt, callback) + } } func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req *socketmode.Request) { From a9669da854d006114609aabb137582441edc16c4 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 30 Jun 2022 21:44:04 -0700 Subject: [PATCH 31/76] fix: Do not set TimeStamps for SlashCommands SlashCommands do not allow for threading and do not send a timestamp, so leave this unset since there's no real value in doing so. --- slacker.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/slacker.go b/slacker.go index e582728..ca20667 100644 --- a/slacker.go +++ b/slacker.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "strings" - "time" "github.com/shomali11/proper" "github.com/slack-go/slack" @@ -441,9 +440,6 @@ func newMessageEvent(slacker *Slacker, evt interface{}, req *socketmode.Request) Text: fmt.Sprintf("%s %s", ev.Command[1:], ev.Text), Data: req, Type: req.Type, - //TODO get time from slash command - TimeStamp: fmt.Sprint(time.Now()), - ThreadTimeStamp: fmt.Sprint(time.Now()), } } From 69686140f2d9f7291f784a2acbf5ddca39c0b797 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 30 Jun 2022 21:50:52 -0700 Subject: [PATCH 32/76] refactor: Split MessageEvent into message_event.go --- context.go | 52 -------------------------------------------- message_event.go | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 52 deletions(-) create mode 100644 message_event.go diff --git a/context.go b/context.go index eb611fe..9fb8042 100644 --- a/context.go +++ b/context.go @@ -46,55 +46,3 @@ func (r *botContext) SocketMode() *socketmode.Client { func (r *botContext) Client() *slack.Client { return r.client } - -// MessageEvent contains details common to message based events, including the -// raw event as returned from Slack along with the corresponding event type. -// The struct should be kept minimal and only include data that is commonly -// used to prevent freqeuent type assertions when evaluating the event. -type MessageEvent struct { - // Channel ID where the message was sent - Channel string - - // ChannelName where the message was sent - ChannelName string - - // User ID of the sender - User string - - // UserName of the the sender - UserName string - - // Text is the unalterted text of the message, as returned by Slack - Text string - - // TimeStamp is the message timestamp - TimeStamp string - - // ThreadTimeStamp is the message thread timestamp. - ThreadTimeStamp string - - // Data is the raw event data returned from slack. Using Type, you can assert - // this into a slackevents *Event struct. - Data interface{} - - // Type is the type of the event, as returned by Slack. For instance, - // `app_mention` or `message` - Type string - - // BotID of the bot that sent this message. If a bot did not send this - // message, this will be an empty string. - BotID string -} - -// IsThread indicates if a message event took place in a thread. -func (e *MessageEvent) IsThread() bool { - if e.ThreadTimeStamp == "" || e.ThreadTimeStamp == e.TimeStamp { - return false - } - return true -} - -// IsBot indicates if the message was sent by a bot -func (e *MessageEvent) IsBot() bool { - return e.BotID != "" -} diff --git a/message_event.go b/message_event.go new file mode 100644 index 0000000..2692c39 --- /dev/null +++ b/message_event.go @@ -0,0 +1,56 @@ +package slacker + +// MessageEvent contains details common to message based events, including the +// raw event as returned from Slack along with the corresponding event type. +// The struct should be kept minimal and only include data that is commonly +// used to prevent frequent type assertions when evaluating the event. +type MessageEvent struct { + // Channel ID where the message was sent + Channel string + + // ChannelName where the message was sent + ChannelName string + + // User ID of the sender + User string + + // UserName of the the sender + UserName string + + // Text is the unalterted text of the message, as returned by Slack + Text string + + // TimeStamp is the message timestamp. For events that do not support + // threading (eg. slash commands) this will be unset. + // will be left unset. + TimeStamp string + + // ThreadTimeStamp is the message thread timestamp. For events that do not + // support threading (eg. slash commands) this will be unset. + ThreadTimeStamp string + + // Data is the raw event data returned from slack. Using Type, you can assert + // this into a slackevents *Event struct. + Data interface{} + + // Type is the type of the event, as returned by Slack. For instance, + // `app_mention` or `message` + Type string + + // BotID of the bot that sent this message. If a bot did not send this + // message, this will be an empty string. + BotID string +} + +// IsThread indicates if a message event took place in a thread. +func (e *MessageEvent) IsThread() bool { + if e.ThreadTimeStamp == "" || e.ThreadTimeStamp == e.TimeStamp { + return false + } + return true +} + +// IsBot indicates if the message was sent by a bot +func (e *MessageEvent) IsBot() bool { + return e.BotID != "" +} From bd2baec166dd79195ce31b4465b51d1ca2f0ceaa Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 30 Jun 2022 22:17:15 -0700 Subject: [PATCH 33/76] feat: Allow hiding of commands from help menu --- command.go | 4 ++++ examples/18/example18.go | 1 + slacker.go | 3 +++ 3 files changed, 8 insertions(+) diff --git a/command.go b/command.go index 7c4dc4c..a6c38f1 100644 --- a/command.go +++ b/command.go @@ -15,6 +15,10 @@ type CommandDefinition struct { AuthorizationFunc func(botCtx BotContext, request Request) bool Handler func(botCtx BotContext, request Request, response ResponseWriter) Interactive func(*Slacker, *socketmode.Event, *slack.InteractionCallback, *socketmode.Request) + + // HideHelp will cause this command to not be shown when a user requests + // help. + HideHelp bool } // NewBotCommand creates a new bot command object diff --git a/examples/18/example18.go b/examples/18/example18.go index d6fd648..96cabf9 100644 --- a/examples/18/example18.go +++ b/examples/18/example18.go @@ -51,6 +51,7 @@ func main() { BlockID: "slacker_cmd", Handler: slackerCmd("slacker_cmd"), Interactive: slackerInteractive, + Hide: true, }) ctx, cancel := context.WithCancel(context.Background()) diff --git a/slacker.go b/slacker.go index ca20667..890c29f 100644 --- a/slacker.go +++ b/slacker.go @@ -244,6 +244,9 @@ func (s *Slacker) defaultHelp(botCtx BotContext, request Request, response Respo authorizedCommandAvailable := false helpMessage := empty for _, command := range s.botCommands { + if command.Definition().HideHelp { + continue + } tokens := command.Tokenize() for _, token := range tokens { if token.IsParameter() { From 0f12da0a77e58f4320c9fca741bbff6088827a02 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 30 Jun 2022 23:29:50 -0700 Subject: [PATCH 34/76] docs: Update examples - Update 7 to randomly succeed - Clarify confusing behavior in 9 - Remove debug mode from 17 - Fix 18 --- examples/17/example17.go | 32 ++++++++++++++++---------------- examples/18/example18.go | 2 +- examples/7/example7.go | 7 +++++-- examples/9/example9.go | 6 ++++-- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/examples/17/example17.go b/examples/17/example17.go index 42b3efa..b8f4853 100644 --- a/examples/17/example17.go +++ b/examples/17/example17.go @@ -1,33 +1,33 @@ package main import ( - "context" - "log" - "os" + "context" "fmt" + "log" + "os" "strings" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker" ) func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) bot.CleanEventInput(func(in string) string { fmt.Println("My slack bot does not like backticks!") return strings.ReplaceAll(in, "`", "") }) - bot.Command("my-command", &slacker.CommandDefinition{ - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("it works!") - }, - }) + bot.Command("my-command", &slacker.CommandDefinition{ + Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + response.Reply("it works!") + }, + }) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } } diff --git a/examples/18/example18.go b/examples/18/example18.go index 96cabf9..adf011a 100644 --- a/examples/18/example18.go +++ b/examples/18/example18.go @@ -51,7 +51,7 @@ func main() { BlockID: "slacker_cmd", Handler: slackerCmd("slacker_cmd"), Interactive: slackerInteractive, - Hide: true, + HideHelp: true, }) ctx, cancel := context.WithCancel(context.Background()) diff --git a/examples/7/example7.go b/examples/7/example7.go index cbd9638..e7f0bc0 100644 --- a/examples/7/example7.go +++ b/examples/7/example7.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log" + "math/rand" "os" "time" @@ -16,13 +17,15 @@ func main() { definition := &slacker.CommandDefinition{ Description: "Process!", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - timedContext, cancel := context.WithTimeout(botCtx.Context(), time.Second) + timedContext, cancel := context.WithTimeout(botCtx.Context(), 5*time.Second) defer cancel() + duration := time.Duration(rand.Int()%10+1) * time.Second + select { case <-timedContext.Done(): response.ReportError(errors.New("timed out")) - case <-time.After(time.Minute): + case <-time.After(duration): response.Reply("Processing done!") } }, diff --git a/examples/9/example9.go b/examples/9/example9.go index cb13aaf..ce0196b 100644 --- a/examples/9/example9.go +++ b/examples/9/example9.go @@ -19,10 +19,12 @@ func main() { attachments := []slack.Block{} attachments = append(attachments, slack.NewContextBlock("1", - slack.NewTextBlockObject("mrkdwn", "Hi!", false, false)), + slack.NewTextBlockObject("mrkdwn", word, false, false)), ) - response.Reply(word, slacker.WithBlocks(attachments)) + // When using blocks the message argument will be thrown away and can be + // left blank. + response.Reply("", slacker.WithBlocks(attachments)) }, } From 3318efb4575737671f7eb1324ded409ad246d9b0 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 30 Jun 2022 23:40:29 -0700 Subject: [PATCH 35/76] Bump slack-go/slack to v0.11.0 --- go.mod | 2 +- go.sum | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 5eb5f39..faa952e 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,6 @@ require ( github.com/pkg/errors v0.8.1 // indirect github.com/shomali11/commander v0.0.0-20191122162317-51bc574c29ba github.com/shomali11/proper v0.0.0-20180607004733-233a9a872c30 - github.com/slack-go/slack v0.9.1 + github.com/slack-go/slack v0.11.0 github.com/stretchr/testify v1.3.0 // indirect ) diff --git a/go.sum b/go.sum index 8c3ffb2..cfcbbac 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -16,8 +17,11 @@ github.com/shomali11/proper v0.0.0-20180607004733-233a9a872c30 h1:56awf1OXG6Jc2P github.com/shomali11/proper v0.0.0-20180607004733-233a9a872c30/go.mod h1:O723XwIZBX3FR45rBic/Eyp/DKo/YtchYFURzpUWY2c= github.com/slack-go/slack v0.9.1 h1:pekQBs0RmrdAgoqzcMCzUCWSyIkhzUU3F83ExAdZrKo= github.com/slack-go/slack v0.9.1/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= +github.com/slack-go/slack v0.11.0 h1:sBBjQz8LY++6eeWhGJNZpRm5jvLRNnWBFZ/cAq58a6k= +github.com/slack-go/slack v0.11.0/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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 106f32c003eb2f9e121419eead853383f814e5e7 Mon Sep 17 00:00:00 2001 From: Asaf Alima Date: Thu, 30 Jun 2022 23:55:12 -0700 Subject: [PATCH 36/76] feat: Allow custom bot context creation Allow specification of a custom bot context constructor to override the default one. --- slacker.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/slacker.go b/slacker.go index 890c29f..5985542 100644 --- a/slacker.go +++ b/slacker.go @@ -118,6 +118,11 @@ func (s *Slacker) Interactive(interactiveEventHandler func(*Slacker, *socketmode s.interactiveEventHandler = interactiveEventHandler } +// CustomBotContext creates a new bot context +func (s *Slacker) CustomBotContext(botContextConstructor func(ctx context.Context, api *slack.Client, client *socketmode.Client, evt *MessageEvent) BotContext) { + s.botContextConstructor = botContextConstructor +} + // CustomRequest creates a new request func (s *Slacker) CustomRequest(requestConstructor func(botCtx BotContext, properties *proper.Properties) Request) { s.requestConstructor = requestConstructor From 5ceecde95c3527e29ce5aeb701a9cd4f4696c7a2 Mon Sep 17 00:00:00 2001 From: Asaf Alima Date: Thu, 30 Jun 2022 23:56:10 -0700 Subject: [PATCH 37/76] fix: Use default event handler when set --- slacker.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/slacker.go b/slacker.go index 5985542..f47a3a3 100644 --- a/slacker.go +++ b/slacker.go @@ -225,7 +225,11 @@ func (s *Slacker) Listen(ctx context.Context) error { go s.handleInteractiveEvent(s, &evt, &callback, evt.Request) default: - s.unsupportedEventReceived() + if s.defaultEventHandler != nil { + s.defaultEventHandler(evt) + } else { + s.unsupportedEventReceived() + } } } } From 947ccdee2feae8df2fc99c3807c827601cdca1df Mon Sep 17 00:00:00 2001 From: Asaf Alima Date: Wed, 10 Nov 2021 15:18:54 +0200 Subject: [PATCH 38/76] feat: Allow custom bot command constructor --- slacker.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/slacker.go b/slacker.go index f47a3a3..5dcc607 100644 --- a/slacker.go +++ b/slacker.go @@ -68,6 +68,7 @@ type Slacker struct { socketModeClient *socketmode.Client botCommands []BotCommand botContextConstructor func(ctx context.Context, api *slack.Client, client *socketmode.Client, evt *MessageEvent) BotContext + commandConstructor func(usage string, definition *CommandDefinition) BotCommand requestConstructor func(botCtx BotContext, properties *proper.Properties) Request responseConstructor func(botCtx BotContext) ResponseWriter initHandler func() @@ -123,6 +124,11 @@ func (s *Slacker) CustomBotContext(botContextConstructor func(ctx context.Contex s.botContextConstructor = botContextConstructor } +// CustomCommand creates a new BotCommand +func (s *Slacker) CustomCommand(commandConstructor func(usage string, definition *CommandDefinition) BotCommand) { + s.commandConstructor = commandConstructor +} + // CustomRequest creates a new request func (s *Slacker) CustomRequest(requestConstructor func(botCtx BotContext, properties *proper.Properties) Request) { s.requestConstructor = requestConstructor @@ -155,7 +161,10 @@ func (s *Slacker) Help(definition *CommandDefinition) { // Command define a new command and append it to the list of existing commands func (s *Slacker) Command(usage string, definition *CommandDefinition) { - s.botCommands = append(s.botCommands, NewBotCommand(usage, definition)) + if s.commandConstructor == nil { + s.commandConstructor = NewBotCommand + } + s.botCommands = append(s.botCommands, s.commandConstructor(usage, definition)) } // CommandEvents returns read only command events channel From 55d24e684e667a5ae5ca2f11eaa71b2bd59e9557 Mon Sep 17 00:00:00 2001 From: Asaf Alima Date: Wed, 15 Dec 2021 13:23:24 +0200 Subject: [PATCH 39/76] docs: Add example for custom command constructor --- README.md | 70 ++++++++++++++++++++++++++++++++++++++++ examples/19/example19.go | 65 +++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 examples/19/example19.go diff --git a/README.md b/README.md index 85a0bdb..c7912cd 100644 --- a/README.md +++ b/README.md @@ -925,6 +925,76 @@ func main() { } ``` +## 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" + "github.com/shomali11/commander" + "github.com/shomali11/proper" + "github.com/shomali11/slacker" + "log" + "os" +) + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) + bot.CustomCommand(func(usage string, definition *slacker.CommandDefinition) slacker.BotCommand { + 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().User) + c.definition.Handler(botCtx, request, response) +} +``` # Contributing / Submitting an Issue Please review our [Contribution Guidelines](CONTRIBUTING.md) if you have found diff --git a/examples/19/example19.go b/examples/19/example19.go new file mode 100644 index 0000000..18c6409 --- /dev/null +++ b/examples/19/example19.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/shomali11/commander" + "github.com/shomali11/proper" + "github.com/shomali11/slacker" +) + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) + bot.CustomCommand(func(usage string, definition *slacker.CommandDefinition) slacker.BotCommand { + 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().User) + c.definition.Handler(botCtx, request, response) +} From b3d6cafa463d0c9a75a5c146c2df284408c35526 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Fri, 1 Jul 2022 12:31:20 -0700 Subject: [PATCH 40/76] fix: Update example 19 to match bot command interface --- examples/19/example19.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/19/example19.go b/examples/19/example19.go index 18c6409..0e684ff 100644 --- a/examples/19/example19.go +++ b/examples/19/example19.go @@ -9,6 +9,8 @@ import ( "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() { @@ -63,3 +65,6 @@ func (c *cmd) Execute(botCtx slacker.BotContext, request slacker.Request, respon log.Printf("Executing command [%s] invoked by %s", c.usage, botCtx.Event().User) c.definition.Handler(botCtx, request, response) } + +func (c *cmd) Interactive(*slacker.Slacker, *socketmode.Event, *slack.InteractionCallback, *socketmode.Request) { +} From 55d33dfce27eb3306ff53c7fc0c036ce121ff8d6 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 30 Jun 2022 22:29:10 -0700 Subject: [PATCH 41/76] chore: Add goreleaser --- .github/workflows/goreleaser.yaml | 30 ++++++++++++++++++++++++ .gitignore | 3 ++- .goreleaser.yaml | 39 +++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/goreleaser.yaml create mode 100644 .goreleaser.yaml diff --git a/.github/workflows/goreleaser.yaml b/.github/workflows/goreleaser.yaml new file mode 100644 index 0000000..191f7f6 --- /dev/null +++ b/.github/workflows/goreleaser.yaml @@ -0,0 +1,30 @@ +--- +name: goreleaser + +on: + push: + tags: + - '*' + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - + name: Set up Go + uses: actions/setup-go@v3 + - + name: Run GoReleaser + uses: goreleaser/goreleaser-action@v3 + with: + # either 'goreleaser' (default) or 'goreleaser-pro' + distribution: goreleaser + version: latest + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 0a95508..61d2566 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea/ -.vscode/ \ No newline at end of file +.vscode/ +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..d971002 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,39 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com +builds: + - skip: true +before: + hooks: + # You may remove this if you don't use go modules. + - go mod tidy +archives: + - replacements: + darwin: Darwin + linux: Linux + windows: Windows + 386: i386 + amd64: x86_64 +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ incpatch .Version }}-next" +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + - '^Merge branch' + - '^Merge pull request' + - '^Merge remote-tracking branch' + + groups: + - title: Features + regexp: "^.*feat[(\\w)]*:+.*$" + order: 0 + - title: 'Bug fixes' + regexp: "^.*fix[(\\w)]*:+.*$" + order: 1 + - title: Others + order: 999 From 45c6222bcb4027e237a4781e9deed146b6e62a45 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Mon, 4 Jul 2022 16:08:42 -0700 Subject: [PATCH 42/76] chore: README cleanup --- README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 85a0bdb..991ff51 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # slacker [![Build Status](https://travis-ci.com/shomali11/slacker.svg?branch=master)](https://travis-ci.com/shomali11/slacker) [![Go Report Card](https://goreportcard.com/badge/github.com/shomali11/slacker)](https://goreportcard.com/report/github.com/shomali11/slacker) [![GoDoc](https://godoc.org/github.com/shomali11/slacker?status.svg)](https://godoc.org/github.com/shomali11/slacker) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) -Built on top of the Slack API [github.com/slack-go/slack](https://github.com/slack-go/slack) with the idea to simplify the Real-Time Messaging feature to easily create Slack Bots, assign commands to them and extract parameters. +Built on top of the Slack API [github.com/slack-go/slack](https://github.com/slack-go/slack), Slacker is a low-friction framework for creating Slack Bots. ## Features - Supports Slack Apps using [Socket Mode](https://api.slack.com/apis/connections/socket) - Easy definitions of commands and their input +- Slash Command and Block Interactions supported - Available bot initialization, errors and default handlers - Simple parsing of String, Integer, Float and Boolean parameters - Contains support for `context.Context` @@ -17,11 +18,6 @@ Built on top of the Slack API [github.com/slack-go/slack](https://github.com/sla - Produces events for executed commands - Full access to the Slack API [github.com/slack-go/slack](https://github.com/slack-go/slack) -## Dependencies - -- `commander` [github.com/shomali11/commander](https://github.com/shomali11/commander) -- `slack` [github.com/slack-go/slack](https://github.com/slack-go/slack) - # Install ``` From 37ee29bf909a07f36042d1c59bbf091c0c81a60a Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Mon, 4 Jul 2022 16:14:42 -0700 Subject: [PATCH 43/76] chore: Add note about commit contents --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a50090d..49fe11e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,6 +75,8 @@ This section guides you through making a successful pull request. [staticcheck](https://staticcheck.io/) against the repository and address any identified issues. +* Each commit should represent a single feature or fix. + * Your pull request should clearly describe what it's accomplishing and how it approaches the issue. From 1348af555aacebbf830c9909c4eb839a9f2cc238 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Mon, 4 Jul 2022 16:40:49 -0700 Subject: [PATCH 44/76] chore: Add maintainer docs --- MAINTAINING.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 MAINTAINING.md diff --git a/MAINTAINING.md b/MAINTAINING.md new file mode 100644 index 0000000..7a95b44 --- /dev/null +++ b/MAINTAINING.md @@ -0,0 +1,34 @@ +Docs for slacker maintainers + +## Releases + +Releases of Slacker are handled by [goreleaser](https://goreleaser.com) and +Github actions. Simply tagging a release with a semver compatible version tag +(ie. vX.Y.Z) and pushing the tag will trigger a Github action to generate a +release. See the goreleaser [config](.goreleaser.yaml) and Github +[workflow](.github/workflows/.goreleaser.yaml) files. + +### Changelogs + +goreleaser handles generating our changelog based on the commit subject of each +commit. + +Commits that start with `feat:` are grouped into a "Features" section, while +those that start with `fix:` will be grouped into a "Bug fixes" section. Commits +that begin with `chore:` or `docs:` will be excluded, and all others will be +added to an "Others" section in the changelog. + +When reviewing pull requests or committing code, it is strongly encouraged to +use one of the aformentioned prefixes so that changelogs are nicely formatted +and organized. + +## Commit Messages + +To maintain a tidy changelog on release, we should encourage the use of the +following commit subject prefixes (see the Changelogs for details on how they +are used) + +- `feat`: New features +- `fix`: Bug fixes +- `docs`: Usage documentation changes (ie. examples, README) +- `chore`: Housekeeping taks that don't touch code or usage docs From ef33152ba7bc91c9bd8f9aef7f82cb6e036dee3c Mon Sep 17 00:00:00 2001 From: Marco Vito Moscaritolo Date: Fri, 8 Jul 2022 17:45:15 +0200 Subject: [PATCH 45/76] Fix code formatting in README.md The README.md is missing of go language formatting in the Example 17 block --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bc240aa..3171caa 100644 --- a/README.md +++ b/README.md @@ -885,7 +885,7 @@ func main() { Override the default event input cleaning function (to sanitize the messages received by Slacker) -``` +```go package main import ( From b07a424ba87f9b2351af86b0223e60af61df6ac8 Mon Sep 17 00:00:00 2001 From: Raed Shomali Date: Fri, 15 Jul 2022 22:38:49 -0400 Subject: [PATCH 46/76] Introduce new {word} vs parameters --- README.md | 31 +++++++++++++++++++------------ examples/14/example14.go | 2 +- examples/16/example16.go | 40 ++++++++++++++++++++-------------------- examples/3/example3.go | 13 ++++++++++--- examples/4/example4.go | 2 +- examples/6/example6.go | 8 ++++---- examples/8/example8.go | 2 +- examples/9/example9.go | 2 +- go.mod | 3 +-- go.sum | 11 ++++------- 10 files changed, 62 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 3171caa..b8da77e 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ func main() { ## Example 3 -Defining a command with a parameter +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 @@ -163,16 +163,23 @@ import ( func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - definition := &slacker.CommandDefinition{ + bot.Command("echo {word}", &slacker.CommandDefinition{ Description: "Echo a word!", Example: "echo hello", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { word := request.Param("word") response.Reply(word) }, - } + }) - bot.Command("echo ", definition) + bot.Command("say ", &slacker.CommandDefinition{ + Description: "Say a sentence!", + Example: "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() @@ -215,7 +222,7 @@ func main() { }, } - bot.Command("repeat ", definition) + bot.Command("repeat {word} {number}", definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -294,15 +301,15 @@ func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) definition := &slacker.CommandDefinition{ - Description: "Upload a word!", + Description: "Upload a sentence!", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.Param("word") + sentence := request.Param("sentence") client := botCtx.Client() ev := botCtx.Event() if ev.Channel != "" { client.PostMessage(ev.Channel, slack.MsgOptionText("Uploading file ...", false)) - _, err := client.UploadFile(slack.FileUploadParameters{Content: word, Channels: []string{ev.Channel}}) + _, err := client.UploadFile(slack.FileUploadParameters{Content: sentence, Channels: []string{ev.Channel}}) if err != nil { fmt.Printf("Error encountered when uploading file: %+v\n", err) } @@ -310,7 +317,7 @@ func main() { }, } - bot.Command("upload ", definition) + bot.Command("upload ", definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -405,7 +412,7 @@ func main() { }, } - bot.Command("echo ", definition) + bot.Command("echo {word}", definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -450,7 +457,7 @@ func main() { }, } - bot.Command("echo ", definition) + bot.Command("echo {word}", definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -746,7 +753,7 @@ func main() { }, }) - bot.Command("echo ", &slacker.CommandDefinition{ + bot.Command("echo {word}", &slacker.CommandDefinition{ Description: "Echo a word!", Example: "echo hello", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { diff --git a/examples/14/example14.go b/examples/14/example14.go index 3d584b2..68d410f 100644 --- a/examples/14/example14.go +++ b/examples/14/example14.go @@ -32,7 +32,7 @@ func main() { }, }) - bot.Command("echo ", &slacker.CommandDefinition{ + bot.Command("echo {word}", &slacker.CommandDefinition{ Description: "Echo a word!", Example: "echo hello", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { diff --git a/examples/16/example16.go b/examples/16/example16.go index 4d72065..12d1b33 100644 --- a/examples/16/example16.go +++ b/examples/16/example16.go @@ -1,31 +1,31 @@ package main import ( - "context" - "log" - "os" + "context" + "log" + "os" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker" ) func main() { - bot := slacker.NewClient( - os.Getenv("SLACK_BOT_TOKEN"), - os.Getenv("SLACK_APP_TOKEN"), - slacker.WithBotInteractionMode(slacker.BotInteractionModeIgnoreApp), - ) + bot := 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!") - }, - }) + 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() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } } diff --git a/examples/3/example3.go b/examples/3/example3.go index bc7ab56..607b3a4 100644 --- a/examples/3/example3.go +++ b/examples/3/example3.go @@ -11,16 +11,23 @@ import ( func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - definition := &slacker.CommandDefinition{ + bot.Command("echo {word}", &slacker.CommandDefinition{ Description: "Echo a word!", Example: "echo hello", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { word := request.Param("word") response.Reply(word) }, - } + }) - bot.Command("echo ", definition) + bot.Command("say ", &slacker.CommandDefinition{ + Description: "Say a sentence!", + Example: "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() diff --git a/examples/4/example4.go b/examples/4/example4.go index 69a6e48..9104ab2 100644 --- a/examples/4/example4.go +++ b/examples/4/example4.go @@ -23,7 +23,7 @@ func main() { }, } - bot.Command("repeat ", definition) + bot.Command("repeat {word} {number}", definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/6/example6.go b/examples/6/example6.go index b28b00d..bf3a28c 100644 --- a/examples/6/example6.go +++ b/examples/6/example6.go @@ -14,15 +14,15 @@ func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) definition := &slacker.CommandDefinition{ - Description: "Upload a word!", + Description: "Upload a sentence!", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.Param("word") + sentence := request.Param("sentence") client := botCtx.Client() ev := botCtx.Event() if ev.Channel != "" { client.PostMessage(ev.Channel, slack.MsgOptionText("Uploading file ...", false)) - _, err := client.UploadFile(slack.FileUploadParameters{Content: word, Channels: []string{ev.Channel}}) + _, err := client.UploadFile(slack.FileUploadParameters{Content: sentence, Channels: []string{ev.Channel}}) if err != nil { fmt.Printf("Error encountered when uploading file: %+v\n", err) } @@ -30,7 +30,7 @@ func main() { }, } - bot.Command("upload ", definition) + bot.Command("upload ", definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/8/example8.go b/examples/8/example8.go index bccfcd8..1282b85 100644 --- a/examples/8/example8.go +++ b/examples/8/example8.go @@ -29,7 +29,7 @@ func main() { }, } - bot.Command("echo ", definition) + bot.Command("echo {word}", definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/9/example9.go b/examples/9/example9.go index ce0196b..0795b3f 100644 --- a/examples/9/example9.go +++ b/examples/9/example9.go @@ -28,7 +28,7 @@ func main() { }, } - bot.Command("echo ", definition) + bot.Command("echo {word}", definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/go.mod b/go.mod index faa952e..f22b44f 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,7 @@ module github.com/shomali11/slacker go 1.14 require ( - github.com/pkg/errors v0.8.1 // indirect - github.com/shomali11/commander v0.0.0-20191122162317-51bc574c29ba + github.com/shomali11/commander v0.0.0-20220716022157-b5248c76541a github.com/shomali11/proper v0.0.0-20180607004733-233a9a872c30 github.com/slack-go/slack v0.11.0 github.com/stretchr/testify v1.3.0 // indirect diff --git a/go.sum b/go.sum index cfcbbac..fbab869 100644 --- a/go.sum +++ b/go.sum @@ -3,20 +3,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/shomali11/commander v0.0.0-20191122162317-51bc574c29ba h1:EDb+FfzJD5OTWxKE5LQaM6oiScfzNVmzjgCfWziLDkA= -github.com/shomali11/commander v0.0.0-20191122162317-51bc574c29ba/go.mod h1:bYyJw/Aj9fK+qoFmRbPJeWsDgq7WGO8f/Qof95qPug4= +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.9.1 h1:pekQBs0RmrdAgoqzcMCzUCWSyIkhzUU3F83ExAdZrKo= -github.com/slack-go/slack v0.9.1/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= github.com/slack-go/slack v0.11.0 h1:sBBjQz8LY++6eeWhGJNZpRm5jvLRNnWBFZ/cAq58a6k= github.com/slack-go/slack v0.11.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -24,4 +20,5 @@ github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf 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= From c36a4641b85c666d5057de01c580764b6ce35855 Mon Sep 17 00:00:00 2001 From: Raed Shomali Date: Sat, 16 Jul 2022 08:35:05 -0400 Subject: [PATCH 47/76] word vs sentence --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b8da77e..dda8259 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ 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 -- Simple parsing of String, Integer, Float and Boolean parameters - Contains support for `context.Context` -- Built-in `help` command - Replies can be new messages or in threads - Supports authorization - Bot responds to mentions and direct messages From b594c485c7d8d33990be4b6271514b981c56aaa4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Jul 2022 05:52:50 +0000 Subject: [PATCH 48/76] Bump github.com/slack-go/slack from 0.11.0 to 0.11.2 Bumps [github.com/slack-go/slack](https://github.com/slack-go/slack) from 0.11.0 to 0.11.2. - [Release notes](https://github.com/slack-go/slack/releases) - [Changelog](https://github.com/slack-go/slack/blob/master/CHANGELOG.md) - [Commits](https://github.com/slack-go/slack/compare/v0.11.0...v0.11.2) --- updated-dependencies: - dependency-name: github.com/slack-go/slack dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f22b44f..bd394c6 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,6 @@ go 1.14 require ( github.com/shomali11/commander v0.0.0-20220716022157-b5248c76541a github.com/shomali11/proper v0.0.0-20180607004733-233a9a872c30 - github.com/slack-go/slack v0.11.0 + github.com/slack-go/slack v0.11.2 github.com/stretchr/testify v1.3.0 // indirect ) diff --git a/go.sum b/go.sum index fbab869..b694a9b 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,8 @@ github.com/shomali11/commander v0.0.0-20220716022157-b5248c76541a h1:NCmAZOmyqKw 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.11.0 h1:sBBjQz8LY++6eeWhGJNZpRm5jvLRNnWBFZ/cAq58a6k= -github.com/slack-go/slack v0.11.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/slack-go/slack v0.11.2 h1:IWl90Rk+jqPEVyiBytH27CSN/TFAg2vuDDfoPRog/nc= +github.com/slack-go/slack v0.11.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= From a1936b5fa0578016e28aa86303ea1c17613ca313 Mon Sep 17 00:00:00 2001 From: Raed Shomali Date: Tue, 26 Jul 2022 08:46:45 -0400 Subject: [PATCH 49/76] Examples --- README.md | 10 +++++----- command.go | 2 +- examples/14/example14.go | 2 +- examples/2/example2.go | 2 +- examples/3/example3.go | 4 ++-- examples/4/example4.go | 2 +- slacker.go | 4 ++-- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index dda8259..c6f653f 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ func main() { definition := &slacker.CommandDefinition{ Description: "Ping!", - Example: "ping", + Examples: []string{"ping"}, Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { response.Reply("pong", slacker.WithThreadReply(true)) }, @@ -165,7 +165,7 @@ func main() { bot.Command("echo {word}", &slacker.CommandDefinition{ Description: "Echo a word!", - Example: "echo hello", + Examples: []string{"echo hello"}, Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { word := request.Param("word") response.Reply(word) @@ -174,7 +174,7 @@ func main() { bot.Command("say ", &slacker.CommandDefinition{ Description: "Say a sentence!", - Example: "say hello there everyone!", + Examples: []string{"say hello there everyone!"}, Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { sentence := request.Param("sentence") response.Reply(sentence) @@ -212,7 +212,7 @@ func main() { definition := &slacker.CommandDefinition{ Description: "Repeat a word a number of times!", - Example: "repeat hello 10", + 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) @@ -755,7 +755,7 @@ func main() { bot.Command("echo {word}", &slacker.CommandDefinition{ Description: "Echo a word!", - Example: "echo hello", + Examples: []string{"echo hello"}, Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { word := request.Param("word") response.Reply(word) diff --git a/command.go b/command.go index a6c38f1..b56f5a8 100644 --- a/command.go +++ b/command.go @@ -10,7 +10,7 @@ import ( // CommandDefinition structure contains definition of the bot command type CommandDefinition struct { Description string - Example string + Examples []string BlockID string AuthorizationFunc func(botCtx BotContext, request Request) bool Handler func(botCtx BotContext, request Request, response ResponseWriter) diff --git a/examples/14/example14.go b/examples/14/example14.go index 68d410f..052556d 100644 --- a/examples/14/example14.go +++ b/examples/14/example14.go @@ -34,7 +34,7 @@ func main() { bot.Command("echo {word}", &slacker.CommandDefinition{ Description: "Echo a word!", - Example: "echo hello", + Examples: []string{"echo hello"}, Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { word := request.Param("word") response.Reply(word) diff --git a/examples/2/example2.go b/examples/2/example2.go index d4b399e..b1d51be 100644 --- a/examples/2/example2.go +++ b/examples/2/example2.go @@ -13,7 +13,7 @@ func main() { definition := &slacker.CommandDefinition{ Description: "Ping!", - Example: "ping", + Examples: []string{"ping"}, Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { response.Reply("pong", slacker.WithThreadReply(true)) }, diff --git a/examples/3/example3.go b/examples/3/example3.go index 607b3a4..0a00cc5 100644 --- a/examples/3/example3.go +++ b/examples/3/example3.go @@ -13,7 +13,7 @@ func main() { bot.Command("echo {word}", &slacker.CommandDefinition{ Description: "Echo a word!", - Example: "echo hello", + Examples: []string{"echo hello"}, Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { word := request.Param("word") response.Reply(word) @@ -22,7 +22,7 @@ func main() { bot.Command("say ", &slacker.CommandDefinition{ Description: "Say a sentence!", - Example: "say hello there everyone!", + Examples: []string{"say hello there everyone!"}, Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { sentence := request.Param("sentence") response.Reply(sentence) diff --git a/examples/4/example4.go b/examples/4/example4.go index 9104ab2..e8eb51e 100644 --- a/examples/4/example4.go +++ b/examples/4/example4.go @@ -13,7 +13,7 @@ func main() { definition := &slacker.CommandDefinition{ Description: "Repeat a word a number of times!", - Example: "repeat hello 10", + 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) diff --git a/slacker.go b/slacker.go index 5dcc607..aa52f78 100644 --- a/slacker.go +++ b/slacker.go @@ -285,8 +285,8 @@ func (s *Slacker) defaultHelp(botCtx BotContext, request Request, response Respo helpMessage += newLine - if len(command.Definition().Example) > 0 { - helpMessage += fmt.Sprintf(quoteMessageFormat, command.Definition().Example) + newLine + for _, example := range command.Definition().Examples { + helpMessage += fmt.Sprintf(quoteMessageFormat, example) + newLine } } From 287b5db2342bc1f791b5e3a4ea9994f0227af90c Mon Sep 17 00:00:00 2001 From: Brock Berrett <45773088+broxgit@users.noreply.github.com> Date: Sun, 28 Aug 2022 17:36:16 -0600 Subject: [PATCH 50/76] Add ability to define a default inner event handler (#113) Users may define DefaultInnerEventHandler which will be called when an unhandled inner event is encountered. --- README.md | 7 +++++- examples/13/example13.go | 5 +++++ slacker.go | 48 ++++++++++++++++++++++++---------------- 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index c6f653f..03c6209 100644 --- a/README.md +++ b/README.md @@ -661,7 +661,7 @@ func contains(list []string, element string) bool { ## Example 13 -Adding handlers to when the bot is connected, encounters an error and a default for when none of the commands match +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 @@ -674,6 +674,7 @@ import ( "fmt" "github.com/shomali11/slacker" + "github.com/slack-go/slack/socketmode" ) func main() { @@ -694,6 +695,10 @@ func main() { bot.DefaultEvent(func(event interface{}) { fmt.Println(event) }) + + bot.DefaultInnerEvent(func(ctx context.Context, evt interface{}, request *socketmode.Request) { + fmt.Printf("Handling inner event: %s", evt) + }) definition := &slacker.CommandDefinition{ Description: "help!", diff --git a/examples/13/example13.go b/examples/13/example13.go index 0e89592..722d22b 100644 --- a/examples/13/example13.go +++ b/examples/13/example13.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/shomali11/slacker" + "github.com/slack-go/slack/socketmode" ) func main() { @@ -29,6 +30,10 @@ func main() { 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) { diff --git a/slacker.go b/slacker.go index aa52f78..ec64658 100644 --- a/slacker.go +++ b/slacker.go @@ -64,24 +64,25 @@ func NewClient(botToken, appToken string, options ...ClientOption) *Slacker { // Slacker contains the Slack API, botCommands, and handlers type Slacker struct { - client *slack.Client - socketModeClient *socketmode.Client - botCommands []BotCommand - botContextConstructor func(ctx context.Context, api *slack.Client, client *socketmode.Client, evt *MessageEvent) BotContext - commandConstructor func(usage string, definition *CommandDefinition) BotCommand - requestConstructor func(botCtx BotContext, properties *proper.Properties) Request - responseConstructor func(botCtx BotContext) ResponseWriter - initHandler func() - errorHandler func(err string) - interactiveEventHandler func(*Slacker, *socketmode.Event, *slack.InteractionCallback) - helpDefinition *CommandDefinition - defaultMessageHandler func(botCtx BotContext, request Request, response ResponseWriter) - defaultEventHandler func(interface{}) - errUnauthorized error - commandChannel chan *CommandEvent - appID string - botInteractionMode BotInteractionMode - cleanEventInput func(in string) string + client *slack.Client + socketModeClient *socketmode.Client + botCommands []BotCommand + botContextConstructor func(ctx context.Context, api *slack.Client, client *socketmode.Client, evt *MessageEvent) BotContext + commandConstructor func(usage string, definition *CommandDefinition) BotCommand + requestConstructor func(botCtx BotContext, properties *proper.Properties) Request + responseConstructor func(botCtx BotContext) ResponseWriter + initHandler func() + errorHandler func(err string) + interactiveEventHandler func(*Slacker, *socketmode.Event, *slack.InteractionCallback) + helpDefinition *CommandDefinition + defaultMessageHandler func(botCtx BotContext, request Request, response ResponseWriter) + defaultEventHandler func(interface{}) + defaultInnerEventHandler func(ctx context.Context, evt interface{}, request *socketmode.Request) + errUnauthorized error + commandChannel chan *CommandEvent + appID string + botInteractionMode BotInteractionMode + cleanEventInput func(in string) string } // BotCommands returns Bot Commands @@ -149,6 +150,11 @@ func (s *Slacker) DefaultEvent(defaultEventHandler func(interface{})) { s.defaultEventHandler = defaultEventHandler } +// 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 +} + // UnAuthorizedError error message func (s *Slacker) UnAuthorizedError(errUnauthorized error) { s.errUnauthorized = errUnauthorized @@ -213,7 +219,11 @@ func (s *Slacker) Listen(ctx context.Context) error { go s.handleMessageEvent(ctx, ev.InnerEvent.Data, nil) default: - fmt.Printf("unsupported inner event: %+v\n", ev.InnerEvent.Type) + if s.defaultInnerEventHandler != nil { + s.defaultInnerEventHandler(ctx, ev.InnerEvent.Data, evt.Request) + } else { + fmt.Printf("unsupported inner event: %+v\n", ev.InnerEvent.Type) + } } s.socketModeClient.Ack(*evt.Request) From 530ccefec0772a69f26678cc2697135c88b83594 Mon Sep 17 00:00:00 2001 From: kosar Date: Tue, 14 Feb 2023 23:35:38 +0200 Subject: [PATCH 51/76] Update slack-go to latest available, fix getChannelName (after slack-go update) --- go.mod | 2 +- go.sum | 4 ++-- slacker.go | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index bd394c6..66a16aa 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,6 @@ go 1.14 require ( github.com/shomali11/commander v0.0.0-20220716022157-b5248c76541a github.com/shomali11/proper v0.0.0-20180607004733-233a9a872c30 - github.com/slack-go/slack v0.11.2 + github.com/slack-go/slack v0.12.1 github.com/stretchr/testify v1.3.0 // indirect ) diff --git a/go.sum b/go.sum index b694a9b..aa7ffb5 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,8 @@ github.com/shomali11/commander v0.0.0-20220716022157-b5248c76541a h1:NCmAZOmyqKw 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.11.2 h1:IWl90Rk+jqPEVyiBytH27CSN/TFAg2vuDDfoPRog/nc= -github.com/slack-go/slack v0.11.2/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +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/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= diff --git a/slacker.go b/slacker.go index ec64658..826ae0b 100644 --- a/slacker.go +++ b/slacker.go @@ -415,7 +415,10 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * } func getChannelName(slacker *Slacker, channelID string) string { - channel, err := slacker.client.GetConversationInfo(channelID, true) + channel, err := slacker.client.GetConversationInfo(&slack.GetConversationInfoInput{ + ChannelID: channelID, + IncludeLocale: false, + IncludeNumMembers: false}) if err != nil { fmt.Printf("unable to get channel info for %s: %v\n", channelID, err) return channelID From de0582866ee192dac35354cd6c5eeaa03a8bc2d4 Mon Sep 17 00:00:00 2001 From: Raed Shomali Date: Tue, 28 Mar 2023 16:15:45 -0400 Subject: [PATCH 52/76] Clean up --- command.go | 3 +- examples/10/example10.go | 20 +++++++--- go.mod | 1 + go.sum | 2 + message_event.go | 79 ++++++++++++++++++++++++++++++++++++++++ response.go | 17 ++++++++- slacker.go | 75 +------------------------------------- 7 files changed, 114 insertions(+), 83 deletions(-) diff --git a/command.go b/command.go index b56f5a8..c1c0792 100644 --- a/command.go +++ b/command.go @@ -16,8 +16,7 @@ type CommandDefinition struct { Handler func(botCtx BotContext, request Request, response ResponseWriter) Interactive func(*Slacker, *socketmode.Event, *slack.InteractionCallback, *socketmode.Request) - // HideHelp will cause this command to not be shown when a user requests - // help. + // HideHelp will hide this command definition from appearing in the `help` results. HideHelp bool } diff --git a/examples/10/example10.go b/examples/10/example10.go index edc37cf..334b84a 100644 --- a/examples/10/example10.go +++ b/examples/10/example10.go @@ -69,13 +69,22 @@ func (r *MyCustomResponseWriter) ReportError(err error, options ...slacker.Repor } } -// Reply send a attachments to the current channel with a message +// Reply send a message to the current channel func (r *MyCustomResponseWriter) Reply(message string, options ...slacker.ReplyOption) error { + ev := r.botCtx.Event() + if ev == nil { + return fmt.Errorf("unable to get message event details") + } + return r.Post(ev.Channel, message, options...) +} + +// Post send a message to a channel +func (r *MyCustomResponseWriter) Post(channel string, message string, options ...slacker.ReplyOption) error { defaults := slacker.NewReplyDefaults(options...) client := r.botCtx.Client() - event := r.botCtx.Event() - if event == nil { + ev := r.botCtx.Event() + if ev == nil { return fmt.Errorf("unable to get message event details") } @@ -84,12 +93,13 @@ func (r *MyCustomResponseWriter) Reply(message string, options ...slacker.ReplyO slack.MsgOptionAttachments(defaults.Attachments...), slack.MsgOptionBlocks(defaults.Blocks...), } + if defaults.ThreadResponse { - opts = append(opts, slack.MsgOptionTS(event.TimeStamp)) + opts = append(opts, slack.MsgOptionTS(ev.TimeStamp)) } _, _, err := client.PostMessage( - event.Channel, + channel, opts..., ) return err diff --git a/go.mod b/go.mod index 66a16aa..229f106 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/shomali11/slacker go 1.14 require ( + github.com/robfig/cron v1.2.0 // indirect 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 diff --git a/go.sum b/go.sum index aa7ffb5..dc71159 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0U github.com/gorilla/websocket v1.4.2/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/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= diff --git a/message_event.go b/message_event.go index 2692c39..6addb1a 100644 --- a/message_event.go +++ b/message_event.go @@ -1,5 +1,13 @@ package slacker +import ( + "fmt" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" +) + // MessageEvent contains details common to message based events, including the // raw event as returned from Slack along with the corresponding event type. // The struct should be kept minimal and only include data that is commonly @@ -54,3 +62,74 @@ func (e *MessageEvent) IsThread() bool { func (e *MessageEvent) IsBot() bool { return e.BotID != "" } + +// NewMessageEvent creates a new message event structure +func NewMessageEvent(slacker *Slacker, evt interface{}, req *socketmode.Request) *MessageEvent { + var me *MessageEvent + + switch ev := evt.(type) { + case *slackevents.MessageEvent: + me = &MessageEvent{ + Channel: ev.Channel, + ChannelName: getChannelName(slacker, ev.Channel), + User: ev.User, + UserName: getUserName(slacker, ev.User), + Text: ev.Text, + Data: evt, + Type: ev.Type, + TimeStamp: ev.TimeStamp, + ThreadTimeStamp: ev.ThreadTimeStamp, + BotID: ev.BotID, + } + case *slackevents.AppMentionEvent: + me = &MessageEvent{ + Channel: ev.Channel, + ChannelName: getChannelName(slacker, ev.Channel), + User: ev.User, + UserName: getUserName(slacker, ev.User), + Text: ev.Text, + Data: evt, + Type: ev.Type, + TimeStamp: ev.TimeStamp, + ThreadTimeStamp: ev.ThreadTimeStamp, + BotID: ev.BotID, + } + case *slack.SlashCommand: + me = &MessageEvent{ + Channel: ev.ChannelID, + ChannelName: ev.ChannelName, + User: ev.UserID, + UserName: ev.UserName, + Text: fmt.Sprintf("%s %s", ev.Command[1:], ev.Text), + Data: req, + Type: req.Type, + } + } + + // Filter out other bots. At the very least this is needed for MessageEvent + // to prevent the bot from self-triggering and causing loops. However better + // logic should be in place to prevent repeated self-triggering / bot-storms + // if we want to enable this later. + if me.IsBot() { + return nil + } + return me +} + +func getChannelName(slacker *Slacker, channelID string) string { + channel, err := slacker.client.GetConversationInfo(channelID, true) + if err != nil { + fmt.Printf("unable to get channel info for %s: %v\n", channelID, err) + return channelID + } + return channel.Name +} + +func getUserName(slacker *Slacker, userID string) string { + user, err := slacker.client.GetUserInfo(userID) + if err != nil { + fmt.Printf("unable to get user info for %s: %v\n", userID, err) + return userID + } + return user.Name +} diff --git a/response.go b/response.go index 84158fd..2791c0d 100644 --- a/response.go +++ b/response.go @@ -12,6 +12,7 @@ const ( // 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) } @@ -35,17 +36,28 @@ func (r *response) ReportError(err error, options ...ReportErrorOption) { opts := []slack.MsgOption{ slack.MsgOptionText(fmt.Sprintf(errorFormat, err.Error()), false), } + if defaults.ThreadResponse { opts = append(opts, slack.MsgOptionTS(ev.TimeStamp)) } + _, _, err = client.PostMessage(ev.Channel, opts...) if err != nil { fmt.Printf("failed posting message: %v\n", err) } } -// Reply send a attachments to the current channel with a message +// 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.Channel, message, options...) +} + +// Post send a message to a channel +func (r *response) Post(channel string, message string, options ...ReplyOption) error { defaults := NewReplyDefaults(options...) client := r.botCtx.Client() @@ -59,12 +71,13 @@ func (r *response) Reply(message string, options ...ReplyOption) error { slack.MsgOptionAttachments(defaults.Attachments...), slack.MsgOptionBlocks(defaults.Blocks...), } + if defaults.ThreadResponse { opts = append(opts, slack.MsgOptionTS(ev.TimeStamp)) } _, _, err := client.PostMessage( - ev.Channel, + channel, opts..., ) return err diff --git a/slacker.go b/slacker.go index 826ae0b..53d5b57 100644 --- a/slacker.go +++ b/slacker.go @@ -352,7 +352,7 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * s.responseConstructor = NewResponse } - ev := newMessageEvent(s, evt, req) + ev := NewMessageEvent(s, evt, req) if ev == nil { // event doesn't appear to be a valid message type return @@ -414,76 +414,3 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * } } -func getChannelName(slacker *Slacker, channelID string) string { - channel, err := slacker.client.GetConversationInfo(&slack.GetConversationInfoInput{ - ChannelID: channelID, - IncludeLocale: false, - IncludeNumMembers: false}) - if err != nil { - fmt.Printf("unable to get channel info for %s: %v\n", channelID, err) - return channelID - } - return channel.Name -} - -func getUserName(slacker *Slacker, userID string) string { - user, err := slacker.client.GetUserInfo(userID) - if err != nil { - fmt.Printf("unable to get user info for %s: %v\n", userID, err) - return userID - } - return user.Name -} - -func newMessageEvent(slacker *Slacker, evt interface{}, req *socketmode.Request) *MessageEvent { - var me *MessageEvent - - switch ev := evt.(type) { - case *slackevents.MessageEvent: - me = &MessageEvent{ - Channel: ev.Channel, - ChannelName: getChannelName(slacker, ev.Channel), - User: ev.User, - UserName: getUserName(slacker, ev.User), - Text: ev.Text, - Data: evt, - Type: ev.Type, - TimeStamp: ev.TimeStamp, - ThreadTimeStamp: ev.ThreadTimeStamp, - BotID: ev.BotID, - } - case *slackevents.AppMentionEvent: - me = &MessageEvent{ - Channel: ev.Channel, - ChannelName: getChannelName(slacker, ev.Channel), - User: ev.User, - UserName: getUserName(slacker, ev.User), - Text: ev.Text, - Data: evt, - Type: ev.Type, - TimeStamp: ev.TimeStamp, - ThreadTimeStamp: ev.ThreadTimeStamp, - BotID: ev.BotID, - } - case *slack.SlashCommand: - me = &MessageEvent{ - Channel: ev.ChannelID, - ChannelName: ev.ChannelName, - User: ev.UserID, - UserName: ev.UserName, - Text: fmt.Sprintf("%s %s", ev.Command[1:], ev.Text), - Data: req, - Type: req.Type, - } - } - - // Filter out other bots. At the very least this is needed for MessageEvent - // to prevent the bot from self-triggering and causing loops. However better - // logic should be in place to prevent repeated self-triggering / bot-storms - // if we want to enable this later. - if me.IsBot() { - return nil - } - - return me -} From 4a92703a3b34f631e374a88712366ca6c200ea51 Mon Sep 17 00:00:00 2001 From: Raed Shomali Date: Tue, 28 Mar 2023 16:16:59 -0400 Subject: [PATCH 53/76] Clean up --- README.md | 244 ++++++++++++++++++++++++++------------- command.go | 51 ++++---- context.go | 103 ++++++++++++++--- defaults.go | 10 +- examples/10/example10.go | 10 +- examples/11/example11.go | 5 +- examples/12/example12.go | 22 +++- examples/15/example15.go | 15 +-- examples/17/example17.go | 4 +- examples/18/example18.go | 53 ++------- examples/19/example19.go | 6 +- examples/5/example5.go | 2 +- examples/6/example6.go | 10 +- job.go | 48 ++++++++ message_event.go | 87 ++++++++------ response.go | 20 ++-- slacker.go | 231 ++++++++++++++++++++++-------------- 17 files changed, 586 insertions(+), 335 deletions(-) create mode 100644 job.go diff --git a/README.md b/README.md index 03c6209..df9af92 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Built on top of the Slack API [github.com/slack-go/slack](https://github.com/sla - Contains support for `context.Context` - Replies can be new messages or in threads - Supports authorization +- Supports Cron Jobs using [cron](https://github.com/robfig/cron) - Bot responds to mentions and direct messages - Handlers run concurrently via goroutines - Produces events for executed commands @@ -256,14 +257,14 @@ func main() { messageReplyDefinition := &slacker.CommandDefinition{ Description: "Tests errors in new messages", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.ReportError(errors.New("Oops!")) + response.ReportError(errors.New("oops, an error occurred")) }, } threadReplyDefinition := &slacker.CommandDefinition{ Description: "Tests errors in threads", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.ReportError(errors.New("Oops!"), slacker.WithThreadError(true)) + response.ReportError(errors.New("oops, an error occurred"), slacker.WithThreadReplyError(true)) }, } @@ -304,12 +305,12 @@ func main() { Description: "Upload a sentence!", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { sentence := request.Param("sentence") - client := botCtx.Client() - ev := botCtx.Event() + apiClient := botCtx.ApiClient() + event := botCtx.Event() - if ev.Channel != "" { - client.PostMessage(ev.Channel, slack.MsgOptionText("Uploading file ...", false)) - _, err := client.UploadFile(slack.FileUploadParameters{Content: sentence, Channels: []string{ev.Channel}}) + if event.ChannelID != "" { + apiClient.PostMessage(event.ChannelID, slack.MsgOptionText("Uploading file ...", false)) + _, err := apiClient.UploadFile(slack.FileUploadParameters{Content: sentence, Channels: []string{event.ChannelID}}) if err != nil { fmt.Printf("Error encountered when uploading file: %+v\n", err) } @@ -340,6 +341,7 @@ import ( "context" "errors" "log" + "math/rand" "os" "time" @@ -352,13 +354,15 @@ func main() { definition := &slacker.CommandDefinition{ Description: "Process!", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - timedContext, cancel := context.WithTimeout(botCtx.Context(), time.Second) + timedContext, cancel := context.WithTimeout(botCtx.Context(), 5*time.Second) defer cancel() + duration := time.Duration(rand.Int()%10+1) * time.Second + select { case <-timedContext.Done(): response.ReportError(errors.New("timed out")) - case <-time.After(time.Minute): + case <-time.After(duration): response.Reply("Processing done!") } }, @@ -450,10 +454,11 @@ func main() { attachments := []slack.Block{} attachments = append(attachments, slack.NewContextBlock("1", - slack.NewTextBlockObject("mrkdwn", "Hi!", false, false)), + slack.NewTextBlockObject("mrkdwn", word, false, false)), ) - response.Reply(word, slacker.WithBlocks(attachments)) + // When using blocks the message argument will be thrown away and can be left blank. + response.Reply("", slacker.WithBlocks(attachments)) }, } @@ -500,7 +505,7 @@ func main() { Description: "Custom!", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { response.Reply("custom") - response.ReportError(errors.New("oops")) + response.ReportError(errors.New("oops, an error occurred")) }, } @@ -529,7 +534,7 @@ type MyCustomResponseWriter struct { func (r *MyCustomResponseWriter) ReportError(err error, options ...slacker.ReportErrorOption) { defaults := slacker.NewReportErrorDefaults(options...) - client := r.botCtx.Client() + apiClient := r.botCtx.ApiClient() event := r.botCtx.Event() opts := []slack.MsgOption{ @@ -539,20 +544,29 @@ func (r *MyCustomResponseWriter) ReportError(err error, options ...slacker.Repor opts = append(opts, slack.MsgOptionTS(event.TimeStamp)) } - _, _, err = client.PostMessage(event.Channel, opts...) + _, _, err = apiClient.PostMessage(event.ChannelID, opts...) if err != nil { - fmt.Println("failed to report error: %v", err) + fmt.Printf("failed to report error: %v\n", err) } } -// Reply send a attachments to the current channel with a message +// Reply send a message to the current channel func (r *MyCustomResponseWriter) Reply(message string, options ...slacker.ReplyOption) error { + ev := r.botCtx.Event() + if ev == nil { + return fmt.Errorf("unable to get message event details") + } + return r.Post(ev.ChannelID, message, options...) +} + +// Post send a message to a channel +func (r *MyCustomResponseWriter) Post(channel string, message string, options ...slacker.ReplyOption) error { defaults := slacker.NewReplyDefaults(options...) - client := r.botCtx.Client() - event := r.botCtx.Event() - if event == nil { - return fmt.Errorf("Unable to get message event details") + apiClient := r.botCtx.ApiClient() + ev := r.botCtx.Event() + if ev == nil { + return fmt.Errorf("unable to get message event details") } opts := []slack.MsgOption{ @@ -560,12 +574,13 @@ func (r *MyCustomResponseWriter) Reply(message string, options ...slacker.ReplyO slack.MsgOptionAttachments(defaults.Attachments...), slack.MsgOptionBlocks(defaults.Blocks...), } + if defaults.ThreadResponse { - opts = append(opts, slack.MsgOptionTS(event.TimeStamp)) + opts = append(opts, slack.MsgOptionTS(ev.TimeStamp)) } - _, _, err := client.PostMessage( - event.Channel, + _, _, err := apiClient.PostMessage( + channel, opts..., ) return err @@ -581,13 +596,14 @@ package main import ( "context" - "github.com/shomali11/slacker" "log" "os" + + "github.com/shomali11/slacker" ) func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) definition := &slacker.CommandDefinition{ Description: "Ping!", @@ -626,19 +642,31 @@ import ( func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - authorizedUsers := []string{""} + authorizedUserIds := []string{""} + authorizedUserNames := []string{""} - authorizedDefinition := &slacker.CommandDefinition{ + authorizedDefinitionById := &slacker.CommandDefinition{ Description: "Very secret stuff", AuthorizationFunc: func(botCtx slacker.BotContext, request slacker.Request) bool { - return contains(authorizedUsers, botCtx.Event().User) + return contains(authorizedUserIds, botCtx.Event().UserID) }, Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { response.Reply("You are authorized!") }, } - bot.Command("secret", authorizedDefinition) + authorizedDefinitionByName := &slacker.CommandDefinition{ + Description: "Very secret stuff", + AuthorizationFunc: func(botCtx slacker.BotContext, request slacker.Request) bool { + return contains(authorizedUserNames, botCtx.Event().UserProfile.DisplayName) + }, + Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + response.Reply("You are authorized!") + }, + } + + bot.Command("secret-id", authorizedDefinitionById) + bot.Command("secret-name", authorizedDefinitionByName) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -695,7 +723,7 @@ func main() { bot.DefaultEvent(func(event interface{}) { fmt.Println(event) }) - + bot.DefaultInnerEvent(func(ctx context.Context, evt interface{}, request *socketmode.Request) { fmt.Printf("Handling inner event: %s", evt) }) @@ -786,17 +814,17 @@ package main import ( "context" - "github.com/shomali11/slacker" - "github.com/slack-go/slack" - "github.com/slack-go/slack/socketmode" "log" "os" + + "github.com/shomali11/slacker" + "github.com/slack-go/slack" ) func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - bot.Interactive(func(s *slacker.Slacker, event *socketmode.Event, callback *slack.InteractionCallback) { + bot.Interactive(func(botCtx slacker.InteractiveBotContext, callback *slack.InteractionCallback) { if callback.Type != slack.InteractionTypeBlockActions { return } @@ -820,10 +848,10 @@ func main() { text = "I don't understand your mood..." } - _, _, _ = s.Client().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), + _, _, _ = botCtx.ApiClient().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), slack.MsgOptionReplaceOriginal(callback.ResponseURL)) - s.SocketMode().Ack(*event.Request) + botCtx.SocketModeClient().Ack(*botCtx.Event().Request) }) definition := &slacker.CommandDefinition{ @@ -837,8 +865,9 @@ func main() { slack.NewSectionBlock(slack.NewTextBlockObject(slack.PlainTextType, "What is your mood today?", true, false), nil, nil), slack.NewActionBlock("mood-block", happyBtn, sadBtn), })) + if err != nil { - panic(err) + response.ReportError(err) } }, } @@ -863,33 +892,33 @@ Configure bot to process other bot events package main import ( - "context" - "log" - "os" + "context" + "log" + "os" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker" ) func main() { - bot := slacker.NewClient( - os.Getenv("SLACK_BOT_TOKEN"), - os.Getenv("SLACK_APP_TOKEN"), - slacker.WithBotInteractionMode(slacker.BotInteractionModeIgnoreApp), - ) - - bot.Command("hello", &slacker.CommandDefinition{ - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("hai!") - }, - }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } + bot := slacker.NewClient( + os.Getenv("SLACK_BOT_TOKEN"), + os.Getenv("SLACK_APP_TOKEN"), + slacker.WithBotInteractionMode(slacker.BotInteractionModeIgnoreApp), + ) + + bot.Command("hello", &slacker.CommandDefinition{ + Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + response.Reply("hai!") + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } } ``` @@ -901,38 +930,81 @@ Override the default event input cleaning function (to sanitize the messages rec package main import ( - "context" - "log" - "os" + "context" "fmt" + "log" + "os" "strings" - "github.com/shomali11/slacker" + "github.com/shomali11/slacker" ) func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) - bot.CleanEventInput(func(in string) string { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot.SanitizeEventText(func(text string) string { fmt.Println("My slack bot does not like backticks!") - return strings.ReplaceAll(in, "`", "") + return strings.ReplaceAll(text, "`", "") }) - bot.Command("my-command", &slacker.CommandDefinition{ - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("it works!") - }, - }) + bot.Command("my-command", &slacker.CommandDefinition{ + Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + response.Reply("it works!") + }, + }) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } } ``` +## Example 18 + +Showcase the ability to define Cron Jobs + +```go +package main + +import ( + "context" + "log" + "os" + + "github.com/shomali11/slacker" + "github.com/slack-go/slack" +) + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot.Command("ping", &slacker.CommandDefinition{ + Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + response.Reply("pong") + }, + }) + + // Run every minute + bot.Job("0 * * * * *", &slacker.JobDefinition{ + Description: "A cron job that runs every minute", + Handler: func(jobCtx slacker.JobContext) { + jobCtx.ApiClient().PostMessage("#test", slack.MsgOptionText("Hello!", false)) + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} +``` + + ## Example 19 Override the default command constructor to add a prefix to all commands and print log message before command execution @@ -943,16 +1015,19 @@ package main import ( "context" "fmt" + "log" + "os" + "github.com/shomali11/commander" "github.com/shomali11/proper" "github.com/shomali11/slacker" - "log" - "os" + "github.com/slack-go/slack" + "github.com/slack-go/slack/socketmode" ) func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) - bot.CustomCommand(func(usage string, definition *slacker.CommandDefinition) slacker.BotCommand { + bot.CustomCommand(func(usage string, definition *slacker.CommandDefinition) slacker.Command { return &cmd{ usage: usage, definition: definition, @@ -999,10 +1074,15 @@ func (c *cmd) Tokenize() []*commander.Token { } func (c *cmd) Execute(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - log.Printf("Executing command [%s] invoked by %s", c.usage, botCtx.Event().User) + log.Printf("Executing command [%s] invoked by %s", c.usage, botCtx.Event().UserID) c.definition.Handler(botCtx, request, response) } + +func (c *cmd) Interactive(slacker.InteractiveBotContext, *socketmode.Request, *slack.InteractionCallback) { +} ``` + + # Contributing / Submitting an Issue Please review our [Contribution Guidelines](CONTRIBUTING.md) if you have found diff --git a/command.go b/command.go index c1c0792..21c8964 100644 --- a/command.go +++ b/command.go @@ -12,64 +12,63 @@ type CommandDefinition struct { Description string Examples []string BlockID string - AuthorizationFunc func(botCtx BotContext, request Request) bool - Handler func(botCtx BotContext, request Request, response ResponseWriter) - Interactive func(*Slacker, *socketmode.Event, *slack.InteractionCallback, *socketmode.Request) + AuthorizationFunc func(BotContext, Request) bool + Handler func(BotContext, Request, ResponseWriter) + Interactive func(InteractiveBotContext, *socketmode.Request, *slack.InteractionCallback) // HideHelp will hide this command definition from appearing in the `help` results. HideHelp bool } -// NewBotCommand creates a new bot command object -func NewBotCommand(usage string, definition *CommandDefinition) BotCommand { - command := commander.NewCommand(usage) - return &botCommand{ +// NewCommand creates a new bot command object +func NewCommand(usage string, definition *CommandDefinition) Command { + return &command{ usage: usage, definition: definition, - command: command, + cmd: commander.NewCommand(usage), } } -// BotCommand interface -type BotCommand interface { +// Command interface +type Command interface { Usage() string Definition() *CommandDefinition - Match(text string) (*proper.Properties, bool) + Match(string) (*proper.Properties, bool) Tokenize() []*commander.Token - Execute(botCtx BotContext, request Request, response ResponseWriter) - Interactive(*Slacker, *socketmode.Event, *slack.InteractionCallback, *socketmode.Request) + Execute(BotContext, Request, ResponseWriter) + Interactive(InteractiveBotContext, *socketmode.Request, *slack.InteractionCallback) } -// botCommand structure contains the bot's command, description and handler -type botCommand struct { +// command structure contains the bot's command, description and handler +type command struct { usage string definition *CommandDefinition - command *commander.Command + cmd *commander.Command } // Usage returns the command usage -func (c *botCommand) Usage() string { +func (c *command) Usage() string { return c.usage } -// Description returns the command description -func (c *botCommand) Definition() *CommandDefinition { +// Definition returns the command definition +func (c *command) Definition() *CommandDefinition { return c.definition } // Match determines whether the bot should respond based on the text received -func (c *botCommand) Match(text string) (*proper.Properties, bool) { - return c.command.Match(text) +func (c *command) Match(text string) (*proper.Properties, bool) { + return c.cmd.Match(text) } // Tokenize returns the command format's tokens -func (c *botCommand) Tokenize() []*commander.Token { - return c.command.Tokenize() +func (c *command) Tokenize() []*commander.Token { + return c.cmd.Tokenize() } // Execute executes the handler logic -func (c *botCommand) Execute(botCtx BotContext, request Request, response ResponseWriter) { +func (c *command) Execute(botCtx BotContext, request Request, response ResponseWriter) { if c.definition == nil || c.definition.Handler == nil { return } @@ -77,9 +76,9 @@ func (c *botCommand) Execute(botCtx BotContext, request Request, response Respon } // Interactive executes the interactive logic -func (c *botCommand) Interactive(slacker *Slacker, evt *socketmode.Event, callback *slack.InteractionCallback, req *socketmode.Request) { +func (c *command) Interactive(botContext InteractiveBotContext, request *socketmode.Request, callback *slack.InteractionCallback) { if c.definition == nil || c.definition.Interactive == nil { return } - c.definition.Interactive(slacker, evt, callback, req) + c.definition.Interactive(botContext, request, callback) } diff --git a/context.go b/context.go index 9fb8042..baae92c 100644 --- a/context.go +++ b/context.go @@ -7,24 +7,24 @@ import ( "github.com/slack-go/slack/socketmode" ) -// A BotContext interface is used to respond to an event +// BotContext interface is for bot command contexts type BotContext interface { Context() context.Context Event() *MessageEvent - SocketMode() *socketmode.Client - Client() *slack.Client + ApiClient() *slack.Client + SocketModeClient() *socketmode.Client } // NewBotContext creates a new bot context -func NewBotContext(ctx context.Context, client *slack.Client, socketmode *socketmode.Client, evt *MessageEvent) BotContext { - return &botContext{ctx: ctx, event: evt, client: client, socketmode: socketmode} +func NewBotContext(ctx context.Context, apiClient *slack.Client, socketModeClient *socketmode.Client, event *MessageEvent) BotContext { + return &botContext{ctx: ctx, event: event, apiClient: apiClient, socketModeClient: socketModeClient} } type botContext struct { - ctx context.Context - event *MessageEvent - client *slack.Client - socketmode *socketmode.Client + ctx context.Context + event *MessageEvent + apiClient *slack.Client + socketModeClient *socketmode.Client } // Context returns the context @@ -37,12 +37,85 @@ func (r *botContext) Event() *MessageEvent { return r.event } -// SocketMode returns the SocketMode client -func (r *botContext) SocketMode() *socketmode.Client { - return r.socketmode +// ApiClient returns the slack API client +func (r *botContext) ApiClient() *slack.Client { + return r.apiClient } -// Client returns the slack client -func (r *botContext) Client() *slack.Client { - return r.client +// SocketModeClient returns the slack socket mode client +func (r *botContext) SocketModeClient() *socketmode.Client { + return r.socketModeClient +} + +// InteractiveBotContext interface is interactive bot command contexts +type InteractiveBotContext interface { + Context() context.Context + Event() *socketmode.Event + ApiClient() *slack.Client + SocketModeClient() *socketmode.Client +} + +// NewInteractiveBotContext creates a new interactive bot context +func NewInteractiveBotContext(ctx context.Context, apiClient *slack.Client, socketModeClient *socketmode.Client, event *socketmode.Event) InteractiveBotContext { + return &interactiveBotContext{ctx: ctx, event: event, apiClient: apiClient, socketModeClient: socketModeClient} +} + +type interactiveBotContext struct { + ctx context.Context + event *socketmode.Event + apiClient *slack.Client + socketModeClient *socketmode.Client +} + +// Context returns the context +func (r *interactiveBotContext) Context() context.Context { + return r.ctx +} + +// Event returns the socket event +func (r *interactiveBotContext) Event() *socketmode.Event { + return r.event +} + +// ApiClient returns the slack API client +func (r *interactiveBotContext) ApiClient() *slack.Client { + return r.apiClient +} + +// SocketModeClient returns the slack socket mode client +func (r *interactiveBotContext) SocketModeClient() *socketmode.Client { + return r.socketModeClient +} + +// JobContext interface is for job command contexts +type JobContext interface { + Context() context.Context + ApiClient() *slack.Client + SocketModeClient() *socketmode.Client +} + +// NewJobContext creates a new bot context +func NewJobContext(ctx context.Context, apiClient *slack.Client, socketModeClient *socketmode.Client) JobContext { + return &jobContext{ctx: ctx, apiClient: apiClient, socketModeClient: socketModeClient} +} + +type jobContext struct { + ctx context.Context + apiClient *slack.Client + socketModeClient *socketmode.Client +} + +// Context returns the context +func (r *jobContext) Context() context.Context { + return r.ctx +} + +// ApiClient returns the slack API client +func (r *jobContext) ApiClient() *slack.Client { + return r.apiClient +} + +// SocketModeClient returns the slack socket mode client +func (r *jobContext) SocketModeClient() *socketmode.Client { + return r.socketModeClient } diff --git a/defaults.go b/defaults.go index 5e6fece..f3e5710 100644 --- a/defaults.go +++ b/defaults.go @@ -12,8 +12,7 @@ func WithDebug(debug bool) ClientOption { } } -// WithBotInteractionMode instructs Slacker on how to handle message events coming from a -// bot. +// WithBotInteractionMode instructs Slacker on how to handle message events coming from a bot. func WithBotInteractionMode(mode BotInteractionMode) ClientOption { return func(defaults *ClientDefaults) { defaults.BotMode = mode @@ -91,15 +90,14 @@ type ReportErrorDefaults struct { ThreadResponse bool } -// WithThreadError specifies the reply to be inside a thread of the original message -func WithThreadError(useThread bool) ReportErrorOption { +// WithThreadReplyError specifies the reply to be inside a thread of the original message +func WithThreadReplyError(useThread bool) ReportErrorOption { return func(defaults *ReportErrorDefaults) { defaults.ThreadResponse = useThread } } -// NewReportErrorDefaults builds our ReportErrorDefaults from zero or more -// ReportErrorOption. +// NewReportErrorDefaults builds our ReportErrorDefaults from zero or more ReportErrorOption. func NewReportErrorDefaults(options ...ReportErrorOption) *ReportErrorDefaults { config := &ReportErrorDefaults{ ThreadResponse: false, diff --git a/examples/10/example10.go b/examples/10/example10.go index 334b84a..87da85c 100644 --- a/examples/10/example10.go +++ b/examples/10/example10.go @@ -53,7 +53,7 @@ type MyCustomResponseWriter struct { func (r *MyCustomResponseWriter) ReportError(err error, options ...slacker.ReportErrorOption) { defaults := slacker.NewReportErrorDefaults(options...) - client := r.botCtx.Client() + apiClient := r.botCtx.ApiClient() event := r.botCtx.Event() opts := []slack.MsgOption{ @@ -63,7 +63,7 @@ func (r *MyCustomResponseWriter) ReportError(err error, options ...slacker.Repor opts = append(opts, slack.MsgOptionTS(event.TimeStamp)) } - _, _, err = client.PostMessage(event.Channel, opts...) + _, _, err = apiClient.PostMessage(event.ChannelID, opts...) if err != nil { fmt.Printf("failed to report error: %v\n", err) } @@ -75,14 +75,14 @@ func (r *MyCustomResponseWriter) Reply(message string, options ...slacker.ReplyO if ev == nil { return fmt.Errorf("unable to get message event details") } - return r.Post(ev.Channel, message, options...) + return r.Post(ev.ChannelID, message, options...) } // Post send a message to a channel func (r *MyCustomResponseWriter) Post(channel string, message string, options ...slacker.ReplyOption) error { defaults := slacker.NewReplyDefaults(options...) - client := r.botCtx.Client() + apiClient := r.botCtx.ApiClient() ev := r.botCtx.Event() if ev == nil { return fmt.Errorf("unable to get message event details") @@ -98,7 +98,7 @@ func (r *MyCustomResponseWriter) Post(channel string, message string, options .. opts = append(opts, slack.MsgOptionTS(ev.TimeStamp)) } - _, _, err := client.PostMessage( + _, _, err := apiClient.PostMessage( channel, opts..., ) diff --git a/examples/11/example11.go b/examples/11/example11.go index 83ef529..13258cd 100644 --- a/examples/11/example11.go +++ b/examples/11/example11.go @@ -2,13 +2,14 @@ package main import ( "context" - "github.com/shomali11/slacker" "log" "os" + + "github.com/shomali11/slacker" ) func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) definition := &slacker.CommandDefinition{ Description: "Ping!", diff --git a/examples/12/example12.go b/examples/12/example12.go index 71fdb46..b403342 100644 --- a/examples/12/example12.go +++ b/examples/12/example12.go @@ -11,19 +11,33 @@ import ( func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - authorizedUsers := []string{""} + authorizedUserIds := []string{""} + authorizedUserNames := []string{"shomali11"} - authorizedDefinition := &slacker.CommandDefinition{ + authorizedDefinitionById := &slacker.CommandDefinition{ Description: "Very secret stuff", + Examples: []string{"secret-id"}, AuthorizationFunc: func(botCtx slacker.BotContext, request slacker.Request) bool { - return contains(authorizedUsers, botCtx.Event().User) + return contains(authorizedUserIds, botCtx.Event().UserID) }, Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { response.Reply("You are authorized!") }, } - bot.Command("secret", authorizedDefinition) + authorizedDefinitionByName := &slacker.CommandDefinition{ + Description: "Very secret stuff", + Examples: []string{"secret-name"}, + AuthorizationFunc: func(botCtx slacker.BotContext, request slacker.Request) bool { + return contains(authorizedUserNames, botCtx.Event().UserProfile.DisplayName) + }, + Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + response.Reply("You are authorized!") + }, + } + + bot.Command("secret-id", authorizedDefinitionById) + bot.Command("secret-name", authorizedDefinitionByName) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/15/example15.go b/examples/15/example15.go index 1f327c3..63de21b 100644 --- a/examples/15/example15.go +++ b/examples/15/example15.go @@ -2,17 +2,17 @@ package main import ( "context" - "github.com/shomali11/slacker" - "github.com/slack-go/slack" - "github.com/slack-go/slack/socketmode" "log" "os" + + "github.com/shomali11/slacker" + "github.com/slack-go/slack" ) func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - bot.Interactive(func(s *slacker.Slacker, event *socketmode.Event, callback *slack.InteractionCallback) { + bot.Interactive(func(botCtx slacker.InteractiveBotContext, callback *slack.InteractionCallback) { if callback.Type != slack.InteractionTypeBlockActions { return } @@ -36,10 +36,10 @@ func main() { text = "I don't understand your mood..." } - _, _, _ = s.Client().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), + _, _, _ = botCtx.ApiClient().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), slack.MsgOptionReplaceOriginal(callback.ResponseURL)) - s.SocketMode().Ack(*event.Request) + botCtx.SocketModeClient().Ack(*botCtx.Event().Request) }) definition := &slacker.CommandDefinition{ @@ -53,8 +53,9 @@ func main() { slack.NewSectionBlock(slack.NewTextBlockObject(slack.PlainTextType, "What is your mood today?", true, false), nil, nil), slack.NewActionBlock("mood-block", happyBtn, sadBtn), })) + if err != nil { - panic(err) + response.ReportError(err) } }, } diff --git a/examples/17/example17.go b/examples/17/example17.go index b8f4853..6143d21 100644 --- a/examples/17/example17.go +++ b/examples/17/example17.go @@ -12,9 +12,9 @@ import ( func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - bot.CleanEventInput(func(in string) string { + bot.SanitizeEventText(func(text string) string { fmt.Println("My slack bot does not like backticks!") - return strings.ReplaceAll(in, "`", "") + return strings.ReplaceAll(text, "`", "") }) bot.Command("my-command", &slacker.CommandDefinition{ diff --git a/examples/18/example18.go b/examples/18/example18.go index adf011a..d93de09 100644 --- a/examples/18/example18.go +++ b/examples/18/example18.go @@ -2,56 +2,27 @@ package main import ( "context" - "fmt" "log" "os" "github.com/shomali11/slacker" "github.com/slack-go/slack" - "github.com/slack-go/slack/socketmode" ) -func slackerCmd(actionID string) func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - return func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - happyBtn := slack.NewButtonBlockElement("happy", "true", slack.NewTextBlockObject("plain_text", "Happy 🙂", true, false)) - happyBtn.Style = "primary" - sadBtn := slack.NewButtonBlockElement("sad", "false", slack.NewTextBlockObject("plain_text", "Sad â˜šī¸", true, false)) - sadBtn.Style = "danger" - - err := response.Reply("", slacker.WithBlocks([]slack.Block{ - slack.NewSectionBlock(slack.NewTextBlockObject(slack.PlainTextType, "What is your mood today?", true, false), nil, nil), - slack.NewActionBlock(actionID, happyBtn, sadBtn), - })) - - if err != nil { - fmt.Println(err) - } - } -} - -func slackerInteractive(s *slacker.Slacker, e *socketmode.Event, callback *slack.InteractionCallback, request *socketmode.Request) { - text := "" - action := callback.ActionCallback.BlockActions[0] - switch action.ActionID { - case "happy": - text = "I'm happy to hear you are happy!" - case "sad": - text = "I'm sorry to hear you are sad." - default: - text = "I don't understand your mood..." - } - - _, _, _ = s.Client().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), - slack.MsgOptionReplaceOriginal(callback.ResponseURL)) -} - func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - bot.Command("slacker-cmd", &slacker.CommandDefinition{ - BlockID: "slacker_cmd", - Handler: slackerCmd("slacker_cmd"), - Interactive: slackerInteractive, - HideHelp: true, + bot.Command("ping", &slacker.CommandDefinition{ + Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { + response.Reply("pong") + }, + }) + + // Run every minute + bot.Job("0 * * * * *", &slacker.JobDefinition{ + Description: "A cron job that runs every minute", + Handler: func(jobCtx slacker.JobContext) { + jobCtx.ApiClient().PostMessage("#test", slack.MsgOptionText("Hello!", false)) + }, }) ctx, cancel := context.WithCancel(context.Background()) diff --git a/examples/19/example19.go b/examples/19/example19.go index 0e684ff..a032378 100644 --- a/examples/19/example19.go +++ b/examples/19/example19.go @@ -15,7 +15,7 @@ import ( func main() { bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) - bot.CustomCommand(func(usage string, definition *slacker.CommandDefinition) slacker.BotCommand { + bot.CustomCommand(func(usage string, definition *slacker.CommandDefinition) slacker.Command { return &cmd{ usage: usage, definition: definition, @@ -62,9 +62,9 @@ func (c *cmd) Tokenize() []*commander.Token { } func (c *cmd) Execute(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - log.Printf("Executing command [%s] invoked by %s", c.usage, botCtx.Event().User) + log.Printf("Executing command [%s] invoked by %s", c.usage, botCtx.Event().UserID) c.definition.Handler(botCtx, request, response) } -func (c *cmd) Interactive(*slacker.Slacker, *socketmode.Event, *slack.InteractionCallback, *socketmode.Request) { +func (c *cmd) Interactive(slacker.InteractiveBotContext, *socketmode.Request, *slack.InteractionCallback) { } diff --git a/examples/5/example5.go b/examples/5/example5.go index 2d622b6..f074231 100644 --- a/examples/5/example5.go +++ b/examples/5/example5.go @@ -22,7 +22,7 @@ func main() { threadReplyDefinition := &slacker.CommandDefinition{ Description: "Tests errors in threads", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.ReportError(errors.New("oops, an error occurred"), slacker.WithThreadError(true)) + response.ReportError(errors.New("oops, an error occurred"), slacker.WithThreadReplyError(true)) }, } diff --git a/examples/6/example6.go b/examples/6/example6.go index bf3a28c..3259938 100644 --- a/examples/6/example6.go +++ b/examples/6/example6.go @@ -17,12 +17,12 @@ func main() { Description: "Upload a sentence!", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { sentence := request.Param("sentence") - client := botCtx.Client() - ev := botCtx.Event() + apiClient := botCtx.ApiClient() + event := botCtx.Event() - if ev.Channel != "" { - client.PostMessage(ev.Channel, slack.MsgOptionText("Uploading file ...", false)) - _, err := client.UploadFile(slack.FileUploadParameters{Content: sentence, Channels: []string{ev.Channel}}) + if event.ChannelID != "" { + apiClient.PostMessage(event.ChannelID, slack.MsgOptionText("Uploading file ...", false)) + _, err := apiClient.UploadFile(slack.FileUploadParameters{Content: sentence, Channels: []string{event.ChannelID}}) if err != nil { fmt.Printf("Error encountered when uploading file: %+v\n", err) } diff --git a/job.go b/job.go new file mode 100644 index 0000000..15a7a6a --- /dev/null +++ b/job.go @@ -0,0 +1,48 @@ +package slacker + +// JobDefinition structure contains definition of the job +type JobDefinition struct { + Description string + Handler func(JobContext) + + // HideHelp will hide this job definition from appearing in the `help` results. + HideHelp bool +} + +// NewJob creates a new job object +func NewJob(spec string, definition *JobDefinition) Job { + return &job{ + spec: spec, + definition: definition, + } +} + +// Job interface +type Job interface { + Spec() string + Definition() *JobDefinition + Callback(JobContext) func() +} + +// job structure contains the job's spec and handler +type job struct { + spec string + definition *JobDefinition +} + +// Spec returns the job's spec +func (c *job) Spec() string { + return c.spec +} + +// Definition returns the job's definition +func (c *job) Definition() *JobDefinition { + return c.definition +} + +// Callback returns cron job callback +func (c *job) Callback(jobCtx JobContext) func() { + return func() { + c.Definition().Handler(jobCtx) + } +} diff --git a/message_event.go b/message_event.go index 6addb1a..e33cb3e 100644 --- a/message_event.go +++ b/message_event.go @@ -14,16 +14,16 @@ import ( // used to prevent frequent type assertions when evaluating the event. type MessageEvent struct { // Channel ID where the message was sent - Channel string + ChannelID string - // ChannelName where the message was sent - ChannelName string + // Channel contains information about the channel + Channel *slack.Channel // User ID of the sender - User string + UserID string - // UserName of the the sender - UserName string + // UserProfile contains all the information details of a given user + UserProfile *slack.UserProfile // Text is the unalterted text of the message, as returned by Slack Text string @@ -63,73 +63,86 @@ func (e *MessageEvent) IsBot() bool { return e.BotID != "" } -// NewMessageEvent creates a new message event structure -func NewMessageEvent(slacker *Slacker, evt interface{}, req *socketmode.Request) *MessageEvent { - var me *MessageEvent +// NewMessageEvent creates a new message event structure +func NewMessageEvent(slacker *Slacker, event interface{}, req *socketmode.Request) *MessageEvent { + var messageEvent *MessageEvent - switch ev := evt.(type) { + switch ev := event.(type) { case *slackevents.MessageEvent: - me = &MessageEvent{ - Channel: ev.Channel, - ChannelName: getChannelName(slacker, ev.Channel), - User: ev.User, - UserName: getUserName(slacker, ev.User), + messageEvent = &MessageEvent{ + ChannelID: ev.Channel, + Channel: getChannel(slacker, ev.Channel), + UserID: ev.User, + UserProfile: getUserProfile(slacker, ev.User), Text: ev.Text, - Data: evt, + Data: event, Type: ev.Type, TimeStamp: ev.TimeStamp, ThreadTimeStamp: ev.ThreadTimeStamp, BotID: ev.BotID, } case *slackevents.AppMentionEvent: - me = &MessageEvent{ - Channel: ev.Channel, - ChannelName: getChannelName(slacker, ev.Channel), - User: ev.User, - UserName: getUserName(slacker, ev.User), + messageEvent = &MessageEvent{ + ChannelID: ev.Channel, + Channel: getChannel(slacker, ev.Channel), + UserID: ev.User, + UserProfile: getUserProfile(slacker, ev.User), Text: ev.Text, - Data: evt, + Data: event, Type: ev.Type, TimeStamp: ev.TimeStamp, ThreadTimeStamp: ev.ThreadTimeStamp, BotID: ev.BotID, } case *slack.SlashCommand: - me = &MessageEvent{ - Channel: ev.ChannelID, - ChannelName: ev.ChannelName, - User: ev.UserID, - UserName: ev.UserName, + messageEvent = &MessageEvent{ + ChannelID: ev.ChannelID, + Channel: getChannel(slacker, ev.ChannelID), + UserID: ev.UserID, + UserProfile: getUserProfile(slacker, ev.UserID), Text: fmt.Sprintf("%s %s", ev.Command[1:], ev.Text), Data: req, Type: req.Type, } + default: + return nil } // Filter out other bots. At the very least this is needed for MessageEvent // to prevent the bot from self-triggering and causing loops. However better // logic should be in place to prevent repeated self-triggering / bot-storms // if we want to enable this later. - if me.IsBot() { + if messageEvent.IsBot() { return nil } - return me + return messageEvent } -func getChannelName(slacker *Slacker, channelID string) string { - channel, err := slacker.client.GetConversationInfo(channelID, true) +func getChannel(slacker *Slacker, channelID string) *slack.Channel { + if len(channelID) == 0 { + return nil + } + + channel, err := slacker.apiClient.GetConversationInfo(&slack.GetConversationInfoInput{ + ChannelID: channelID, + IncludeLocale: false, + IncludeNumMembers: false}) if err != nil { fmt.Printf("unable to get channel info for %s: %v\n", channelID, err) - return channelID + return nil } - return channel.Name + return channel } -func getUserName(slacker *Slacker, userID string) string { - user, err := slacker.client.GetUserInfo(userID) +func getUserProfile(slacker *Slacker, userID string) *slack.UserProfile { + if len(userID) == 0 { + return nil + } + + user, err := slacker.apiClient.GetUserInfo(userID) if err != nil { fmt.Printf("unable to get user info for %s: %v\n", userID, err) - return userID + return nil } - return user.Name + return &user.Profile } diff --git a/response.go b/response.go index 2791c0d..6a85e50 100644 --- a/response.go +++ b/response.go @@ -30,18 +30,18 @@ type response struct { func (r *response) ReportError(err error, options ...ReportErrorOption) { defaults := NewReportErrorDefaults(options...) - client := r.botCtx.Client() - ev := r.botCtx.Event() + apiClient := r.botCtx.ApiClient() + event := r.botCtx.Event() opts := []slack.MsgOption{ slack.MsgOptionText(fmt.Sprintf(errorFormat, err.Error()), false), } if defaults.ThreadResponse { - opts = append(opts, slack.MsgOptionTS(ev.TimeStamp)) + opts = append(opts, slack.MsgOptionTS(event.TimeStamp)) } - _, _, err = client.PostMessage(ev.Channel, opts...) + _, _, err = apiClient.PostMessage(event.ChannelID, opts...) if err != nil { fmt.Printf("failed posting message: %v\n", err) } @@ -53,16 +53,16 @@ func (r *response) Reply(message string, options ...ReplyOption) error { if ev == nil { return fmt.Errorf("unable to get message event details") } - return r.Post(ev.Channel, message, options...) + return r.Post(ev.ChannelID, message, options...) } // Post send a message to a channel func (r *response) Post(channel string, message string, options ...ReplyOption) error { defaults := NewReplyDefaults(options...) - client := r.botCtx.Client() - ev := r.botCtx.Event() - if ev == nil { + apiClient := r.botCtx.ApiClient() + event := r.botCtx.Event() + if event == nil { return fmt.Errorf("unable to get message event details") } @@ -73,10 +73,10 @@ func (r *response) Post(channel string, message string, options ...ReplyOption) } if defaults.ThreadResponse { - opts = append(opts, slack.MsgOptionTS(ev.TimeStamp)) + opts = append(opts, slack.MsgOptionTS(event.TimeStamp)) } - _, _, err := client.PostMessage( + _, _, err := apiClient.PostMessage( channel, opts..., ) diff --git a/slacker.go b/slacker.go index 53d5b57..a20d4e5 100644 --- a/slacker.go +++ b/slacker.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "github.com/robfig/cron" "github.com/shomali11/proper" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" @@ -15,8 +16,8 @@ import ( const ( space = " " dash = "-" - star = "*" newLine = "\n" + lock = ":lock:" invalidToken = "invalid token" helpCommand = "help" directChannelMarker = "D" @@ -25,7 +26,6 @@ const ( boldMessageFormat = "*%s*" italicMessageFormat = "_%s_" quoteMessageFormat = ">_*Example:* %s_" - authorizedUsersOnly = "Authorized users only" slackBotUser = "USLACKBOT" ) @@ -47,56 +47,63 @@ func NewClient(botToken, appToken string, options ...ClientOption) *Slacker { slack.OptionAppLevelToken(appToken), ) - smc := socketmode.New( + socketModeClient := socketmode.New( api, socketmode.OptionDebug(defaults.Debug), ) + slacker := &Slacker{ - client: api, - socketModeClient: smc, + apiClient: api, + socketModeClient: socketModeClient, + cronClient: cron.New(), commandChannel: make(chan *CommandEvent, 100), errUnauthorized: errUnauthorized, botInteractionMode: defaults.BotMode, - cleanEventInput: defaultCleanEventInput, + sanitizeEventText: defaultCleanEventInput, } return slacker } // Slacker contains the Slack API, botCommands, and handlers type Slacker struct { - client *slack.Client - socketModeClient *socketmode.Client - botCommands []BotCommand - botContextConstructor func(ctx context.Context, api *slack.Client, client *socketmode.Client, evt *MessageEvent) BotContext - commandConstructor func(usage string, definition *CommandDefinition) BotCommand - requestConstructor func(botCtx BotContext, properties *proper.Properties) Request - responseConstructor func(botCtx BotContext) ResponseWriter - initHandler func() - errorHandler func(err string) - interactiveEventHandler func(*Slacker, *socketmode.Event, *slack.InteractionCallback) - helpDefinition *CommandDefinition - defaultMessageHandler func(botCtx BotContext, request Request, response ResponseWriter) - defaultEventHandler func(interface{}) - defaultInnerEventHandler func(ctx context.Context, evt interface{}, request *socketmode.Request) - errUnauthorized error - commandChannel chan *CommandEvent - appID string - botInteractionMode BotInteractionMode - cleanEventInput func(in string) string + apiClient *slack.Client + socketModeClient *socketmode.Client + cronClient *cron.Cron + commands []Command + botContextConstructor func(context.Context, *slack.Client, *socketmode.Client, *MessageEvent) BotContext + interactiveBotContextConstructor func(context.Context, *slack.Client, *socketmode.Client, *socketmode.Event) InteractiveBotContext + commandConstructor func(string, *CommandDefinition) Command + requestConstructor func(BotContext, *proper.Properties) Request + responseConstructor func(BotContext) ResponseWriter + jobs []Job + jobContextConstructor func(context.Context, *slack.Client, *socketmode.Client) JobContext + jobConstructor func(string, *JobDefinition) Job + initHandler func() + errorHandler func(err string) + interactiveEventHandler func(InteractiveBotContext, *slack.InteractionCallback) + helpDefinition *CommandDefinition + defaultMessageHandler func(BotContext, Request, ResponseWriter) + defaultEventHandler func(interface{}) + defaultInnerEventHandler func(context.Context, interface{}, *socketmode.Request) + errUnauthorized error + commandChannel chan *CommandEvent + appID string + botInteractionMode BotInteractionMode + sanitizeEventText func(string) string } // BotCommands returns Bot Commands -func (s *Slacker) BotCommands() []BotCommand { - return s.botCommands +func (s *Slacker) BotCommands() []Command { + return s.commands } -// Client returns the internal slack.Client of Slacker struct -func (s *Slacker) Client() *slack.Client { - return s.client +// ApiClient returns the internal slack.Client of Slacker struct +func (s *Slacker) ApiClient() *slack.Client { + return s.apiClient } -// SocketMode returns the internal socketmode.Client of Slacker struct -func (s *Slacker) SocketMode() *socketmode.Client { +// SocketModeClient returns the internal socketmode.Client of Slacker struct +func (s *Slacker) SocketModeClient() *socketmode.Client { return s.socketModeClient } @@ -110,23 +117,33 @@ func (s *Slacker) Err(errorHandler func(err string)) { s.errorHandler = errorHandler } -// CleanEventInput allows the api consumer to override the default event input cleaning behavior -func (s *Slacker) CleanEventInput(cei func(in string) string) { - s.cleanEventInput = cei +// SanitizeEventText allows the api consumer to override the default event text sanitization +func (s *Slacker) SanitizeEventText(sanitizeEventText func(in string) string) { + s.sanitizeEventText = sanitizeEventText } // Interactive assigns an interactive event handler -func (s *Slacker) Interactive(interactiveEventHandler func(*Slacker, *socketmode.Event, *slack.InteractionCallback)) { +func (s *Slacker) Interactive(interactiveEventHandler func(InteractiveBotContext, *slack.InteractionCallback)) { s.interactiveEventHandler = interactiveEventHandler } // CustomBotContext creates a new bot context -func (s *Slacker) CustomBotContext(botContextConstructor func(ctx context.Context, api *slack.Client, client *socketmode.Client, evt *MessageEvent) BotContext) { +func (s *Slacker) CustomBotContext(botContextConstructor func(context.Context, *slack.Client, *socketmode.Client, *MessageEvent) BotContext) { s.botContextConstructor = botContextConstructor } +// CustomInteractiveBotContext creates a new interactive bot context +func (s *Slacker) CustomInteractiveBotContext(interactiveBotContextConstructor func(context.Context, *slack.Client, *socketmode.Client, *socketmode.Event) InteractiveBotContext) { + s.interactiveBotContextConstructor = interactiveBotContextConstructor +} + +// CustomJobContext creates a new job context +func (s *Slacker) CustomJobContext(jobContextConstructor func(context.Context, *slack.Client, *socketmode.Client) JobContext) { + s.jobContextConstructor = jobContextConstructor +} + // CustomCommand creates a new BotCommand -func (s *Slacker) CustomCommand(commandConstructor func(usage string, definition *CommandDefinition) BotCommand) { +func (s *Slacker) CustomCommand(commandConstructor func(usage string, definition *CommandDefinition) Command) { s.commandConstructor = commandConstructor } @@ -165,12 +182,20 @@ func (s *Slacker) Help(definition *CommandDefinition) { s.helpDefinition = definition } -// Command define a new command and append it to the list of existing commands +// Command define a new command and append it to the list of existing bot commands func (s *Slacker) Command(usage string, definition *CommandDefinition) { if s.commandConstructor == nil { - s.commandConstructor = NewBotCommand + s.commandConstructor = NewCommand } - s.botCommands = append(s.botCommands, s.commandConstructor(usage, definition)) + s.commands = append(s.commands, s.commandConstructor(usage, definition)) +} + +// Job define a new cron job and append it to the list of existing jobs +func (s *Slacker) Job(spec string, definition *JobDefinition) { + if s.jobConstructor == nil { + s.jobConstructor = NewJob + } + s.jobs = append(s.jobs, s.jobConstructor(spec, definition)) } // CommandEvents returns read only command events channel @@ -187,65 +212,71 @@ func (s *Slacker) Listen(ctx context.Context) error { select { case <-ctx.Done(): return - case evt, ok := <-s.socketModeClient.Events: + case socketEvent, ok := <-s.socketModeClient.Events: if !ok { return } - switch evt.Type { + switch socketEvent.Type { case socketmode.EventTypeConnecting: fmt.Println("Connecting to Slack with Socket Mode.") if s.initHandler == nil { continue } go s.initHandler() + case socketmode.EventTypeConnectionError: fmt.Println("Connection failed. Retrying later...") + case socketmode.EventTypeConnected: fmt.Println("Connected to Slack with Socket Mode.") + case socketmode.EventTypeHello: - s.appID = evt.Request.ConnectionInfo.AppID + s.appID = socketEvent.Request.ConnectionInfo.AppID fmt.Printf("Connected as App ID %v\n", s.appID) case socketmode.EventTypeEventsAPI: - ev, ok := evt.Data.(slackevents.EventsAPIEvent) + event, ok := socketEvent.Data.(slackevents.EventsAPIEvent) if !ok { - fmt.Printf("Ignored %+v\n", evt) + fmt.Printf("Ignored %+v\n", socketEvent) continue } - switch ev.InnerEvent.Type { + switch event.InnerEvent.Type { case "message", "app_mention": // message-based events - go s.handleMessageEvent(ctx, ev.InnerEvent.Data, nil) + go s.handleMessageEvent(ctx, event.InnerEvent.Data, nil) default: if s.defaultInnerEventHandler != nil { - s.defaultInnerEventHandler(ctx, ev.InnerEvent.Data, evt.Request) + s.defaultInnerEventHandler(ctx, event.InnerEvent.Data, socketEvent.Request) } else { - fmt.Printf("unsupported inner event: %+v\n", ev.InnerEvent.Type) + fmt.Printf("unsupported inner event: %+v\n", event.InnerEvent.Type) } } - s.socketModeClient.Ack(*evt.Request) + s.socketModeClient.Ack(*socketEvent.Request) + case socketmode.EventTypeSlashCommand: - callback, ok := evt.Data.(slack.SlashCommand) + callback, ok := socketEvent.Data.(slack.SlashCommand) if !ok { - fmt.Printf("Ignored %+v\n", evt) + fmt.Printf("Ignored %+v\n", socketEvent) continue } - s.socketModeClient.Ack(*evt.Request) - go s.handleMessageEvent(ctx, &callback, evt.Request) + s.socketModeClient.Ack(*socketEvent.Request) + go s.handleMessageEvent(ctx, &callback, socketEvent.Request) + case socketmode.EventTypeInteractive: - callback, ok := evt.Data.(slack.InteractionCallback) + callback, ok := socketEvent.Data.(slack.InteractionCallback) if !ok { - fmt.Printf("Ignored %+v\n", evt) + fmt.Printf("Ignored %+v\n", socketEvent) continue } - go s.handleInteractiveEvent(s, &evt, &callback, evt.Request) + go s.handleInteractiveEvent(ctx, &socketEvent, &callback) + default: if s.defaultEventHandler != nil { - s.defaultEventHandler(evt) + s.defaultEventHandler(socketEvent) } else { s.unsupportedEventReceived() } @@ -254,6 +285,9 @@ func (s *Slacker) Listen(ctx context.Context) error { } }() + s.startCronJobs(ctx) + defer s.cronClient.Stop() + // blocking call that handles listening for events and placing them in the // Events channel as well as handling outgoing events. return s.socketModeClient.RunContext(ctx) @@ -263,15 +297,9 @@ func (s *Slacker) unsupportedEventReceived() { s.socketModeClient.Debugf("unsupported Events API event received") } -// GetUserInfo retrieve complete user information -func (s *Slacker) GetUserInfo(user string) (*slack.User, error) { - return s.client.GetUserInfo(user) -} - func (s *Slacker) defaultHelp(botCtx BotContext, request Request, response ResponseWriter) { - authorizedCommandAvailable := false helpMessage := empty - for _, command := range s.botCommands { + for _, command := range s.commands { if command.Definition().HideHelp { continue } @@ -289,8 +317,7 @@ func (s *Slacker) defaultHelp(botCtx BotContext, request Request, response Respo } if command.Definition().AuthorizationFunc != nil { - authorizedCommandAvailable = true - helpMessage += space + fmt.Sprintf(codeMessageFormat, star) + helpMessage += space + lock } helpMessage += newLine @@ -300,9 +327,20 @@ func (s *Slacker) defaultHelp(botCtx BotContext, request Request, response Respo } } - if authorizedCommandAvailable { - helpMessage += fmt.Sprintf(codeMessageFormat, star+space+authorizedUsersOnly) + newLine + for _, command := range s.jobs { + if command.Definition().HideHelp { + continue + } + + helpMessage += fmt.Sprintf(codeMessageFormat, command.Spec()) + space + + if len(command.Definition().Description) > 0 { + helpMessage += dash + space + fmt.Sprintf(italicMessageFormat, command.Definition().Description) + } + + helpMessage += newLine } + response.Reply(helpMessage) } @@ -319,27 +357,45 @@ func (s *Slacker) prependHelpHandle() { s.helpDefinition.Description = helpCommand } - s.botCommands = append([]BotCommand{NewBotCommand(helpCommand, s.helpDefinition)}, s.botCommands...) + s.commands = append([]Command{NewCommand(helpCommand, s.helpDefinition)}, s.commands...) } -func (s *Slacker) handleInteractiveEvent(slacker *Slacker, evt *socketmode.Event, callback *slack.InteractionCallback, req *socketmode.Request) { - for _, cmd := range s.botCommands { +func (s *Slacker) startCronJobs(ctx context.Context) { + if s.jobContextConstructor == nil { + s.jobContextConstructor = NewJobContext + } + + jobCtx := s.jobContextConstructor(ctx, s.apiClient, s.socketModeClient) + for _, jobCommand := range s.jobs { + s.cronClient.AddFunc(jobCommand.Spec(), jobCommand.Callback(jobCtx)) + } + + s.cronClient.Start() +} + +func (s *Slacker) handleInteractiveEvent(ctx context.Context, event *socketmode.Event, callback *slack.InteractionCallback) { + if s.interactiveBotContextConstructor == nil { + s.interactiveBotContextConstructor = NewInteractiveBotContext + } + + botCtx := s.interactiveBotContextConstructor(ctx, s.apiClient, s.socketModeClient, event) + for _, cmd := range s.commands { for _, action := range callback.ActionCallback.BlockActions { if action.BlockID != cmd.Definition().BlockID { continue } - cmd.Interactive(slacker, evt, callback, req) + cmd.Interactive(botCtx, event.Request, callback) return } } if s.interactiveEventHandler != nil { - s.interactiveEventHandler(slacker, evt, callback) + s.interactiveEventHandler(botCtx, callback) } } -func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req *socketmode.Request) { +func (s *Slacker) handleMessageEvent(ctx context.Context, event interface{}, req *socketmode.Request) { if s.botContextConstructor == nil { s.botContextConstructor = NewBotContext } @@ -352,14 +408,14 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * s.responseConstructor = NewResponse } - ev := NewMessageEvent(s, evt, req) - if ev == nil { + messageEvent := NewMessageEvent(s, event, req) + if messageEvent == nil { // event doesn't appear to be a valid message type return - } else if ev.IsBot() { + } else if messageEvent.IsBot() { switch s.botInteractionMode { case BotInteractionModeIgnoreApp: - bot, err := s.client.GetBotInfo(ev.BotID) + bot, err := s.apiClient.GetBotInfo(messageEvent.BotID) if err != nil { if err.Error() == "missing_scope" { fmt.Println("unable to determine if bot response is from me -- please add users:read scope to your app") @@ -373,21 +429,19 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * return } case BotInteractionModeIgnoreAll: - fmt.Printf("Ignoring event that originated from Bot ID: %v\n", ev.BotID) + fmt.Printf("Ignoring event that originated from Bot ID: %v\n", messageEvent.BotID) return default: // BotInteractionModeIgnoreNone is handled in the default case } - } - botCtx := s.botContextConstructor(ctx, s.client, s.socketModeClient, ev) + botCtx := s.botContextConstructor(ctx, s.apiClient, s.socketModeClient, messageEvent) response := s.responseConstructor(botCtx) - eventTxt := s.cleanEventInput(ev.Text) - - for _, cmd := range s.botCommands { - parameters, isMatch := cmd.Match(eventTxt) + eventText := s.sanitizeEventText(messageEvent.Text) + for _, cmd := range s.commands { + parameters, isMatch := cmd.Match(eventText) if !isMatch { continue } @@ -399,7 +453,7 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * } select { - case s.commandChannel <- NewCommandEvent(cmd.Usage(), parameters, ev): + case s.commandChannel <- NewCommandEvent(cmd.Usage(), parameters, messageEvent): default: // full channel, dropped event } @@ -413,4 +467,3 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}, req * s.defaultMessageHandler(botCtx, request, response) } } - From 4163d7eb7b23259beb8f6d43ba784f72ee419faa Mon Sep 17 00:00:00 2001 From: Raed Shomali Date: Tue, 28 Mar 2023 16:18:13 -0400 Subject: [PATCH 54/76] Clean up --- README.md | 2 +- go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index df9af92..fa21840 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Built on top of the Slack API [github.com/slack-go/slack](https://github.com/sla - Contains support for `context.Context` - Replies can be new messages or in threads - Supports authorization -- Supports Cron Jobs using [cron](https://github.com/robfig/cron) +- 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 diff --git a/go.mod b/go.mod index 229f106..9b8f1af 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/shomali11/slacker go 1.14 require ( - github.com/robfig/cron v1.2.0 // indirect + github.com/robfig/cron v1.2.0 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 From 8a7ea74b47f23a2a1bea316e0e828ec5872d4968 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 29 Mar 2023 12:55:23 -0700 Subject: [PATCH 55/76] docs: Add slash command example --- examples/slash-cmd/main.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 examples/slash-cmd/main.go diff --git a/examples/slash-cmd/main.go b/examples/slash-cmd/main.go new file mode 100644 index 0000000..6757916 --- /dev/null +++ b/examples/slash-cmd/main.go @@ -0,0 +1,36 @@ +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) + } + +} From ad7ec92f0423dc39a2bbc533802c4d912905a545 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 29 Mar 2023 13:05:30 -0700 Subject: [PATCH 56/76] docs: Add example of interactive slash command --- examples/interactive/main.go | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 examples/interactive/main.go diff --git a/examples/interactive/main.go b/examples/interactive/main.go new file mode 100644 index 0000000..6fcbc6b --- /dev/null +++ b/examples/interactive/main.go @@ -0,0 +1,67 @@ +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) + } +} From fd92eeabe05ed786ea37c4cbc071010a89bb384c Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 29 Mar 2023 13:09:23 -0700 Subject: [PATCH 57/76] fix: BREAKING CHANGE resolve static check errors Resolve staticcheck errors introduced in v1.4.0 --- context.go | 18 +++++++++--------- examples/10/example10.go | 4 ++-- examples/12/example12.go | 4 ++-- examples/15/example15.go | 2 +- examples/18/example18.go | 2 +- examples/6/example6.go | 2 +- examples/interactive/main.go | 2 +- response.go | 4 ++-- slacker.go | 4 ++-- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/context.go b/context.go index baae92c..0cf752d 100644 --- a/context.go +++ b/context.go @@ -11,7 +11,7 @@ import ( type BotContext interface { Context() context.Context Event() *MessageEvent - ApiClient() *slack.Client + APIClient() *slack.Client SocketModeClient() *socketmode.Client } @@ -37,8 +37,8 @@ func (r *botContext) Event() *MessageEvent { return r.event } -// ApiClient returns the slack API client -func (r *botContext) ApiClient() *slack.Client { +// APIClient returns the slack API client +func (r *botContext) APIClient() *slack.Client { return r.apiClient } @@ -51,7 +51,7 @@ func (r *botContext) SocketModeClient() *socketmode.Client { type InteractiveBotContext interface { Context() context.Context Event() *socketmode.Event - ApiClient() *slack.Client + APIClient() *slack.Client SocketModeClient() *socketmode.Client } @@ -77,8 +77,8 @@ func (r *interactiveBotContext) Event() *socketmode.Event { return r.event } -// ApiClient returns the slack API client -func (r *interactiveBotContext) ApiClient() *slack.Client { +// APIClient returns the slack API client +func (r *interactiveBotContext) APIClient() *slack.Client { return r.apiClient } @@ -90,7 +90,7 @@ func (r *interactiveBotContext) SocketModeClient() *socketmode.Client { // JobContext interface is for job command contexts type JobContext interface { Context() context.Context - ApiClient() *slack.Client + APIClient() *slack.Client SocketModeClient() *socketmode.Client } @@ -110,8 +110,8 @@ func (r *jobContext) Context() context.Context { return r.ctx } -// ApiClient returns the slack API client -func (r *jobContext) ApiClient() *slack.Client { +// APIClient returns the slack API client +func (r *jobContext) APIClient() *slack.Client { return r.apiClient } diff --git a/examples/10/example10.go b/examples/10/example10.go index 87da85c..d3b1a68 100644 --- a/examples/10/example10.go +++ b/examples/10/example10.go @@ -53,7 +53,7 @@ type MyCustomResponseWriter struct { func (r *MyCustomResponseWriter) ReportError(err error, options ...slacker.ReportErrorOption) { defaults := slacker.NewReportErrorDefaults(options...) - apiClient := r.botCtx.ApiClient() + apiClient := r.botCtx.APIClient() event := r.botCtx.Event() opts := []slack.MsgOption{ @@ -82,7 +82,7 @@ func (r *MyCustomResponseWriter) Reply(message string, options ...slacker.ReplyO func (r *MyCustomResponseWriter) Post(channel string, message string, options ...slacker.ReplyOption) error { defaults := slacker.NewReplyDefaults(options...) - apiClient := r.botCtx.ApiClient() + apiClient := r.botCtx.APIClient() ev := r.botCtx.Event() if ev == nil { return fmt.Errorf("unable to get message event details") diff --git a/examples/12/example12.go b/examples/12/example12.go index b403342..65ad2ca 100644 --- a/examples/12/example12.go +++ b/examples/12/example12.go @@ -14,7 +14,7 @@ func main() { authorizedUserIds := []string{""} authorizedUserNames := []string{"shomali11"} - authorizedDefinitionById := &slacker.CommandDefinition{ + authorizedDefinitionByID := &slacker.CommandDefinition{ Description: "Very secret stuff", Examples: []string{"secret-id"}, AuthorizationFunc: func(botCtx slacker.BotContext, request slacker.Request) bool { @@ -36,7 +36,7 @@ func main() { }, } - bot.Command("secret-id", authorizedDefinitionById) + bot.Command("secret-id", authorizedDefinitionByID) bot.Command("secret-name", authorizedDefinitionByName) ctx, cancel := context.WithCancel(context.Background()) diff --git a/examples/15/example15.go b/examples/15/example15.go index 63de21b..d05ccfe 100644 --- a/examples/15/example15.go +++ b/examples/15/example15.go @@ -36,7 +36,7 @@ func main() { text = "I don't understand your mood..." } - _, _, _ = botCtx.ApiClient().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), + _, _, _ = botCtx.APIClient().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), slack.MsgOptionReplaceOriginal(callback.ResponseURL)) botCtx.SocketModeClient().Ack(*botCtx.Event().Request) diff --git a/examples/18/example18.go b/examples/18/example18.go index d93de09..8302aac 100644 --- a/examples/18/example18.go +++ b/examples/18/example18.go @@ -21,7 +21,7 @@ func main() { 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)) + jobCtx.APIClient().PostMessage("#test", slack.MsgOptionText("Hello!", false)) }, }) diff --git a/examples/6/example6.go b/examples/6/example6.go index 3259938..824d77c 100644 --- a/examples/6/example6.go +++ b/examples/6/example6.go @@ -17,7 +17,7 @@ func main() { Description: "Upload a sentence!", Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { sentence := request.Param("sentence") - apiClient := botCtx.ApiClient() + apiClient := botCtx.APIClient() event := botCtx.Event() if event.ChannelID != "" { diff --git a/examples/interactive/main.go b/examples/interactive/main.go index 6fcbc6b..dc50333 100644 --- a/examples/interactive/main.go +++ b/examples/interactive/main.go @@ -44,7 +44,7 @@ func slackerInteractive(ctx slacker.InteractiveBotContext, request *socketmode.R text = "I don't understand your mood..." } - _, _, _ = ctx.ApiClient().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), + _, _, _ = ctx.APIClient().PostMessage(callback.Channel.ID, slack.MsgOptionText(text, false), slack.MsgOptionReplaceOriginal(callback.ResponseURL)) } diff --git a/response.go b/response.go index 6a85e50..14d3c76 100644 --- a/response.go +++ b/response.go @@ -30,7 +30,7 @@ type response struct { func (r *response) ReportError(err error, options ...ReportErrorOption) { defaults := NewReportErrorDefaults(options...) - apiClient := r.botCtx.ApiClient() + apiClient := r.botCtx.APIClient() event := r.botCtx.Event() opts := []slack.MsgOption{ @@ -60,7 +60,7 @@ func (r *response) Reply(message string, options ...ReplyOption) error { func (r *response) Post(channel string, message string, options ...ReplyOption) error { defaults := NewReplyDefaults(options...) - apiClient := r.botCtx.ApiClient() + apiClient := r.botCtx.APIClient() event := r.botCtx.Event() if event == nil { return fmt.Errorf("unable to get message event details") diff --git a/slacker.go b/slacker.go index a20d4e5..e6b56b6 100644 --- a/slacker.go +++ b/slacker.go @@ -97,8 +97,8 @@ func (s *Slacker) BotCommands() []Command { return s.commands } -// ApiClient returns the internal slack.Client of Slacker struct -func (s *Slacker) ApiClient() *slack.Client { +// APIClient returns the internal slack.Client of Slacker struct +func (s *Slacker) APIClient() *slack.Client { return s.apiClient } From 81c0c91fd849211624003e24badeb5eee73cd72f Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Tue, 4 Apr 2023 00:48:07 -0700 Subject: [PATCH 58/76] docs: Add Slack badge, remove unused Travis build badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fa21840..ddd1b92 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# slacker [![Build Status](https://travis-ci.com/shomali11/slacker.svg?branch=master)](https://travis-ci.com/shomali11/slacker) [![Go Report Card](https://goreportcard.com/badge/github.com/shomali11/slacker)](https://goreportcard.com/report/github.com/shomali11/slacker) [![GoDoc](https://godoc.org/github.com/shomali11/slacker?status.svg)](https://godoc.org/github.com/shomali11/slacker) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) +# slacker [![Slack](https://img.shields.io/badge/slack-%23slacker--framework-orange)](https://gophers.slack.com/archives/C051MGM3GFL) [![Go Report Card](https://goreportcard.com/badge/github.com/shomali11/slacker)](https://goreportcard.com/report/github.com/shomali11/slacker) [![GoDoc](https://godoc.org/github.com/shomali11/slacker?status.svg)](https://godoc.org/github.com/shomali11/slacker) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) Built on top of the Slack API [github.com/slack-go/slack](https://github.com/slack-go/slack), Slacker is a low-friction framework for creating Slack Bots. From 48e84a3589fe886dff24c6fecdd82cbcc4a8dccb Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 5 Apr 2023 10:47:38 -0700 Subject: [PATCH 59/76] fix: Use log package for logging Using the `log` package ensures that timestamps are emitted with our log entries, and the format matches the logging behavior of the slack-go library when debugging is enabled. Certain log entries, such as ignoring unsupported events, are now gated by whether or not debugging has been enabled. Closes #131 --- message_event.go | 4 ++-- response.go | 3 ++- slacker.go | 37 +++++++++++++++++++++++++------------ 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/message_event.go b/message_event.go index e33cb3e..3736f3b 100644 --- a/message_event.go +++ b/message_event.go @@ -128,7 +128,7 @@ func getChannel(slacker *Slacker, channelID string) *slack.Channel { IncludeLocale: false, IncludeNumMembers: false}) if err != nil { - fmt.Printf("unable to get channel info for %s: %v\n", channelID, err) + slacker.logf("unable to get channel info for %s: %v\n", channelID, err) return nil } return channel @@ -141,7 +141,7 @@ func getUserProfile(slacker *Slacker, userID string) *slack.UserProfile { user, err := slacker.apiClient.GetUserInfo(userID) if err != nil { - fmt.Printf("unable to get user info for %s: %v\n", userID, err) + slacker.logf("unable to get user info for %s: %v\n", userID, err) return nil } return &user.Profile diff --git a/response.go b/response.go index 14d3c76..d364985 100644 --- a/response.go +++ b/response.go @@ -2,6 +2,7 @@ package slacker import ( "fmt" + "log" "github.com/slack-go/slack" ) @@ -43,7 +44,7 @@ func (r *response) ReportError(err error, options ...ReportErrorOption) { _, _, err = apiClient.PostMessage(event.ChannelID, opts...) if err != nil { - fmt.Printf("failed posting message: %v\n", err) + log.Printf("failed posting message: %v\n", err) } } diff --git a/slacker.go b/slacker.go index e6b56b6..fa83285 100644 --- a/slacker.go +++ b/slacker.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "strings" "github.com/robfig/cron" @@ -60,6 +61,7 @@ func NewClient(botToken, appToken string, options ...ClientOption) *Slacker { errUnauthorized: errUnauthorized, botInteractionMode: defaults.BotMode, sanitizeEventText: defaultCleanEventInput, + debug: defaults.Debug, } return slacker } @@ -90,6 +92,7 @@ type Slacker struct { appID string botInteractionMode BotInteractionMode sanitizeEventText func(string) string + debug bool } // BotCommands returns Bot Commands @@ -219,26 +222,26 @@ func (s *Slacker) Listen(ctx context.Context) error { switch socketEvent.Type { case socketmode.EventTypeConnecting: - fmt.Println("Connecting to Slack with Socket Mode.") + s.logf("Connecting to Slack with Socket Mode.") if s.initHandler == nil { continue } go s.initHandler() case socketmode.EventTypeConnectionError: - fmt.Println("Connection failed. Retrying later...") + s.logf("Connection failed. Retrying later...") case socketmode.EventTypeConnected: - fmt.Println("Connected to Slack with Socket Mode.") + s.logf("Connected to Slack with Socket Mode.") case socketmode.EventTypeHello: s.appID = socketEvent.Request.ConnectionInfo.AppID - fmt.Printf("Connected as App ID %v\n", s.appID) + s.logf("Connected as App ID %v\n", s.appID) case socketmode.EventTypeEventsAPI: event, ok := socketEvent.Data.(slackevents.EventsAPIEvent) if !ok { - fmt.Printf("Ignored %+v\n", socketEvent) + s.debugf("Ignored %+v\n", socketEvent) continue } @@ -250,7 +253,7 @@ func (s *Slacker) Listen(ctx context.Context) error { if s.defaultInnerEventHandler != nil { s.defaultInnerEventHandler(ctx, event.InnerEvent.Data, socketEvent.Request) } else { - fmt.Printf("unsupported inner event: %+v\n", event.InnerEvent.Type) + s.debugf("unsupported inner event: %+v\n", event.InnerEvent.Type) } } @@ -259,7 +262,7 @@ func (s *Slacker) Listen(ctx context.Context) error { case socketmode.EventTypeSlashCommand: callback, ok := socketEvent.Data.(slack.SlashCommand) if !ok { - fmt.Printf("Ignored %+v\n", socketEvent) + s.debugf("Ignored %+v\n", socketEvent) continue } s.socketModeClient.Ack(*socketEvent.Request) @@ -268,7 +271,7 @@ func (s *Slacker) Listen(ctx context.Context) error { case socketmode.EventTypeInteractive: callback, ok := socketEvent.Data.(slack.InteractionCallback) if !ok { - fmt.Printf("Ignored %+v\n", socketEvent) + s.debugf("Ignored %+v\n", socketEvent) continue } @@ -418,18 +421,18 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, event interface{}, req bot, err := s.apiClient.GetBotInfo(messageEvent.BotID) if err != nil { if err.Error() == "missing_scope" { - fmt.Println("unable to determine if bot response is from me -- please add users:read scope to your app") + s.logf("unable to determine if bot response is from me -- please add users:read scope to your app\n") } else { - fmt.Printf("unable to get bot that sent message information: %v\n", err) + s.debugf("unable to get bot that sent message information: %v\n", err) } return } if bot.AppID == s.appID { - fmt.Printf("Ignoring event that originated from my App ID: %v\n", bot.AppID) + s.debugf("Ignoring event that originated from my App ID: %v\n", bot.AppID) return } case BotInteractionModeIgnoreAll: - fmt.Printf("Ignoring event that originated from Bot ID: %v\n", messageEvent.BotID) + s.debugf("Ignoring event that originated from Bot ID: %v\n", messageEvent.BotID) return default: // BotInteractionModeIgnoreNone is handled in the default case @@ -467,3 +470,13 @@ func (s *Slacker) handleMessageEvent(ctx context.Context, event interface{}, req s.defaultMessageHandler(botCtx, request, response) } } + +func (s *Slacker) logf(format string, v ...interface{}) { + log.Printf(format, v...) +} + +func (s *Slacker) debugf(format string, v ...interface{}) { + if s.debug { + log.Printf(format, v...) + } +} From 68117e404d337f077049b8612b6b2d70f9a2638d Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 5 Apr 2023 11:25:00 -0700 Subject: [PATCH 60/76] docs: Add commit message standards to contrib docs Remove similar section from maintainer docs --- CONTRIBUTING.md | 30 ++++++++++++++++++++++++++++++ MAINTAINING.md | 16 ++-------------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49fe11e..71a2bdc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,3 +82,33 @@ This section guides you through making a successful pull request. * Someone will review your PR and ensure it meets these guidelines. If it does not, we will ask you to fix the identified issues. + +### Commit Messages + +Commit subjects should begin with the following prefix `:`, where type is +one of the following change types: + +- `feat` adds a new feature or expands upon existing behavior +- `fix` for changes that resolve an error in our code +- `docs` for documentation updates +- `chore` for changes related to project maintenance, such as github actions or linter configurations. +- `revert` for a commit that reverts another commit +- `test` for adding missing tests + +When multiple change types are present (ie. a new feature is created and as a result fixes a bug), you should use `feat`. + +If a change addresses one or more issues, be sure and include `Closes #1, #2, +...` as a dedicated line at the end of your commit body. + +An example commit may look like: + +``` +feat: Add support for a custom command channel size + +Create an additional functional option `WithCommandChanSize(int)` that +may be passed into `NewClient(...)` that lets the user specify the +size of the channel used to hold incoming commands to be processed. This +may be useful to expand for high-volume bots. + +Closes #319 +``` diff --git a/MAINTAINING.md b/MAINTAINING.md index 7a95b44..95167e5 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -18,17 +18,5 @@ those that start with `fix:` will be grouped into a "Bug fixes" section. Commits that begin with `chore:` or `docs:` will be excluded, and all others will be added to an "Others" section in the changelog. -When reviewing pull requests or committing code, it is strongly encouraged to -use one of the aformentioned prefixes so that changelogs are nicely formatted -and organized. - -## Commit Messages - -To maintain a tidy changelog on release, we should encourage the use of the -following commit subject prefixes (see the Changelogs for details on how they -are used) - -- `feat`: New features -- `fix`: Bug fixes -- `docs`: Usage documentation changes (ie. examples, README) -- `chore`: Housekeeping taks that don't touch code or usage docs +For more details on commit message formatting see the +[CONTRIBUTING](./CONTRIBUTING.md) doc. From bd48d9e9503531eade75f47c2944f6dc3c44143f Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Tue, 4 Apr 2023 18:04:36 -0700 Subject: [PATCH 61/76] chore: Add commit message check Adds workflow to enforce our commit message format during pull requests and pushing to master branch. --- .github/workflows/commit-message-check.yaml | 43 +++++++++++++++++++++ MAINTAINING.md | 5 +++ 2 files changed, 48 insertions(+) create mode 100644 .github/workflows/commit-message-check.yaml diff --git a/.github/workflows/commit-message-check.yaml b/.github/workflows/commit-message-check.yaml new file mode 100644 index 0000000..3a3c792 --- /dev/null +++ b/.github/workflows/commit-message-check.yaml @@ -0,0 +1,43 @@ +name: 'Commit Message Check' +on: + pull_request: {} + pull_request_target: {} + push: + branches: + - master + - 'releases/*' + +jobs: + check-commit-message: + name: Check Commit Message + runs-on: ubuntu-latest + steps: + - name: Check Commit Subject Prefix + uses: gsactions/commit-message-checker@v2 + with: + pattern: '(fix|feat|chore|docs|test|refactor|revert|test)(\[[^]]+\])?: .*$' + flags: 'gm' + error: >- + Your first line is missing a valid subject prefix. See Commit + Messages section of CONTRIBUTING doc + checkAllCommitMessages: 'true' + excludeTitle: 'true' + excludeDescription: 'true' + accessToken: ${{ secrets.GITHUB_TOKEN }} + + - name: Check Line Length + uses: gsactions/commit-message-checker@v2 + with: + pattern: '^(?![^#].{74})' + error: 'The maximum line length of 74 characters is exceeded.' + excludeDescription: 'true' + excludeTitle: 'true' + checkAllCommitMessages: 'true' + accessToken: ${{ secrets.GITHUB_TOKEN }} + +# Maybe in the future? +# - name: Check for Resolves / Fixes +# uses: gsactions/commit-message-checker@v2 +# with: +# pattern: '^.+(Resolves|Fixes): \#[0-9]+$' +# error: 'You need at least one "Resolves|Fixes: #" line.' diff --git a/MAINTAINING.md b/MAINTAINING.md index 95167e5..1a4f03a 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -18,5 +18,10 @@ those that start with `fix:` will be grouped into a "Bug fixes" section. Commits that begin with `chore:` or `docs:` will be excluded, and all others will be added to an "Others" section in the changelog. +The [commit-message-check](./.github/workflows/commit-message-check.yaml) +workflow enforces this format during pull requests. When pushing directly to +master this will not prevent the commit from being pushed but the check will +still fail. + For more details on commit message formatting see the [CONTRIBUTING](./CONTRIBUTING.md) doc. From 761c272ec439d603bf8184cd57f7b86183799af8 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 5 Apr 2023 02:00:31 -0700 Subject: [PATCH 62/76] chore: Add commitizen-go configuration Specify a commitizen-go configuration that matches our preferred commit message format so that users who already use commitizen-go will be guided to use the proper format. --- .git-czrc.json | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .git-czrc.json diff --git a/.git-czrc.json b/.git-czrc.json new file mode 100644 index 0000000..12bbc5b --- /dev/null +++ b/.git-czrc.json @@ -0,0 +1,55 @@ +{ + "message": { + "items": [ + { + "name": "type", + "desc": "Select the type of change that you're committing:", + "form": "select", + "options": [ + { "name": "feat", "desc": "feat: A new feature" }, + { "name": "fix", "desc": "fix: A bug fix" }, + { "name": "docs", "desc": "docs: Documentation only changes" }, + { + "name": "refactor", + "desc": "refactor: A code change that neither fixes a bug nor adds a feature" + }, + { "name": "test", "desc": "test: Adding missing tests" }, + { + "name": "chore", + "desc": + "chore: Changes to the build process or auxiliary tools\n and libraries such as documentation generation" + }, + { "name": "revert", "desc": "revert: Revert to a commit" }, + { "name": "WIP", "desc": "WIP: Work in progress" } + ], + "required": true + }, + { + "name": "scope", + "desc": "Scope. Could be anything specifying place of the commit change (users, db, poll):", + "form": "input" + }, + { + "name": "subject", + "desc": "Subject. Concise description of the changes. Imperative, lower case and no final dot:", + "form": "input", + "required": true + }, + { + "name": "body", + "desc": "Body. Motivation for the change and contrast this with previous behavior:", + "form": "multiline" + }, + { + "name": "footer", + "desc": "Footer. Information about Breaking Changes and reference issues that this commit closes:", + "form": "multiline" + } + ], + "template": "{{.type}}{{with .scope}}({{.}}){{end}}: {{.subject}}{{with .body}}\n\n{{.}}{{end}}{{with .footer}}\n\n{{.}}{{end}}" + } +} + + + + From ae37100db81db07ef0aff21c21f4250f4918472a Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 5 Apr 2023 13:00:17 -0700 Subject: [PATCH 63/76] docs: Add docs on how to release new versions --- MAINTAINING.md | 53 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/MAINTAINING.md b/MAINTAINING.md index 1a4f03a..9696f42 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -1,27 +1,50 @@ Docs for slacker maintainers +## Versioning + +Version numbers adhere to go [module versioning +numbering](https://go.dev/doc/modules/version-numbers). + +Use the following criteria to identify which component of the version to change: + +- If a breaking change is made, the major version must be incremented +- If any features are added, the minor version should be incremented +- If only fixes are added, the patch version should be incremented +- If no code changes are made, no version change should be made + +When updating the major version we must also update our [go.mod](./go.mod) +module path to reflect this. For example the version 2.x.x module path should +end with `/v2`. + ## Releases -Releases of Slacker are handled by [goreleaser](https://goreleaser.com) and -Github actions. Simply tagging a release with a semver compatible version tag -(ie. vX.Y.Z) and pushing the tag will trigger a Github action to generate a -release. See the goreleaser [config](.goreleaser.yaml) and Github -[workflow](.github/workflows/.goreleaser.yaml) files. +Once all changes are merged to the master branch, a new release can be created +by performing the following steps: + +- Identify new version number, `ie. v1.2.3` +- Use [golang.org/x/exp/cmd/gorelease](https://pkg.go.dev/golang.org/x/exp/cmd/gorelease) to ensure version is acceptable + - If issues are identified, either fix the issues or change the target version + - Example: `gorelease -base= -version=` +- Tag commit with new version +- Push tag upstream + +Once pushed, the [goreleaser](./.github/workflows/goreleaser.yaml) workflow is +triggered to create a new GitHub release from this tag along with a changelog +since the previous release. ### Changelogs -goreleaser handles generating our changelog based on the commit subject of each -commit. +Changelog entries depend on commit subjects, which is why it is important that +we encourage well written commit messages. + +Based on the commit message, we group changes together like so: -Commits that start with `feat:` are grouped into a "Features" section, while -those that start with `fix:` will be grouped into a "Bug fixes" section. Commits -that begin with `chore:` or `docs:` will be excluded, and all others will be -added to an "Others" section in the changelog. +- `Features` groups all commits of type `feat` +- `Bug Fixes` groups all commits of type `fix` +- `Other` groups all other commits -The [commit-message-check](./.github/workflows/commit-message-check.yaml) -workflow enforces this format during pull requests. When pushing directly to -master this will not prevent the commit from being pushed but the check will -still fail. +Note that the `chore` and `docs` commit types are ignored and will not show up +in the changelog. For more details on commit message formatting see the [CONTRIBUTING](./CONTRIBUTING.md) doc. From c1eb8ca21878ec85d734617269f14c51f15959bb Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Wed, 12 Apr 2023 22:38:17 -0700 Subject: [PATCH 64/76] chore: Fix commit message check While we keep merge commits, we need to account for them in our message check. GoReleaser seems to ignore them so this doesn't impact the changelog. --- .github/workflows/commit-message-check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/commit-message-check.yaml b/.github/workflows/commit-message-check.yaml index 3a3c792..d3cea38 100644 --- a/.github/workflows/commit-message-check.yaml +++ b/.github/workflows/commit-message-check.yaml @@ -15,7 +15,7 @@ jobs: - name: Check Commit Subject Prefix uses: gsactions/commit-message-checker@v2 with: - pattern: '(fix|feat|chore|docs|test|refactor|revert|test)(\[[^]]+\])?: .*$' + pattern: '^((fix|feat|chore|docs|test|refactor|revert|test)(\[[^]]+\])?:\ |[Mm]erge\ pull\ request\ ).*$' flags: 'gm' error: >- Your first line is missing a valid subject prefix. See Commit From 48bf59b85e5c21c08b8b60ed67a42b85d211ade6 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Sat, 15 Apr 2023 00:34:51 -0700 Subject: [PATCH 65/76] fix: Resolve regression of BotInteractionMode (#137) In commit d3d36ce8fb8cf7a9110034b02acf671ad6a37146 a regression was introduced that broke BotInteractionMode and instead ignored all bot messages. Closes #135 --- message_event.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/message_event.go b/message_event.go index 3736f3b..0cfa766 100644 --- a/message_event.go +++ b/message_event.go @@ -108,13 +108,6 @@ func NewMessageEvent(slacker *Slacker, event interface{}, req *socketmode.Reques return nil } - // Filter out other bots. At the very least this is needed for MessageEvent - // to prevent the bot from self-triggering and causing loops. However better - // logic should be in place to prevent repeated self-triggering / bot-storms - // if we want to enable this later. - if messageEvent.IsBot() { - return nil - } return messageEvent } From c38812ce2a7fb20f60a3335bc73f564904e9a096 Mon Sep 17 00:00:00 2001 From: Kevin Paulisse Date: Sat, 15 Apr 2023 02:53:32 -0500 Subject: [PATCH 66/76] feat: Let the slack API URL be set (#123) --- defaults.go | 9 +++++++++ slacker.go | 12 ++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/defaults.go b/defaults.go index f3e5710..cee3769 100644 --- a/defaults.go +++ b/defaults.go @@ -5,6 +5,13 @@ 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) { @@ -21,12 +28,14 @@ func WithBotInteractionMode(mode BotInteractionMode) ClientOption { // 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, } diff --git a/slacker.go b/slacker.go index fa83285..f2e3f2a 100644 --- a/slacker.go +++ b/slacker.go @@ -42,10 +42,18 @@ func defaultCleanEventInput(msg string) string { func NewClient(botToken, appToken string, options ...ClientOption) *Slacker { defaults := newClientDefaults(options...) - api := slack.New( - botToken, + 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..., ) socketModeClient := socketmode.New( From bc3ade56b54a5f33759b997a4085834e76ab72fb Mon Sep 17 00:00:00 2001 From: Raed Shomali Date: Fri, 23 Jun 2023 22:39:08 -0400 Subject: [PATCH 67/76] V2 Foundation: Groups, Middlewares, Simplified Interfaces, ... (#134) feat: Slacker V2 - Simplified Interfaces - Added support for Groups, Middleware - Enhanced support for Interaction Callbacks - Enhanced support for Cron Jobs - Added support for more message actions - Improved examples and documentation --- README.md | 1035 +---------------- analytics.go | 25 - .../manifest.yml | 0 bots.go | 16 +- command.go | 45 +- command_group.go | 50 + context.go | 185 +-- defaults.go | 119 -- examples/10/example10.go | 106 -- examples/12/example12.go | 58 - examples/13/example13.go | 53 - examples/14/example14.go | 51 - examples/18/example18.go | 35 - examples/19/example19.go | 70 -- examples/3/example3.go | 39 - examples/4/example4.go | 35 - examples/5/example5.go | 39 - examples/6/example6.go | 42 - examples/9/example9.go | 40 - examples/basic/main.go | 41 + examples/blocks/main.go | 41 + .../{16/example16.go => bot-modes/main.go} | 13 +- examples/command-groups/main.go | 85 ++ examples/command-middleware/main.go | 56 + examples/command-parameters/main.go | 59 + examples/{7/example7.go => contexts/main.go} | 15 +- examples/{11/example11.go => debug/main.go} | 11 +- examples/hooks/main.go | 70 ++ examples/interaction-middleware/main.go | 79 ++ .../example15.go => interaction-sink/main.go} | 29 +- examples/interaction/main.go | 62 + examples/interactive/main.go | 67 -- examples/job-middleware/main.go | 68 ++ examples/job/main.go | 49 + examples/logger/main.go | 74 ++ .../main.go} | 15 +- examples/message-delete/main.go | 37 + .../example1.go => message-ephemeral/main.go} | 11 +- examples/message-error/main.go | 44 + examples/message-replace/main.go | 37 + examples/message-schedule/main.go | 37 + .../{2/example2.go => message-thread/main.go} | 11 +- .../{17/example17.go => sanitization/main.go} | 13 +- examples/slack-api/main.go | 42 + examples/slash-cmd/main.go | 36 - executors.go | 39 + go.mod | 14 +- go.sum | 15 +- handler.go | 19 + interaction.go | 25 + job.go | 40 +- logger.go | 55 + message_event.go | 34 +- options.go | 188 +++ request.go | 33 +- response.go | 120 +- response_replier.go | 60 + response_writer.go | 89 ++ slacker.go | 626 +++++----- 59 files changed, 2070 insertions(+), 2432 deletions(-) delete mode 100644 analytics.go rename {examples/app_manifest => app_manifest}/manifest.yml (100%) create mode 100644 command_group.go delete mode 100644 defaults.go delete mode 100644 examples/10/example10.go delete mode 100644 examples/12/example12.go delete mode 100644 examples/13/example13.go delete mode 100644 examples/14/example14.go delete mode 100644 examples/18/example18.go delete mode 100644 examples/19/example19.go delete mode 100644 examples/3/example3.go delete mode 100644 examples/4/example4.go delete mode 100644 examples/5/example5.go delete mode 100644 examples/6/example6.go delete mode 100644 examples/9/example9.go create mode 100644 examples/basic/main.go create mode 100644 examples/blocks/main.go rename examples/{16/example16.go => bot-modes/main.go} (52%) create mode 100644 examples/command-groups/main.go create mode 100644 examples/command-middleware/main.go create mode 100644 examples/command-parameters/main.go rename examples/{7/example7.go => contexts/main.go} (59%) rename examples/{11/example11.go => debug/main.go} (61%) create mode 100644 examples/hooks/main.go create mode 100644 examples/interaction-middleware/main.go rename examples/{15/example15.go => interaction-sink/main.go} (65%) create mode 100644 examples/interaction/main.go delete mode 100644 examples/interactive/main.go create mode 100644 examples/job-middleware/main.go create mode 100644 examples/job/main.go create mode 100644 examples/logger/main.go rename examples/{8/example8.go => message-attachments/main.go} (64%) create mode 100644 examples/message-delete/main.go rename examples/{1/example1.go => message-ephemeral/main.go} (61%) create mode 100644 examples/message-error/main.go create mode 100644 examples/message-replace/main.go create mode 100644 examples/message-schedule/main.go rename examples/{2/example2.go => message-thread/main.go} (58%) rename examples/{17/example17.go => sanitization/main.go} (54%) create mode 100644 examples/slack-api/main.go delete mode 100644 examples/slash-cmd/main.go create mode 100644 executors.go create mode 100644 handler.go create mode 100644 interaction.go create mode 100644 logger.go create mode 100644 options.go create mode 100644 response_replier.go create mode 100644 response_writer.go 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", " ") } From fcb291b42e9694f8185ea8817251ad1e04a6d7eb Mon Sep 17 00:00:00 2001 From: Raed Shomali Date: Wed, 26 Jul 2023 22:12:13 -0400 Subject: [PATCH 68/76] fix: Improve Reply to be Thread Aware (#139) --- context.go | 26 +++++++++++++++++++++++-- examples/interaction-middleware/main.go | 3 +-- examples/job-middleware/main.go | 5 ++--- examples/message-error/main.go | 2 +- examples/message-thread/main.go | 2 +- message_event.go | 9 +++------ options.go | 8 ++++---- response_replier.go | 8 +++++--- util.go | 9 +++++++++ 9 files changed, 50 insertions(+), 22 deletions(-) create mode 100644 util.go diff --git a/context.go b/context.go index d3d9cf4..1f87c27 100644 --- a/context.go +++ b/context.go @@ -18,7 +18,7 @@ func newCommandContext( ) *CommandContext { request := newRequest(parameters) writer := newWriter(ctx, logger, slackClient) - replier := newReplier(event.ChannelID, event.UserID, event.TimeStamp, writer) + replier := newReplier(event.ChannelID, event.UserID, event.InThread(), event.TimeStamp, writer) response := newResponseReplier(writer, replier) return &CommandContext{ @@ -28,6 +28,7 @@ func newCommandContext( definition: definition, request: request, response: response, + logger: logger, } } @@ -39,6 +40,7 @@ type CommandContext struct { definition *CommandDefinition request *Request response *ResponseReplier + logger Logger } // Context returns the context @@ -71,6 +73,11 @@ func (r *CommandContext) Response() *ResponseReplier { return r.response } +// Logger returns the logger +func (r *CommandContext) Logger() Logger { + return r.logger +} + // newInteractionContext creates a new interaction context func newInteractionContext( ctx context.Context, @@ -79,8 +86,9 @@ func newInteractionContext( callback *slack.InteractionCallback, definition *InteractionDefinition, ) *InteractionContext { + inThread := isMessageInThread(callback.OriginalMessage.ThreadTimestamp, callback.OriginalMessage.Timestamp) writer := newWriter(ctx, logger, slackClient) - replier := newReplier(callback.Channel.ID, callback.User.ID, callback.MessageTs, writer) + replier := newReplier(callback.Channel.ID, callback.User.ID, inThread, callback.MessageTs, writer) response := newResponseReplier(writer, replier) return &InteractionContext{ ctx: ctx, @@ -88,6 +96,7 @@ func newInteractionContext( callback: callback, slackClient: slackClient, response: response, + logger: logger, } } @@ -98,6 +107,7 @@ type InteractionContext struct { callback *slack.InteractionCallback slackClient *slack.Client response *ResponseReplier + logger Logger } // Context returns the context @@ -125,6 +135,11 @@ func (r *InteractionContext) SlackClient() *slack.Client { return r.slackClient } +// Logger returns the logger +func (r *InteractionContext) Logger() Logger { + return r.logger +} + // newJobContext creates a new bot context func newJobContext(ctx context.Context, logger Logger, slackClient *slack.Client, definition *JobDefinition) *JobContext { writer := newWriter(ctx, logger, slackClient) @@ -134,6 +149,7 @@ func newJobContext(ctx context.Context, logger Logger, slackClient *slack.Client definition: definition, slackClient: slackClient, response: response, + logger: logger, } } @@ -143,6 +159,7 @@ type JobContext struct { definition *JobDefinition slackClient *slack.Client response *ResponseWriter + logger Logger } // Context returns the context @@ -164,3 +181,8 @@ func (r *JobContext) Response() *ResponseWriter { func (r *JobContext) SlackClient() *slack.Client { return r.slackClient } + +// Logger returns the logger +func (r *JobContext) Logger() Logger { + return r.logger +} diff --git a/examples/interaction-middleware/main.go b/examples/interaction-middleware/main.go index ca93110..1ad3908 100644 --- a/examples/interaction-middleware/main.go +++ b/examples/interaction-middleware/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "fmt" "log" "os" @@ -66,7 +65,7 @@ func slackerInteractive(ctx *slacker.InteractionContext) { func LoggingInteractionMiddleware() slacker.InteractionMiddlewareHandler { return func(next slacker.InteractionHandler) slacker.InteractionHandler { return func(ctx *slacker.InteractionContext) { - fmt.Printf( + ctx.Logger().Infof( "%s initiated \"%s\" with action \"%v\" in channel %s\n", ctx.Callback().User.ID, ctx.Definition().BlockID, diff --git a/examples/job-middleware/main.go b/examples/job-middleware/main.go index f2d4f21..8a585be 100644 --- a/examples/job-middleware/main.go +++ b/examples/job-middleware/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "fmt" "log" "os" @@ -54,12 +53,12 @@ func main() { func LoggingJobMiddleware() slacker.JobMiddlewareHandler { return func(next slacker.JobHandler) slacker.JobHandler { return func(ctx *slacker.JobContext) { - fmt.Printf( + ctx.Logger().Infof( "%s started\n", ctx.Definition().Name, ) next(ctx) - fmt.Printf( + ctx.Logger().Infof( "%s ended\n", ctx.Definition().Name, ) diff --git a/examples/message-error/main.go b/examples/message-error/main.go index 9d186f1..53c53a0 100644 --- a/examples/message-error/main.go +++ b/examples/message-error/main.go @@ -27,7 +27,7 @@ func main() { Command: "thread", Description: "Tests errors in threads", Handler: func(ctx *slacker.CommandContext) { - ctx.Response().ReplyError(errors.New("oops, an error occurred"), slacker.WithInThread()) + ctx.Response().ReplyError(errors.New("oops, an error occurred"), slacker.WithInThread(true)) }, } diff --git a/examples/message-thread/main.go b/examples/message-thread/main.go index 36018db..8404442 100644 --- a/examples/message-thread/main.go +++ b/examples/message-thread/main.go @@ -18,7 +18,7 @@ func main() { Description: "Ping!", Examples: []string{"ping"}, Handler: func(ctx *slacker.CommandContext) { - ctx.Response().Reply("pong", slacker.WithInThread()) + ctx.Response().Reply("pong", slacker.WithInThread(true)) }, } diff --git a/message_event.go b/message_event.go index 19e1757..fe95054 100644 --- a/message_event.go +++ b/message_event.go @@ -50,12 +50,9 @@ type MessageEvent struct { BotID string } -// IsThread indicates if a message event took place in a thread. -func (e *MessageEvent) IsThread() bool { - if e.ThreadTimeStamp == "" || e.ThreadTimeStamp == e.TimeStamp { - return false - } - return true +// InThread indicates if a message event took place in a thread. +func (e *MessageEvent) InThread() bool { + return isMessageInThread(e.ThreadTimeStamp, e.TimeStamp) } // IsBot indicates if the message was sent by a bot diff --git a/options.go b/options.go index dae30d9..5ab0b85 100644 --- a/options.go +++ b/options.go @@ -81,9 +81,9 @@ func WithAttachments(attachments []slack.Attachment) ReplyOption { } // WithInThread specifies whether to reply inside a thread of the original message -func WithInThread() ReplyOption { +func WithInThread(inThread bool) ReplyOption { return func(defaults *replyOptions) { - defaults.InThread = true + defaults.InThread = &inThread } } @@ -110,7 +110,7 @@ func WithSchedule(timestamp time.Time) ReplyOption { type replyOptions struct { Attachments []slack.Attachment - InThread bool + InThread *bool ReplaceMessageTS string IsEphemeral bool ScheduleTime *time.Time @@ -120,7 +120,7 @@ type replyOptions struct { func newReplyOptions(options ...ReplyOption) *replyOptions { config := &replyOptions{ Attachments: []slack.Attachment{}, - InThread: false, + InThread: nil, } for _, option := range options { diff --git a/response_replier.go b/response_replier.go index 5bc6951..9184454 100644 --- a/response_replier.go +++ b/response_replier.go @@ -5,14 +5,15 @@ import ( ) // 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} +func newReplier(channelID string, userID string, inThread bool, eventTS string, writer *Writer) *Replier { + return &Replier{channelID: channelID, userID: userID, inThread: inThread, eventTS: eventTS, writer: writer} } // Replier sends messages to the same channel the event came from type Replier struct { channelID string userID string + inThread bool eventTS string writer *Writer } @@ -41,7 +42,8 @@ func (r *Replier) convertOptions(options ...ReplyOption) []PostOption { SetAttachments(replyOptions.Attachments), } - if replyOptions.InThread { + // If the original message came from a thread, reply in a thread, unless there is an override + if (replyOptions.InThread == nil && r.inThread) || (replyOptions.InThread != nil && *replyOptions.InThread) { responseOptions = append(responseOptions, SetThreadTS(r.eventTS)) } diff --git a/util.go b/util.go new file mode 100644 index 0000000..408a062 --- /dev/null +++ b/util.go @@ -0,0 +1,9 @@ +package slacker + +// isMessageInThread determines if a message is in a thread +func isMessageInThread(threadTimestamp string, messageTimestamp string) bool { + if threadTimestamp == "" || threadTimestamp == messageTimestamp { + return false + } + return true +} From 0722a13bf57937832bb51f57fe0ab56522494426 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Thu, 27 Jul 2023 09:44:38 -0700 Subject: [PATCH 69/76] chore: Address goreleaser config deprecations archives.replacements was deprecated 2022-11-24 and expired recently on 2023-06-06, causing our builds to break. --- .goreleaser.yaml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d971002..2b3d246 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -7,12 +7,13 @@ before: # You may remove this if you don't use go modules. - go mod tidy archives: - - replacements: - darwin: Darwin - linux: Linux - windows: Windows - 386: i386 - amd64: x86_64 + - name_template: >- + {{ .ProjectName}}_ + {{ .Version }}_ + {{ title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386"}}i386 + {{- else}}{{ .Arch }}{{ end }} checksum: name_template: 'checksums.txt' snapshot: From 22775bb8a98cfb321b3dda19469839e06f52f25a Mon Sep 17 00:00:00 2001 From: Raed Shomali Date: Sat, 29 Jul 2023 22:21:26 -0400 Subject: [PATCH 70/76] feat: Introduce Command Aliases (#141) --- command.go | 25 +++++++++++++++++++- examples/command-aliases/main.go | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 examples/command-aliases/main.go diff --git a/command.go b/command.go index 8f982a2..0d5614a 100644 --- a/command.go +++ b/command.go @@ -8,6 +8,7 @@ import ( // CommandDefinition structure contains definition of the bot command type CommandDefinition struct { Command string + Aliases []string Description string Examples []string Middlewares []CommandMiddlewareHandler @@ -19,9 +20,15 @@ type CommandDefinition struct { // newCommand creates a new bot command object func newCommand(definition *CommandDefinition) Command { + cmdAliases := make([]*commander.Command, 0) + for _, alias := range definition.Aliases { + cmdAliases = append(cmdAliases, commander.NewCommand(alias)) + } + return &command{ definition: definition, cmd: commander.NewCommand(definition.Command), + cmdAliases: cmdAliases, } } @@ -37,6 +44,7 @@ type Command interface { type command struct { definition *CommandDefinition cmd *commander.Command + cmdAliases []*commander.Command } // Definition returns the command definition @@ -46,7 +54,22 @@ func (c *command) Definition() *CommandDefinition { // Match determines whether the bot should respond based on the text received func (c *command) Match(text string) (*proper.Properties, bool) { - return c.cmd.Match(text) + properties, isMatch := c.cmd.Match(text) + if isMatch { + return properties, isMatch + } + + allCommands := make([]*commander.Command, 0) + allCommands = append(allCommands, c.cmd) + allCommands = append(allCommands, c.cmdAliases...) + + for _, cmd := range allCommands { + properties, isMatch := cmd.Match(text) + if isMatch { + return properties, isMatch + } + } + return nil, false } // Tokenize returns the command format's tokens diff --git a/examples/command-aliases/main.go b/examples/command-aliases/main.go new file mode 100644 index 0000000..872715e --- /dev/null +++ b/examples/command-aliases/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/shomali11/slacker/v2" +) + +// Defining a command with aliases + +func main() { + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot.AddCommand(&slacker.CommandDefinition{ + Command: "echo {word}", + Aliases: []string{ + "repeat {word}", + "mimic {word}", + }, + Description: "Echo a word!", + Examples: []string{ + "echo hello", + "repeat hello", + "mimic hello", + }, + Handler: func(ctx *slacker.CommandContext) { + word := ctx.Request().Param("word") + ctx.Response().Reply(word) + }, + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} From 66824cd6e0df39963b53e7f849cb876b2411524b Mon Sep 17 00:00:00 2001 From: Raed Shomali Date: Sat, 29 Jul 2023 23:04:13 -0400 Subject: [PATCH 71/76] chore: Upgrade dependencies version (#142) --- examples/command-aliases/main.go | 6 ++++-- go.mod | 4 ++-- go.sum | 10 +++++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/examples/command-aliases/main.go b/examples/command-aliases/main.go index 872715e..97555e3 100644 --- a/examples/command-aliases/main.go +++ b/examples/command-aliases/main.go @@ -3,7 +3,7 @@ package main import ( "context" "log" - "os" + //"os" "github.com/shomali11/slacker/v2" ) @@ -11,7 +11,9 @@ import ( // Defining a command with aliases func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + //bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) + bot := slacker.NewClient("xoxb-13360094916-2243791173942-I6FwX8kkBNPvANakdfVWm8PF", "xapp-1-A027JMM1RV2-5373599545492-06fa58c972681603e29d44cac9c0f62aa895bc5ccc013256b180dee7961eab23") + bot.AddCommand(&slacker.CommandDefinition{ Command: "echo {word}", Aliases: []string{ diff --git a/go.mod b/go.mod index 002296c..971a930 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,12 @@ go 1.18 require ( github.com/robfig/cron/v3 v3.0.1 - github.com/shomali11/commander v0.0.0-20220716022157-b5248c76541a + github.com/shomali11/commander v0.0.0-20230730023802-0b64f620037d 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 + github.com/stretchr/testify v1.8.4 // indirect ) diff --git a/go.sum b/go.sum index 38e763b..d8c7894 100644 --- a/go.sum +++ b/go.sum @@ -12,16 +12,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/commander v0.0.0-20230730023802-0b64f620037d h1:IImd1gV+EdlKWWi8RoHSaccjLQtSi4tJiFOjq6mM+ZQ= +github.com/shomali11/commander v0.0.0-20230730023802-0b64f620037d/go.mod h1:bYyJw/Aj9fK+qoFmRbPJeWsDgq7WGO8f/Qof95qPug4= github.com/shomali11/proper v0.0.0-20180607004733-233a9a872c30/go.mod h1:O723XwIZBX3FR45rBic/Eyp/DKo/YtchYFURzpUWY2c= 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= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From 32e63477008b5603f9dc7bcce41909bb4a1e1116 Mon Sep 17 00:00:00 2001 From: Raed Shomali Date: Sat, 29 Jul 2023 23:06:08 -0400 Subject: [PATCH 72/76] chore: Fix example --- examples/command-aliases/main.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/command-aliases/main.go b/examples/command-aliases/main.go index 97555e3..872715e 100644 --- a/examples/command-aliases/main.go +++ b/examples/command-aliases/main.go @@ -3,7 +3,7 @@ package main import ( "context" "log" - //"os" + "os" "github.com/shomali11/slacker/v2" ) @@ -11,9 +11,7 @@ import ( // Defining a command with aliases func main() { - //bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - bot := slacker.NewClient("xoxb-13360094916-2243791173942-I6FwX8kkBNPvANakdfVWm8PF", "xapp-1-A027JMM1RV2-5373599545492-06fa58c972681603e29d44cac9c0f62aa895bc5ccc013256b180dee7961eab23") - + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) bot.AddCommand(&slacker.CommandDefinition{ Command: "echo {word}", Aliases: []string{ From 4d2ff795a51f7cfb8aa6c2f88e0f5355638b2946 Mon Sep 17 00:00:00 2001 From: Aaron Russo Date: Mon, 6 Nov 2023 17:58:29 -0800 Subject: [PATCH 73/76] chore: Update staticcheck config to force 1.21 --- .github/workflows/reviewdog.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reviewdog.yaml b/.github/workflows/reviewdog.yaml index e1646fd..d7f515f 100644 --- a/.github/workflows/reviewdog.yaml +++ b/.github/workflows/reviewdog.yaml @@ -6,7 +6,12 @@ jobs: name: runner / staticcheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v4 + with: + go-version: "1.21" + - uses: reviewdog/action-staticcheck@v1 with: github_token: ${{ secrets.github_token }} From 4151d3f4dfc38c49c9934a433ca3ea1f52cc609a Mon Sep 17 00:00:00 2001 From: Nicolas Carlier Date: Tue, 24 Oct 2023 22:49:54 -0400 Subject: [PATCH 74/76] feat: Handle more interaction types Convert BlockID to InteractionID field into the InteractionDefinition struct, and add the necessary dispatch mechanisms to handle the following interactions: - shortcut - message_actions - view_submission - view_closed Add Interaction type to interaction definition --- examples/interaction-middleware/main.go | 7 +- examples/interaction-shortcut/main.go | 54 +++++++++++ examples/interaction-view/main.go | 118 ++++++++++++++++++++++++ examples/interaction/main.go | 5 +- interaction.go | 9 +- slacker.go | 65 +++++++++---- 6 files changed, 234 insertions(+), 24 deletions(-) create mode 100644 examples/interaction-shortcut/main.go create mode 100644 examples/interaction-view/main.go diff --git a/examples/interaction-middleware/main.go b/examples/interaction-middleware/main.go index 1ad3908..ae4cfb9 100644 --- a/examples/interaction-middleware/main.go +++ b/examples/interaction-middleware/main.go @@ -20,8 +20,9 @@ func main() { bot.AddInteractionMiddleware(LoggingInteractionMiddleware()) bot.AddInteraction(&slacker.InteractionDefinition{ - BlockID: "mood", - Handler: slackerInteractive, + InteractionID: "mood", + Handler: slackerInteractive, + Type: slack.InteractionTypeBlockActions, }) ctx, cancel := context.WithCancel(context.Background()) @@ -68,7 +69,7 @@ func LoggingInteractionMiddleware() slacker.InteractionMiddlewareHandler { ctx.Logger().Infof( "%s initiated \"%s\" with action \"%v\" in channel %s\n", ctx.Callback().User.ID, - ctx.Definition().BlockID, + ctx.Definition().InteractionID, ctx.Callback().ActionCallback.BlockActions[0].ActionID, ctx.Callback().Channel.ID, ) diff --git a/examples/interaction-shortcut/main.go b/examples/interaction-shortcut/main.go new file mode 100644 index 0000000..3a799df --- /dev/null +++ b/examples/interaction-shortcut/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/shomali11/slacker/v2" + "github.com/slack-go/slack" +) + +// Implements a basic interactive command with modal view. + +func main() { + bot := slacker.NewClient( + os.Getenv("SLACK_BOT_TOKEN"), + os.Getenv("SLACK_APP_TOKEN"), + slacker.WithDebug(false), + ) + + bot.AddInteraction(&slacker.InteractionDefinition{ + InteractionID: "mood-survey-message-shortcut-callback-id", + Handler: moodShortcutHandler, + Type: slack.InteractionTypeMessageAction, + }) + + bot.AddInteraction(&slacker.InteractionDefinition{ + InteractionID: "mood-survey-global-shortcut-callback-id", + Handler: moodShortcutHandler, + Type: slack.InteractionTypeShortcut, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} + +func moodShortcutHandler(ctx *slacker.InteractionContext) { + switch ctx.Callback().Type { + case slack.InteractionTypeMessageAction: + { + fmt.Print("Message shortcut.\n") + } + case slack.InteractionTypeShortcut: + { + fmt.Print("Global shortcut.\n") + } + } +} diff --git a/examples/interaction-view/main.go b/examples/interaction-view/main.go new file mode 100644 index 0000000..3d628af --- /dev/null +++ b/examples/interaction-view/main.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/shomali11/slacker/v2" + "github.com/slack-go/slack" +) + +var moodSurveyView = slack.ModalViewRequest{ + Type: "modal", + CallbackID: "mood-survey-callback-id", + Title: &slack.TextBlockObject{ + Type: "plain_text", + Text: "Which mood are you in?", + }, + Submit: &slack.TextBlockObject{ + Type: "plain_text", + Text: "Submit", + }, + NotifyOnClose: true, + Blocks: slack.Blocks{ + BlockSet: []slack.Block{ + &slack.InputBlock{ + Type: slack.MBTInput, + BlockID: "mood", + Label: &slack.TextBlockObject{ + Type: "plain_text", + Text: "Mood", + }, + Element: &slack.SelectBlockElement{ + Type: slack.OptTypeStatic, + ActionID: "mood", + Options: []*slack.OptionBlockObject{ + { + Text: &slack.TextBlockObject{ + Type: "plain_text", + Text: "Happy", + }, + Value: "Happy", + }, + { + Text: &slack.TextBlockObject{ + Type: "plain_text", + Text: "Sad", + }, + Value: "Sad", + }, + }, + }, + }, + }, + }, +} + +// Implements a basic interactive command with modal view. +func main() { + bot := slacker.NewClient( + os.Getenv("SLACK_BOT_TOKEN"), + os.Getenv("SLACK_APP_TOKEN"), + slacker.WithDebug(false), + ) + + bot.AddCommand(&slacker.CommandDefinition{ + Command: "mood", + Handler: moodCmdHandler, + }) + + bot.AddInteraction(&slacker.InteractionDefinition{ + InteractionID: "mood-survey-callback-id", + Handler: moodViewHandler, + Type: slack.InteractionTypeViewSubmission, + }) + + bot.AddInteraction(&slacker.InteractionDefinition{ + InteractionID: "mood-survey-callback-id", + Handler: moodViewHandler, + Type: slack.InteractionTypeViewClosed, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} + +func moodCmdHandler(ctx *slacker.CommandContext) { + _, err := ctx.SlackClient().OpenView( + ctx.Event().Data.(*slack.SlashCommand).TriggerID, + moodSurveyView, + ) + if err != nil { + log.Printf("ERROR openEscalationModal: %v", err) + } +} + +func moodViewHandler(ctx *slacker.InteractionContext) { + switch ctx.Callback().Type { + case slack.InteractionTypeViewSubmission: + { + viewState := ctx.Callback().View.State.Values + fmt.Printf( + "Mood view submitted.\nMood: %s\n", + viewState["mood"]["mood"].SelectedOption.Value, + ) + } + case slack.InteractionTypeViewClosed: + { + fmt.Print("Mood view closed.\n") + } + } +} diff --git a/examples/interaction/main.go b/examples/interaction/main.go index 2987f52..61dfd1f 100644 --- a/examples/interaction/main.go +++ b/examples/interaction/main.go @@ -19,8 +19,9 @@ func main() { }) bot.AddInteraction(&slacker.InteractionDefinition{ - BlockID: "mood", - Handler: slackerInteractive, + InteractionID: "mood", + Handler: slackerInteractive, + Type: slack.InteractionTypeBlockActions, }) ctx, cancel := context.WithCancel(context.Background()) diff --git a/interaction.go b/interaction.go index bc96a00..758b430 100644 --- a/interaction.go +++ b/interaction.go @@ -1,10 +1,13 @@ package slacker +import "github.com/slack-go/slack" + // InteractionDefinition structure contains definition of the bot interaction type InteractionDefinition struct { - BlockID string - Middlewares []InteractionMiddlewareHandler - Handler InteractionHandler + InteractionID string + Middlewares []InteractionMiddlewareHandler + Handler InteractionHandler + Type slack.InteractionType } // newInteraction creates a new bot interaction object diff --git a/slacker.go b/slacker.go index 290426c..9f43f63 100644 --- a/slacker.go +++ b/slacker.go @@ -42,6 +42,7 @@ func NewClient(botToken, appToken string, clientOptions ...ClientOption) *Slacke botInteractionMode: options.BotMode, sanitizeEventTextHandler: defaultEventTextSanitizer, logger: options.Logger, + interactions: make(map[slack.InteractionType][]*Interaction), } return slacker } @@ -54,7 +55,7 @@ type Slacker struct { commandMiddlewares []CommandMiddlewareHandler commandGroups []*CommandGroup interactionMiddlewares []InteractionMiddlewareHandler - interactions []*Interaction + interactions map[slack.InteractionType][]*Interaction jobMiddlewares []JobMiddlewareHandler jobs []*Job onHello func(socketmode.Event) @@ -78,7 +79,7 @@ func (s *Slacker) GetCommandGroups() []*CommandGroup { } // GetInteractions returns Groups -func (s *Slacker) GetInteractions() []*Interaction { +func (s *Slacker) GetInteractions() map[slack.InteractionType][]*Interaction { return s.interactions } @@ -174,11 +175,15 @@ func (s *Slacker) AddCommandGroup(prefix string) *CommandGroup { // 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`") + if len(definition.InteractionID) == 0 { + s.logger.Error("missing `ID`") return } - s.interactions = append(s.interactions, newInteraction(definition)) + if len(definition.Type) == 0 { + s.logger.Error("missing `Type`") + return + } + s.interactions[definition.Type] = append(s.interactions[definition.Type], newInteraction(definition)) } // AddInteractionMiddleware appends a new interaction middleware to the list of root level interaction middlewares @@ -437,21 +442,49 @@ func (s *Slacker) handleInteractionEvent(ctx context.Context, callback *slack.In middlewares := make([]InteractionMiddlewareHandler, 0) middlewares = append(middlewares, s.interactionMiddlewares...) - for _, interaction := range s.interactions { - for _, action := range callback.ActionCallback.BlockActions { - definition := interaction.Definition() - if action.BlockID != definition.BlockID { - continue + var interaction *Interaction + var definition *InteractionDefinition + + switch callback.Type { + case slack.InteractionTypeBlockActions: + for _, i := range s.interactions[callback.Type] { + for _, a := range callback.ActionCallback.BlockActions { + definition = i.Definition() + if a.BlockID == definition.InteractionID { + interaction = i + break + } + } + if interaction != nil { + break } - - interactionCtx := newInteractionContext(ctx, s.logger, s.slackClient, callback, definition) - - middlewares = append(middlewares, definition.Middlewares...) - executeInteraction(interactionCtx, definition.Handler, middlewares...) - return } + case slack.InteractionTypeViewClosed, slack.InteractionTypeViewSubmission: + for _, i := range s.interactions[callback.Type] { + definition = i.Definition() + if definition.InteractionID == callback.View.CallbackID { + interaction = i + break + } + } + case slack.InteractionTypeShortcut, slack.InteractionTypeMessageAction: + for _, i := range s.interactions[callback.Type] { + definition = i.Definition() + if definition.InteractionID == callback.CallbackID { + interaction = i + break + } + } + } + + if interaction != nil { + interactionCtx := newInteractionContext(ctx, s.logger, s.slackClient, callback, definition) + middlewares = append(middlewares, definition.Middlewares...) + executeInteraction(interactionCtx, definition.Handler, middlewares...) + return } + s.logger.Debugf("unsupported interaction type received %s\n", callback.Type) if s.unsupportedInteractionHandler != nil { interactionCtx := newInteractionContext(ctx, s.logger, s.slackClient, callback, nil) executeInteraction(interactionCtx, s.unsupportedInteractionHandler, middlewares...) From fb87d90ce5cdf180996c6eab447f78421807c0f7 Mon Sep 17 00:00:00 2001 From: Nicolas Carlier Date: Mon, 30 Oct 2023 23:12:59 -0400 Subject: [PATCH 75/76] feat: Fix comments --- examples/interaction-view/main.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/interaction-view/main.go b/examples/interaction-view/main.go index 3d628af..4d886af 100644 --- a/examples/interaction-view/main.go +++ b/examples/interaction-view/main.go @@ -96,7 +96,7 @@ func moodCmdHandler(ctx *slacker.CommandContext) { moodSurveyView, ) if err != nil { - log.Printf("ERROR openEscalationModal: %v", err) + fmt.Printf("ERROR openEscalationModal: %v\n", err) } } @@ -111,8 +111,6 @@ func moodViewHandler(ctx *slacker.InteractionContext) { ) } case slack.InteractionTypeViewClosed: - { - fmt.Print("Mood view closed.\n") - } + fmt.Print("Mood view closed.\n") } } From c4a40a86760a04ee7c487bbe77eccd76e028e82f Mon Sep 17 00:00:00 2001 From: Raed Shomali <149345478+raed-shomali@users.noreply.github.com> Date: Sun, 19 Nov 2023 23:51:02 -0500 Subject: [PATCH 76/76] docs: Repository moving to Org Update docs to point users to new repository. --- README.md | 91 ++----------------------------------------------------- 1 file changed, 2 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index ceb73e9..f76b93f 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,3 @@ -# slacker [![Slack](https://img.shields.io/badge/slack-%23slacker--framework-orange)](https://gophers.slack.com/archives/C051MGM3GFL) [![Go Report Card](https://goreportcard.com/badge/github.com/shomali11/slacker)](https://goreportcard.com/report/github.com/shomali11/slacker) [![GoDoc](https://godoc.org/github.com/shomali11/slacker?status.svg)](https://godoc.org/github.com/shomali11/slacker) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) +# `shomali11/slacker` has moved to `slack-io/slacker` -Built on top of the Slack API [github.com/slack-go/slack](https://github.com/slack-go/slack), Slacker is a low-friction framework for creating Slack Bots. - -## Features - -- Supports Slack Apps using [Socket Mode](https://api.slack.com/apis/connections/socket) -- Easy definitions of commands and their input -- Built-in `help` command -- 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 -- 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) -- Handlers run concurrently via goroutines -- Full access to the Slack API [github.com/slack-go/slack](https://github.com/slack-go/slack) - -# Install - -``` -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. - -## Manual Steps - -Slacker works by communicating with the Slack [Events API](https://api.slack.com/apis/connections/events-api) using the [Socket Mode](https://api.slack.com/apis/connections/socket) connection protocol. - -To get started, you must have or create a [Slack App](https://api.slack.com/apps?new_app=1) and enable `Socket Mode`, which will generate your app token (`SLACK_APP_TOKEN` in the examples) that will be needed to authenticate. - -Additionally, you need to subscribe to events for your bot to respond to under the `Event Subscriptions` section. Common event subscriptions for bots include `app_mention` or `message.im`. - -After setting up your subscriptions, add scopes necessary to your bot in the `OAuth & Permissions`. The following scopes are recommended for getting started, though you may need to add/remove scopes depending on your bots purpose: - -* `app_mentions:read` -* `channels:history` -* `chat:write` -* `groups:history` -* `im:history` -* `mpim:history` - -Once you've selected your scopes install your app to the workspace and navigate back to the `OAuth & Permissions` section. Here you can retrieve yor bot's OAuth token (`SLACK_BOT_TOKEN` in the examples) from the top of the page. - -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](./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. - -If you wish to only have your bot respond to messages they are directly messaged in, you will need to add the `app_mentions:read` scope, and remove: - -- `im:history` # single-person dm -- `mpim:history` # multi-person dm -- `channels:history` # public channels -- `groups:history` # private channels - -You'll also need to adjust the event subscriptions, adding `app_mention` and removing: - -- `message.channels` -- `message.groups` -- `message.im` -- `message.mpim` - -# Contributing / Submitting an Issue - -Please review our [Contribution Guidelines](CONTRIBUTING.md) if you have found -an issue with Slacker or wish to contribute to the project. - -# Troubleshooting - -## My bot is not responding to events - -There are a few common issues that can cause this: - -* The OAuth (bot) Token may be incorrect. In this case authentication does not fail like it does if the App Token is incorrect, and the bot will simply have no scopes and be unable to respond. -* Required scopes are missing from the OAuth (bot) Token. Similar to the incorrect OAuth Token, without the necessary scopes, the bot cannot respond. -* The bot does not have the correct event subscriptions setup, and is not receiving events to respond to. +Please update your import statements to use the official [`slack-io/slacker`](https://github.com/slack-io/slacker).