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}}" + } +} + + + + 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 diff --git a/.github/workflows/commit-message-check.yaml b/.github/workflows/commit-message-check.yaml new file mode 100644 index 0000000..d3cea38 --- /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)(\[[^]]+\])?:\ |[Mm]erge\ pull\ request\ ).*$' + 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/.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/.github/workflows/reviewdog.yaml b/.github/workflows/reviewdog.yaml new file mode 100644 index 0000000..d7f515f --- /dev/null +++ b/.github/workflows/reviewdog.yaml @@ -0,0 +1,23 @@ +--- +name: reviewdog +on: [pull_request] +jobs: + staticcheck: + name: runner / staticcheck + runs-on: ubuntu-latest + steps: + - 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 }} + # 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 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..2b3d246 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,40 @@ +# 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: + - 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: + 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..71a2bdc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,114 @@ +# 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. + +* 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. + +* 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 new file mode 100644 index 0000000..9696f42 --- /dev/null +++ b/MAINTAINING.md @@ -0,0 +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 + +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 + +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: + +- `Features` groups all commits of type `feat` +- `Bug Fixes` groups all commits of type `fix` +- `Other` groups all other commits + +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. diff --git a/README.md b/README.md index 61137f9..f76b93f 100644 --- a/README.md +++ b/README.md @@ -1,739 +1,3 @@ -# 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) +# `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) with the idea to simplify the Real-Time Messaging feature to easily create Slack Bots, assign commands to them and extract parameters. - -## Features - -- Supports Slack Apps using [Socket Mode](https://api.slack.com/apis/connections/socket) -- Easy definitions of commands and their input -- 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 -- 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) - -## Dependencies - -- `commander` [github.com/shomali11/commander](https://github.com/shomali11/commander) -- `slack` [github.com/slack-go/slack](https://github.com/slack-go/slack) - -# Install - -``` -go get github.com/shomali11/slacker -``` - -# Preparing your Slack App - -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. - -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. - -Once installed, navigate back to the `OAuth & Permissions` section and retrieve yor bot token from the top of the page. - -With both tokens in hand, you can now proceed with the examples below. - -# 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!", - Example: "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 - -```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: "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) - - 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!", - Example: "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 ", 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!")) - }, - } - - 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)) - }, - } - - 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 word!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.Param("word") - 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}}) - 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" - "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(), time.Second) - defer cancel() - - select { - case <-timedContext.Done(): - response.ReportError(errors.New("timed out")) - case <-time.After(time.Minute): - 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 ", 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", "Hi!", false, false)), - ) - - response.Reply(word, slacker.WithBlocks(attachments)) - }, - } - - bot.Command("echo ", 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")) - }, - } - - 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...) - - client := r.botCtx.Client() - 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 = client.PostMessage(event.Channel, opts...) - if err != nil { - fmt.Println("failed to report error: %v", err) - } -} - -// Reply send a attachments to the current channel with a message -func (r *MyCustomResponseWriter) Reply(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") - } - - 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 := client.PostMessage( - event.Channel, - opts..., - ) - return err -} -``` - -## Example 11 - -Showcasing the ability to toggle the slack Debug option via `WithDebug` - -```go -package main - -import ( - "context" - "github.com/shomali11/slacker" - "log" - "os" -) - -func main() { - bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN")) - - 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")) - - authorizedUsers := []string{""} - - authorizedDefinition := &slacker.CommandDefinition{ - Description: "Very secret stuff", - AuthorizationFunc: func(botCtx slacker.BotContext, request slacker.Request) bool { - return contains(authorizedUsers, botCtx.Event().User) - }, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("You are authorized!") - }, - } - - bot.Command("secret", authorizedDefinition) - - 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 - -```go -package main - -import ( - "log" - "os" - - "context" - "fmt" - - "github.com/shomali11/slacker" -) - -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) - }) - - 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 ", &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) - }, - }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := bot.Listen(ctx) - if err != nil { - log.Fatal(err) - } -} -``` +Please update your import statements to use the official [`slack-io/slacker`](https://github.com/slack-io/slacker). 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/app_manifest/manifest.yml b/app_manifest/manifest.yml new file mode 100644 index 0000000..ae12f5d --- /dev/null +++ b/app_manifest/manifest.yml @@ -0,0 +1,33 @@ +_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: + - channels:history + - chat:write + - groups:history + - im:history + - mpim:history + - users:read +settings: + event_subscriptions: + bot_events: + - message.channels + - message.groups + - message.im + - message.mpim + interactivity: + is_enabled: true + org_deploy_enabled: false + socket_mode_enabled: true diff --git a/bots.go b/bots.go new file mode 100644 index 0000000..33feb13 --- /dev/null +++ b/bots.go @@ -0,0 +1,21 @@ +package slacker + +// BotMode instruct the bot on how to handle incoming events that +// originated from a bot. +type BotMode int + +const ( + // BotModeIgnoreAll instructs our bot to ignore any activity coming + // from other bots, including our self. + BotModeIgnoreAll BotMode = iota + + // 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. + BotModeIgnoreApp + + // 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. + BotModeIgnoreNone +) diff --git a/command.go b/command.go index b6d3220..0d5614a 100644 --- a/command.go +++ b/command.go @@ -7,63 +7,72 @@ import ( // CommandDefinition structure contains definition of the bot command type CommandDefinition struct { - Description string - Example string - AuthorizationFunc func(botCtx BotContext, request Request) bool - Handler func(botCtx BotContext, request Request, response ResponseWriter) + Command string + Aliases []string + Description string + Examples []string + Middlewares []CommandMiddlewareHandler + Handler CommandHandler + + // 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{ - usage: usage, +// 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, - command: command, + cmd: commander.NewCommand(definition.Command), + cmdAliases: cmdAliases, } } -// BotCommand interface -type BotCommand interface { - Usage() string +// Command interface +type Command interface { Definition() *CommandDefinition - Match(text string) (*proper.Properties, bool) + Match(string) (*proper.Properties, bool) Tokenize() []*commander.Token - Execute(botCtx BotContext, request Request, response ResponseWriter) } -// botCommand structure contains the bot's command, description and handler -type botCommand struct { - usage string +// command structure contains the bot's command, description and handler +type command struct { definition *CommandDefinition - command *commander.Command + cmd *commander.Command + cmdAliases []*commander.Command } -// Usage returns the command usage -func (c *botCommand) 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) { + properties, isMatch := c.cmd.Match(text) + if isMatch { + return properties, isMatch + } -// Tokenize returns the command format's tokens -func (c *botCommand) Tokenize() []*commander.Token { - return c.command.Tokenize() -} + allCommands := make([]*commander.Command, 0) + allCommands = append(allCommands, c.cmd) + allCommands = append(allCommands, c.cmdAliases...) -// Execute executes the handler logic -func (c *botCommand) Execute(botCtx BotContext, request Request, response ResponseWriter) { - if c.definition == nil || c.definition.Handler == nil { - return + for _, cmd := range allCommands { + properties, isMatch := cmd.Match(text) + if isMatch { + return properties, isMatch + } } - c.definition.Handler(botCtx, request, response) + return nil, false +} + +// Tokenize returns the command format's tokens +func (c *command) Tokenize() []*commander.Token { + return c.cmd.Tokenize() } 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 58ffeb9..1f87c27 100644 --- a/context.go +++ b/context.go @@ -3,94 +3,186 @@ package slacker import ( "context" + "github.com/shomali11/proper" "github.com/slack-go/slack" - "github.com/slack-go/slack/socketmode" ) -// A BotContext interface is used to respond to an event -type BotContext interface { - Context() context.Context - Event() *MessageEvent - SocketMode() *socketmode.Client - Client() *slack.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} +// 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.InThread(), event.TimeStamp, writer) + response := newResponseReplier(writer, replier) + + return &CommandContext{ + ctx: ctx, + event: event, + slackClient: slackClient, + definition: definition, + request: request, + response: response, + logger: logger, + } } -type botContext struct { - ctx context.Context - event *MessageEvent - client *slack.Client - socketmode *socketmode.Client +// 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 + logger Logger } // 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 } -// SocketMode returns the SocketMode client -func (r *botContext) SocketMode() *socketmode.Client { - return r.socketmode +// SlackClient returns the slack API client +func (r *CommandContext) SlackClient() *slack.Client { + return r.slackClient } -// Client returns the slack client -func (r *botContext) Client() *slack.Client { - return r.client +// Request returns the command request +func (r *CommandContext) Request() *Request { + return r.request } -// 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 +// Response returns the response writer +func (r *CommandContext) Response() *ResponseReplier { + return r.response +} - // User ID of the sender - User string +// Logger returns the logger +func (r *CommandContext) Logger() Logger { + return r.logger +} - // Text is the unalterted text of the message, as returned by Slack - Text string +// newInteractionContext creates a new interaction context +func newInteractionContext( + ctx context.Context, + logger Logger, + slackClient *slack.Client, + 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, inThread, callback.MessageTs, writer) + response := newResponseReplier(writer, replier) + return &InteractionContext{ + ctx: ctx, + definition: definition, + callback: callback, + slackClient: slackClient, + response: response, + logger: logger, + } +} - // TimeStamp is the message timestamp - TimeStamp string +// InteractionContext contains information relevant to the executed interaction +type InteractionContext struct { + ctx context.Context + definition *InteractionDefinition + callback *slack.InteractionCallback + slackClient *slack.Client + response *ResponseReplier + logger Logger +} - // ThreadTimeStamp is the message thread timestamp. - ThreadTimeStamp string +// Context returns the context +func (r *InteractionContext) Context() context.Context { + return r.ctx +} - // Data is the raw event data returned from slack. Using Type, you can assert - // this into a slackevents *Event struct. - Data interface{} +// Definition returns the interaction definition +func (r *InteractionContext) Definition() *InteractionDefinition { + return r.definition +} - // Type is the type of the event, as returned by Slack. For instance, - // `app_mention` or `message` - Type string +// Callback returns the interaction callback +func (r *InteractionContext) Callback() *slack.InteractionCallback { + return r.callback +} - // BotID holds the Slack User ID for our bot - BotID string +// Response returns the response writer +func (r *InteractionContext) Response() *ResponseReplier { + return r.response } -// 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 +// SlackClient returns the slack API client +func (r *InteractionContext) SlackClient() *slack.Client { + return r.slackClient } -// IsBot indicates if the message was sent by a bot -func (e *MessageEvent) IsBot() bool { - if e.BotID == "" { - return false +// 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) + response := newWriterResponse(writer) + return &JobContext{ + ctx: ctx, + definition: definition, + slackClient: slackClient, + response: response, + logger: logger, } - return true +} + +// JobContext contains information relevant to the executed job +type JobContext struct { + ctx context.Context + definition *JobDefinition + slackClient *slack.Client + response *ResponseWriter + logger Logger +} + +// Context returns the context +func (r *JobContext) Context() context.Context { + return r.ctx +} + +// 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 +} + +// SlackClient returns the slack API client +func (r *JobContext) SlackClient() *slack.Client { + return r.slackClient +} + +// Logger returns the logger +func (r *JobContext) Logger() Logger { + return r.logger } diff --git a/defaults.go b/defaults.go deleted file mode 100644 index e348880..0000000 --- a/defaults.go +++ /dev/null @@ -1,102 +0,0 @@ -package slacker - -import "github.com/slack-go/slack" - -// ClientOption an option for client values -type ClientOption func(*ClientDefaults) - -// WithDebug sets debug toggle -func WithDebug(debug bool) ClientOption { - return func(defaults *ClientDefaults) { - defaults.Debug = debug - } -} - -// ClientDefaults configuration -type ClientDefaults struct { - Debug bool -} - -func newClientDefaults(options ...ClientOption) *ClientDefaults { - config := &ClientDefaults{ - Debug: false, - } - - 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 -} - -// WithThreadError specifies the reply to be inside a thread of the original message -func WithThreadError(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 b98dc60..0000000 --- a/examples/10/example10.go +++ /dev/null @@ -1,96 +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")) - }, - } - - 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...) - - client := r.botCtx.Client() - 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 = client.PostMessage(event.Channel, opts...) - if err != nil { - fmt.Printf("failed to report error: %v\n", err) - } -} - -// Reply send a attachments to the current channel with a message -func (r *MyCustomResponseWriter) Reply(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") - } - - 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 := client.PostMessage( - event.Channel, - opts..., - ) - return err -} diff --git a/examples/12/example12.go b/examples/12/example12.go deleted file mode 100644 index 71fdb46..0000000 --- a/examples/12/example12.go +++ /dev/null @@ -1,44 +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")) - - authorizedUsers := []string{""} - - authorizedDefinition := &slacker.CommandDefinition{ - Description: "Very secret stuff", - AuthorizationFunc: func(botCtx slacker.BotContext, request slacker.Request) bool { - return contains(authorizedUsers, botCtx.Event().User) - }, - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("You are authorized!") - }, - } - - bot.Command("secret", authorizedDefinition) - - 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 0e89592..0000000 --- a/examples/13/example13.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "log" - "os" - - "context" - "fmt" - - "github.com/shomali11/slacker" -) - -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) - }) - - 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 3d584b2..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 ", &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) - }, - }) - - 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 69a6e48..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!", - Example: "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 ", 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 0e64492..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!")) - }, - } - - 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)) - }, - } - - 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 b28b00d..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 word!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - word := request.Param("word") - 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}}) - 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/7/example7.go b/examples/7/example7.go deleted file mode 100644 index cbd9638..0000000 --- a/examples/7/example7.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "context" - "errors" - "log" - "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(), time.Second) - defer cancel() - - select { - case <-timedContext.Done(): - response.ReportError(errors.New("timed out")) - case <-time.After(time.Minute): - 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) - } -} diff --git a/examples/9/example9.go b/examples/9/example9.go deleted file mode 100644 index cb13aaf..0000000 --- a/examples/9/example9.go +++ /dev/null @@ -1,38 +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", "Hi!", false, false)), - ) - - response.Reply(word, slacker.WithBlocks(attachments)) - }, - } - - bot.Command("echo ", 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/bot-modes/main.go b/examples/bot-modes/main.go new file mode 100644 index 0000000..320aabc --- /dev/null +++ b/examples/bot-modes/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "log" + "os" + + "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.WithBotMode(slacker.BotModeIgnoreApp), + ) + + bot.AddCommand(&slacker.CommandDefinition{ + Command: "hello", + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("hai!") + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := bot.Listen(ctx) + if err != nil { + log.Fatal(err) + } +} 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) + } +} 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/contexts/main.go b/examples/contexts/main.go new file mode 100644 index 0000000..a572d29 --- /dev/null +++ b/examples/contexts/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "errors" + "log" + "math/rand" + "os" + "time" + + "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(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(): + ctx.Response().ReplyError(errors.New("timed out")) + case <-time.After(duration): + ctx.Response().Reply("Processing done!") + } + }, + } + + 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/debug/main.go similarity index 54% rename from examples/2/example2.go rename to examples/debug/main.go index d4b399e..ecadc1f 100644 --- a/examples/2/example2.go +++ b/examples/debug/main.go @@ -5,21 +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")) + bot := slacker.NewClient(os.Getenv("SLACK_BOT_TOKEN"), os.Getenv("SLACK_APP_TOKEN"), slacker.WithDebug(true)) definition := &slacker.CommandDefinition{ + Command: "ping", Description: "Ping!", - Example: "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") }, } - 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..ae4cfb9 --- /dev/null +++ b/examples/interaction-middleware/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "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{ + InteractionID: "mood", + Handler: slackerInteractive, + Type: slack.InteractionTypeBlockActions, + }) + + 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) { + ctx.Logger().Infof( + "%s initiated \"%s\" with action \"%v\" in channel %s\n", + ctx.Callback().User.ID, + ctx.Definition().InteractionID, + ctx.Callback().ActionCallback.BlockActions[0].ActionID, + ctx.Callback().Channel.ID, + ) + next(ctx) + } + } +} 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-sink/main.go b/examples/interaction-sink/main.go new file mode 100644 index 0000000..bf46a5f --- /dev/null +++ b/examples/interaction-sink/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "log" + "os" + + "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.UnsupportedInteractionHandler(func(ctx *slacker.InteractionContext) { + callback := ctx.Callback() + 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..." + } + + ctx.Response().Reply(text, slacker.WithReplace(callback.Message.Timestamp)) + }) + + definition := &slacker.CommandDefinition{ + Command: "mood", + Handler: 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("mood-block", happyBtn, sadBtn), + }) + }, + } + + 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/interaction-view/main.go b/examples/interaction-view/main.go new file mode 100644 index 0000000..4d886af --- /dev/null +++ b/examples/interaction-view/main.go @@ -0,0 +1,116 @@ +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 { + fmt.Printf("ERROR openEscalationModal: %v\n", 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 new file mode 100644 index 0000000..61dfd1f --- /dev/null +++ b/examples/interaction/main.go @@ -0,0 +1,63 @@ +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{ + InteractionID: "mood", + Handler: slackerInteractive, + Type: slack.InteractionTypeBlockActions, + }) + + 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/job-middleware/main.go b/examples/job-middleware/main.go new file mode 100644 index 0000000..8a585be --- /dev/null +++ b/examples/job-middleware/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "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) { + ctx.Logger().Infof( + "%s started\n", + ctx.Definition().Name, + ) + next(ctx) + ctx.Logger().Infof( + "%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 bccfcd8..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 ", definition) + bot.AddCommand(definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/3/example3.go b/examples/message-delete/main.go similarity index 52% rename from examples/3/example3.go rename to examples/message-delete/main.go index bc7ab56..c85eb58 100644 --- a/examples/3/example3.go +++ b/examples/message-delete/main.go @@ -4,23 +4,28 @@ import ( "context" "log" "os" + "time" - "github.com/shomali11/slacker" + "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{ - 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) + 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.Command("echo ", definition) + bot.AddCommand(definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() 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..53c53a0 --- /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(true)) + }, + } + + 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/11/example11.go b/examples/message-thread/main.go similarity index 52% rename from examples/11/example11.go rename to examples/message-thread/main.go index 83ef529..8404442 100644 --- a/examples/11/example11.go +++ b/examples/message-thread/main.go @@ -2,22 +2,27 @@ package main import ( "context" - "github.com/shomali11/slacker" "log" "os" + + "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!", - Handler: func(botCtx slacker.BotContext, request slacker.Request, response slacker.ResponseWriter) { - response.Reply("pong") + Examples: []string{"ping"}, + Handler: func(ctx *slacker.CommandContext) { + ctx.Response().Reply("pong", slacker.WithInThread(true)) }, } - bot.Command("ping", definition) + bot.AddCommand(definition) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/examples/sanitization/main.go b/examples/sanitization/main.go new file mode 100644 index 0000000..21865f9 --- /dev/null +++ b/examples/sanitization/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "strings" + + "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.SanitizeEventTextHandler(func(text string) string { + fmt.Println("My slack bot does not like backticks!") + return strings.ReplaceAll(text, "`", "") + }) + + bot.AddCommand(&slacker.CommandDefinition{ + Command: "my-command", + Handler: func(ctx *slacker.CommandContext) { + ctx.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/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/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 5eb5f39..971a930 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/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/stretchr/testify v1.3.0 // indirect + github.com/robfig/cron/v3 v3.0.1 + 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.8.4 // indirect ) diff --git a/go.sum b/go.sum index 8c3ffb2..d8c7894 100644 --- a/go.sum +++ b/go.sum @@ -3,21 +3,25 @@ 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/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +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/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/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/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/proper v0.0.0-20180607004733-233a9a872c30 h1:56awf1OXG6Jc2Pk1saojpCzpzkoBvlqecCyNLY+wwkc= +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-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/slack-go/slack v0.9.1 h1:pekQBs0RmrdAgoqzcMCzUCWSyIkhzUU3F83ExAdZrKo= -github.com/slack-go/slack v0.9.1/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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/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= 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..758b430 --- /dev/null +++ b/interaction.go @@ -0,0 +1,28 @@ +package slacker + +import "github.com/slack-go/slack" + +// InteractionDefinition structure contains definition of the bot interaction +type InteractionDefinition struct { + InteractionID string + Middlewares []InteractionMiddlewareHandler + Handler InteractionHandler + Type slack.InteractionType +} + +// 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 new file mode 100644 index 0000000..8dbc66f --- /dev/null +++ b/job.go @@ -0,0 +1,30 @@ +package slacker + +// JobDefinition structure contains definition of the job +type JobDefinition struct { + 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(definition *JobDefinition) *Job { + return &Job{ + definition: definition, + } +} + +// Job structure contains the job's spec and handler +type Job struct { + definition *JobDefinition +} + +// Definition returns the job's definition +func (c *Job) Definition() *JobDefinition { + return c.definition +} 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 new file mode 100644 index 0000000..fe95054 --- /dev/null +++ b/message_event.go @@ -0,0 +1,138 @@ +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 +// used to prevent frequent type assertions when evaluating the event. +type MessageEvent struct { + // Channel ID where the message was sent + ChannelID string + + // Channel contains information about the channel + Channel *slack.Channel + + // User ID of the sender + UserID 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 + + // 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 any + + // 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 +} + +// 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 +func (e *MessageEvent) IsBot() bool { + return e.BotID != "" +} + +// 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(logger, slackClient, ev.Channel), + UserID: ev.User, + UserProfile: getUserProfile(logger, slackClient, ev.User), + Text: ev.Text, + Data: event, + Type: ev.Type, + TimeStamp: ev.TimeStamp, + ThreadTimeStamp: ev.ThreadTimeStamp, + BotID: ev.BotID, + } + case *slackevents.AppMentionEvent: + messageEvent = &MessageEvent{ + ChannelID: ev.Channel, + Channel: getChannel(logger, slackClient, ev.Channel), + UserID: ev.User, + UserProfile: getUserProfile(logger, slackClient, ev.User), + Text: ev.Text, + Data: event, + Type: ev.Type, + TimeStamp: ev.TimeStamp, + ThreadTimeStamp: ev.ThreadTimeStamp, + BotID: ev.BotID, + } + case *slack.SlashCommand: + messageEvent = &MessageEvent{ + ChannelID: ev.ChannelID, + Channel: getChannel(logger, slackClient, ev.ChannelID), + UserID: ev.UserID, + UserProfile: getUserProfile(logger, slackClient, ev.UserID), + Text: fmt.Sprintf("%s %s", ev.Command[1:], ev.Text), + Data: event, + Type: socketmode.RequestTypeSlashCommands, + } + default: + return nil + } + + return messageEvent +} + +func getChannel(logger Logger, slackClient *slack.Client, channelID string) *slack.Channel { + if len(channelID) == 0 { + return nil + } + + channel, err := slackClient.GetConversationInfo(&slack.GetConversationInfoInput{ + ChannelID: channelID, + IncludeLocale: false, + IncludeNumMembers: false}) + if err != nil { + logger.Errorf("unable to get channel info for %s: %v\n", channelID, err) + return nil + } + return channel +} + +func getUserProfile(logger Logger, slackClient *slack.Client, userID string) *slack.UserProfile { + if len(userID) == 0 { + return nil + } + + user, err := slackClient.GetUserInfo(userID) + if err != nil { + 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..5ab0b85 --- /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(inThread bool) ReplyOption { + return func(defaults *replyOptions) { + defaults.InThread = &inThread + } +} + +// 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: nil, + } + + 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 913cc94..548a592 100644 --- a/response.go +++ b/response.go @@ -1,71 +1,81 @@ package slacker import ( - "fmt" - "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} +} + +// ResponseReplier sends messages to Slack +type ResponseReplier struct { + writer *Writer + replier *Replier +} + +// Reply send a message to the current channel +func (r *ResponseReplier) Reply(message string, options ...ReplyOption) (string, error) { + return r.replier.Reply(message, options...) +} + +// ReplyError send an error to the current channel +func (r *ResponseReplier) ReplyError(err error, options ...ReplyOption) (string, error) { + return r.replier.ReplyError(err, 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...) +} + +// 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...) +} + +// 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...) +} + +// Delete deletes a message in a channel +func (r *ResponseReplier) Delete(channel string, messageTimestamp string) (string, error) { + return r.writer.Delete(channel, messageTimestamp) +} + +// newWriterResponse creates a new response structure +func newWriterResponse(writer *Writer) *ResponseWriter { + return &ResponseWriter{writer: writer} +} -// A ResponseWriter interface is used to respond to an event -type ResponseWriter interface { - Reply(text string, options ...ReplyOption) error - ReportError(err error, options ...ReportErrorOption) +// ResponseWriter sends messages to slack +type ResponseWriter struct { + writer *Writer } -// NewResponse creates a new response structure -func NewResponse(botCtx BotContext) ResponseWriter { - return &response{botCtx: botCtx} +// Post send a message to a channel +func (r *ResponseWriter) Post(channel string, message string, options ...PostOption) (string, error) { + return r.writer.Post(channel, message, options...) } -type response struct { - botCtx BotContext +// 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...) } -// 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...) - - client := r.botCtx.Client() - ev := r.botCtx.Event() - - 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) - } +// 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...) } -// Reply send a attachments to the current channel with a message -func (r *response) Reply(message string, options ...ReplyOption) error { - defaults := NewReplyDefaults(options...) - - client := r.botCtx.Client() - 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 := client.PostMessage( - ev.Channel, - opts..., - ) - return err +// 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..9184454 --- /dev/null +++ b/response_replier.go @@ -0,0 +1,62 @@ +package slacker + +import ( + "github.com/slack-go/slack" +) + +// newReplier creates a new replier structure +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 +} + +// 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 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)) + } + + 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 9a4803c..9f43f63 100644 --- a/slacker.go +++ b/slacker.go @@ -2,140 +2,207 @@ package slacker import ( "context" - "errors" "fmt" + "strings" - "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 = "-" - star = "*" - newLine = "\n" - invalidToken = "invalid token" - helpCommand = "help" - directChannelMarker = "D" - userMentionFormat = "<@%s>" - codeMessageFormat = "`%s`" - boldMessageFormat = "*%s*" - italicMessageFormat = "_%s_" - quoteMessageFormat = ">_*Example:* %s_" - authorizedUsersOnly = "Authorized users only" - slackBotUser = "USLACKBOT" -) - -var ( - unAuthorizedError = errors.New("You are not authorized to execute this command") + space = " " + dash = "-" + newLine = "\n" + invalidToken = "invalid token" + helpCommand = "help" + codeMessageFormat = "`%s`" + boldMessageFormat = "*%s*" + italicMessageFormat = "_%s_" + exampleMessageFormat = "_*Example:*_ %s" ) // NewClient creates a new client using the Slack API -func NewClient(botToken, appToken string, options ...ClientOption) *Slacker { - defaults := newClientDefaults(options...) - - api := slack.New( - botToken, - slack.OptionDebug(defaults.Debug), - slack.OptionAppLevelToken(appToken), +func NewClient(botToken, appToken string, clientOptions ...ClientOption) *Slacker { + options := newClientOptions(clientOptions...) + slackOpts := newSlackOptions(appToken, options) + + slackAPI := slack.New(botToken, slackOpts...) + socketModeClient := socketmode.New( + slackAPI, + socketmode.OptionDebug(options.Debug), ) - smc := socketmode.New( - api, - socketmode.OptionDebug(defaults.Debug), - ) slacker := &Slacker{ - client: api, - socketModeClient: smc, - commandChannel: make(chan *CommandEvent, 100), - unAuthorizedError: unAuthorizedError, + slackClient: slackAPI, + socketModeClient: socketModeClient, + cronClient: cron.New(cron.WithLocation(options.CronLocation)), + commandGroups: []*CommandGroup{newGroup("")}, + botInteractionMode: options.BotMode, + sanitizeEventTextHandler: defaultEventTextSanitizer, + logger: options.Logger, + interactions: make(map[slack.InteractionType][]*Interaction), } 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 - 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 -} - -// BotCommands returns Bot Commands -func (s *Slacker) BotCommands() []BotCommand { - return s.botCommands -} - -// Client returns the internal slack.Client of Slacker struct -func (s *Slacker) Client() *slack.Client { - return s.client -} - -// SocketMode returns the internal socketmode.Client of Slacker struct -func (s *Slacker) SocketMode() *socketmode.Client { + slackClient *slack.Client + socketModeClient *socketmode.Client + cronClient *cron.Cron + commandMiddlewares []CommandMiddlewareHandler + commandGroups []*CommandGroup + interactionMiddlewares []InteractionMiddlewareHandler + interactions map[slack.InteractionType][]*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() map[slack.InteractionType][]*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 +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 +} + +// OnConnected handle the event when the bot is connected +func (s *Slacker) OnConnected(onConnected func(socketmode.Event)) { + s.onConnected = onConnected +} + +// OnConnecting handle the event when the bot is connecting +func (s *Slacker) OnConnecting(onConnecting func(socketmode.Event)) { + s.onConnecting = onConnecting } -// Err handle when errors are encountered -func (s *Slacker) Err(errorHandler func(err string)) { - s.errorHandler = errorHandler +// OnConnectionError handle the event when the bot fails to connect +func (s *Slacker) OnConnectionError(onConnectionError func(socketmode.Event)) { + s.onConnectionError = onConnectionError } -// CustomRequest creates a new request -func (s *Slacker) CustomRequest(requestConstructor func(botCtx BotContext, properties *proper.Properties) Request) { - s.requestConstructor = requestConstructor +// OnDisconnected handle the event when the bot is disconnected +func (s *Slacker) OnDisconnected(onDisconnected func(socketmode.Event)) { + s.onDisconnected = onDisconnected } -// CustomResponse creates a new response writer -func (s *Slacker) CustomResponse(responseConstructor func(botCtx BotContext) ResponseWriter) { - s.responseConstructor = responseConstructor +// UnsupportedInteractionHandler handles interactions when none of the callbacks are matched +func (s *Slacker) UnsupportedInteractionHandler(unsupportedInteractionHandler InteractionHandler) { + s.unsupportedInteractionHandler = unsupportedInteractionHandler } -// 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 +// UnsupportedCommandHandler handles messages when none of the commands are matched +func (s *Slacker) UnsupportedCommandHandler(unsupportedCommandHandler CommandHandler) { + s.unsupportedCommandHandler = unsupportedCommandHandler } -// DefaultEvent handle events when an unknown event is seen -func (s *Slacker) DefaultEvent(defaultEventHandler func(interface{})) { - s.defaultEventHandler = defaultEventHandler +// UnsupportedEventHandler handles events when an unknown event is seen +func (s *Slacker) UnsupportedEventHandler(unsupportedEventHandler func(socketmode.Event)) { + s.unsupportedEventHandler = unsupportedEventHandler } -// UnAuthorizedError error message -func (s *Slacker) UnAuthorizedError(unAuthorizedError error) { - s.unAuthorizedError = unAuthorizedError +// SanitizeEventTextHandler overrides the default event text sanitization +func (s *Slacker) SanitizeEventTextHandler(sanitizeEventTextHandler func(in string) string) { + s.sanitizeEventTextHandler = sanitizeEventTextHandler } // 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 } -// 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)) +// 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) +} + +// 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) +} + +// 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 +} + +// AddInteraction define a new interaction and append it to the list of interactions +func (s *Slacker) AddInteraction(definition *InteractionDefinition) { + if len(definition.InteractionID) == 0 { + s.logger.Error("missing `ID`") + return + } + 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 +func (s *Slacker) AddInteractionMiddleware(middleware InteractionMiddlewareHandler) { + s.interactionMiddlewares = append(s.interactionMiddlewares, middleware) +} + +// 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, 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 @@ -147,190 +214,360 @@ 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 { + 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: - fmt.Println("Connection failed. Retrying later...") + s.logger.Infof("connection failed. Retrying later...\n") + + if s.onConnectionError == nil { + continue + } + go s.onConnectionError(socketEvent) + case socketmode.EventTypeConnected: - fmt.Println("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.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: - ev, ok := evt.Data.(slackevents.EventsAPIEvent) + event, ok := socketEvent.Data.(slackevents.EventsAPIEvent) if !ok { - fmt.Printf("Ignored %+v\n", evt) + s.logger.Debugf("ignored %+v\n", socketEvent) continue } - switch ev.InnerEvent.Type { + // 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, ev.InnerEvent.Data) + go s.handleMessageEvent(ctx, event.InnerEvent.Data) default: - fmt.Printf("unsupported inner event: %+v\n", ev.InnerEvent.Type) + if s.unsupportedEventHandler != nil { + s.unsupportedEventHandler(socketEvent) + } else { + s.logger.Debugf("unsupported event received %+v\n", socketEvent) + } + } + + case socketmode.EventTypeSlashCommand: + event, ok := socketEvent.Data.(slack.SlashCommand) + if !ok { + s.logger.Debugf("ignored %+v\n", socketEvent) + continue } - s.socketModeClient.Ack(*evt.Request) + // Acknowledge receiving the request + s.socketModeClient.Ack(*socketEvent.Request) + + go s.handleMessageEvent(ctx, &event) + + case socketmode.EventTypeInteractive: + callback, ok := socketEvent.Data.(slack.InteractionCallback) + if !ok { + s.logger.Debugf("ignored %+v\n", socketEvent) + continue + } + + // Acknowledge receiving the request + s.socketModeClient.Ack(*socketEvent.Request) + + go s.handleInteractionEvent(ctx, &callback) default: - s.socketModeClient.Debugf("unsupported Events API event received") + if s.unsupportedEventHandler != nil { + s.unsupportedEventHandler(socketEvent) + } else { + s.logger.Debugf("unsupported event received %+v\n", socketEvent) + } } } } }() + 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.Run() + return s.socketModeClient.RunContext(ctx) } -// GetUserInfo retrieve complete user information -func (s *Slacker) GetUserInfo(user string) (*slack.User, error) { - return s.client.GetUserInfo(user) -} +func (s *Slacker) defaultHelp(ctx *CommandContext) { + blocks := []slack.Block{} -func (s *Slacker) defaultHelp(botCtx BotContext, request Request, response ResponseWriter) { - authorizedCommandAvailable := false - helpMessage := empty - for _, command := range s.botCommands { - 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 len(command.Definition().Description) > 0 { + helpMessage += dash + space + fmt.Sprintf(italicMessageFormat, command.Definition().Description) + } + + blocks = append(blocks, + slack.NewSectionBlock( + slack.NewTextBlockObject(slack.MarkdownType, helpMessage, false, false), + nil, nil, + )) + + 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), + )) + } } + } + + if len(s.GetJobs()) == 0 { + ctx.Response().ReplyBlocks(blocks) + return + } - if command.Definition().AuthorizationFunc != nil { - authorizedCommandAvailable = true - helpMessage += space + fmt.Sprintf(codeMessageFormat, star) + blocks = append(blocks, slack.NewDividerBlock()) + for _, job := range s.GetJobs() { + if job.Definition().HideHelp { + continue } - helpMessage += newLine + helpMessage := fmt.Sprintf(codeMessageFormat, job.Definition().CronExpression) - if len(command.Definition().Example) > 0 { - helpMessage += fmt.Sprintf(quoteMessageFormat, command.Definition().Example) + newLine + if len(job.Definition().Name) > 0 { + helpMessage += space + dash + space + fmt.Sprintf(codeMessageFormat, job.Definition().Name) + } + + if len(job.Definition().Description) > 0 { + helpMessage += space + dash + space + fmt.Sprintf(italicMessageFormat, job.Definition().Description) } - } - if authorizedCommandAvailable { - helpMessage += fmt.Sprintf(codeMessageFormat, star+space+authorizedUsersOnly) + 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{} + s.helpDefinition = &CommandDefinition{ + Command: helpCommand, + Description: helpCommand, + Handler: s.defaultHelp, + } } - if s.helpDefinition.Handler == nil { - s.helpDefinition.Handler = s.defaultHelp - } + s.commandGroups[0].PrependCommand(s.helpDefinition) +} + +func (s *Slacker) startCronJobs(ctx context.Context) { + 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()) + } - if len(s.helpDefinition.Description) == 0 { - s.helpDefinition.Description = helpCommand } - s.botCommands = append([]BotCommand{NewBotCommand(helpCommand, s.helpDefinition)}, s.botCommands...) + s.cronClient.Start() } -func (s *Slacker) handleMessageEvent(ctx context.Context, evt interface{}) { - if s.botContextConstructor == nil { - s.botContextConstructor = NewBotContext +func (s *Slacker) handleInteractionEvent(ctx context.Context, callback *slack.InteractionCallback) { + middlewares := make([]InteractionMiddlewareHandler, 0) + middlewares = append(middlewares, s.interactionMiddlewares...) + + 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 + } + } + 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 s.requestConstructor == nil { - s.requestConstructor = NewRequest + if interaction != nil { + interactionCtx := newInteractionContext(ctx, s.logger, s.slackClient, callback, definition) + middlewares = append(middlewares, definition.Middlewares...) + executeInteraction(interactionCtx, definition.Handler, middlewares...) + return } - if s.responseConstructor == nil { - s.responseConstructor = NewResponse + 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...) } +} - ev := newMessageEvent(evt) - if ev == nil { +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 } - botCtx := s.botContextConstructor(ctx, s.client, s.socketModeClient, ev) - response := s.responseConstructor(botCtx) - - for _, cmd := range s.botCommands { - parameters, isMatch := cmd.Match(ev.Text) - if !isMatch { - continue - } - - request := s.requestConstructor(botCtx, parameters) - if cmd.Definition().AuthorizationFunc != nil && !cmd.Definition().AuthorizationFunc(botCtx, request) { - response.ReportError(s.unAuthorizedError) + if messageEvent.IsBot() { + if s.ignoreBotMessage(messageEvent) { return } + } + + middlewares := make([]CommandMiddlewareHandler, 0) + middlewares = append(middlewares, s.commandMiddlewares...) + + eventText := s.sanitizeEventTextHandler(messageEvent.Text) + for _, group := range s.commandGroups { + for _, cmd := range group.GetCommands() { + parameters, isMatch := cmd.Match(eventText) + if !isMatch { + continue + } + + definition := cmd.Definition() + ctx := newCommandContext(ctx, s.logger, s.slackClient, messageEvent, definition, parameters) - select { - case s.commandChannel <- NewCommandEvent(cmd.Usage(), parameters, ev): - 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.unsupportedCommandHandler != nil { + ctx := newCommandContext(ctx, s.logger, s.slackClient, messageEvent, nil, nil) + executeCommand(ctx, s.unsupportedCommandHandler, middlewares...) } } -func newMessageEvent(evt interface{}) *MessageEvent { - var me *MessageEvent - - switch evt.(type) { - case *slackevents.MessageEvent: - ev := evt.(*slackevents.MessageEvent) - me = &MessageEvent{ - Channel: ev.Channel, - User: ev.User, - Text: ev.Text, - Data: evt, - Type: ev.Type, - TimeStamp: ev.TimeStamp, - ThreadTimeStamp: ev.ThreadTimeStamp, - BotID: ev.BotID, +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 } - case *slackevents.AppMentionEvent: - ev := evt.(*slackevents.AppMentionEvent) - me = &MessageEvent{ - Channel: ev.Channel, - User: ev.User, - Text: ev.Text, - Data: evt, - Type: ev.Type, - TimeStamp: ev.TimeStamp, - ThreadTimeStamp: ev.ThreadTimeStamp, - BotID: ev.BotID, + 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 newSlackOptions(appToken string, options *clientOptions) []slack.Option { + slackOptions := []slack.Option{ + slack.OptionDebug(options.Debug), + slack.OptionAppLevelToken(appToken), } - // 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 + if len(options.APIURL) > 0 { + slackOptions = append(slackOptions, slack.OptionAPIURL(options.APIURL)) } + return slackOptions +} - return me +func defaultEventTextSanitizer(msg string) string { + return strings.ReplaceAll(msg, "\u00a0", " ") } 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"] 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 +}