grape-slack-bot.rb

Gem Version Test Status codecov

Extensible Slack bot implementation gem for ruby-grape

Sponsored by Kisko Labs.

Install

Using Bundler:

bundle add grape-slack-bot

Using RubyGems:

gem install grape-slack-bot

Gemfile

gem 'grape-slack-bot'

Gem modules and classes

SlackBot is the main module that contains all the classes and modules.

Concepts

Slash command

Slash command is a command that is triggered by user in Slack chat using / prefix.

Characteristics:

  • Can have multiple URL endpoints (later called url_token, e.g. /api/slack/commands/game)
  • Starts with / and is followed by command name (e.g. /game, called token)
  • Can have multiple argument commands (e.g. /game start, called token)
  • Can have multiple arguments (e.g. /game start password=P@5sW0Rd, called args)
  • Can send message to chat
  • Can open interactive component with callback identifier
  • Can trigger event in background

References:

Interactive component

Interactive component is a component that is requested to be opened by bot app for the user in Slack application.

Characteristics:

  • Can be associated with slash command
  • Can be associated with event

References:

Event

Event is a notification that is sent to bot app when something happens in Slack.

References:

View

View is a class that has logic for rendering internals of message or modal or any other user interface component.

Characteristics:

  • Can be associated with slash command, interactive component or event for using ready-made methods like open_modal, update_modal or publish_view

References:

Block

Block is an object that is used to render user interface elements in Slack.

References:

Callback

Callback is a class for managing interactive component state and handling interactive component actions.

Example uses Rails.cache for storing interactive component state, use CallbackStorage for building custom storage class as a base.

References:

Arguments

Class for handling slash command and interactive element values as queries.

Gem implementation uses Rack::Utils for parsing and building query strings.

References:

Pager

Own implementation of pagination that is relying on Arguments and ActiveRecord.

References:

Specification

  • [x] Create any amount of endpoints that will handle Slack calls
  • [x] Create multiple instances of bots and configure them separately or use the same configuration for all bots
  • [x] Define and reuse slash command handlers for Slack slash commands
  • [x] Define interactive component handlers for Slack interactive components
  • [x] Define and reuse views for slash commands, interactive components and events
  • [x] Define event handlers for Slack events
  • [x] Define menu options handlers for Slack menu options
  • [x] Store interactive component state in cache for usage in other handlers
  • [x] Access current user session and user from any handler
  • [x] Extend API endpoint with custom hooks and helpers within grape specification
  • [x] Supports Slack signature verification
  • [ ] Supports Slack socket mode (?)
  • [ ] Supports Slack token rotation

Usage with grape

Create app/api/slack_bot_api.rb, it will contain bot configuration and endpoints setup:

SlackBot::DevConsole.logger = Rails.logger
SlackBot::DevConsole.enabled = Rails.env.development?
SlackBot::Config.configure do
  callback_storage Rails.cache
  callback_user_finder ->(id) { User.active.find_by(id: id) }

  # TODO: Register event handlers
  event :app_home_opened, MySlackBot::AppHomeOpenedEvent
  interaction MySlackBot::AppHomeInteraction

  # TODO: Register slash command handlers
  slash_command_endpoint :game, MySlackBot::Game::MenuCommand do
    command :start, MySlackBot::Game::StartCommand
  end
end

class SlackBotApi < Grape::API
  include SlackBot::GrapeExtension

  helpers do
    def config
      SlackBot::Config.current_instance
    end

    def resolve_user_session(team_id, user_id)
      uid = OmniAuth::Strategies::SlackOpenid.generate_uid(team_id, user_id)
      UserSession.find_by(uid: uid, provider: UserSession.slack_openid_provider)
    end

    def current_user_session
      # NOTE: fetch_team_id and fetch_user_id are provided by SlackBot::Grape::ApiExtension
      @current_user_session ||=
        resolve_user_session(fetch_team_id, fetch_user_id)
    end

    def current_user_ip
      request.env["action_dispatch.remote_ip"].to_s
    end

    def current_user
      @current_user ||= current_user_session&.user
    end
  end
end

In routes file config/routes.rb mount the API:

mount SlackBotApi => "/api/slack"

Slack bot manifest

You can use this manifest as a template for your Slack app configuration:

display_information:
  name: Example
  description: Example bot
  background_color: "#000000"
features:
  bot_user:
    display_name: Example
    always_online: true
  slash_commands:
    - command: /game
      url: https://example.com/api/slack/commands/game
      description: The game
      should_escape: false
oauth_config:
  redirect_urls:
    - https://example.com/user/auth/slack_openid/callback
  scopes:
    bot:
      - incoming-webhook
      - app_mentions:read
      - chat:write
      - users:read
      - users:read.email
      - im:read
      - im:write
      - im:history
      - channels:read
      - groups:read
      - mpim:read
      - reactions:read
      - commands
settings:
  event_subscriptions:
    request_url: https://example.com/api/slack/events
    bot_events:
      - app_home_opened
      - app_mention
      - im_history_changed
      - member_joined_channel
      - member_left_channel
      - message.im
      - profile_opened
      - reaction_added
      - reaction_removed
  interactivity:
    is_enabled: true
    request_url: https://example.com/api/slack/interactions
    message_menu_options_url: https://example.com/api/slack/menu_options
  org_deploy_enabled: false
  socket_mode_enabled: false
  token_rotation_enabled: false

Command example

module MySlackBot::Game
  class MenuCommand < SlackBot::Command
    interaction MySlackBot::Game::MenuInteraction
    view MySlackBot::Game::MenuView
    def call
      open_modal :index_modal
    end
  end
  class StartCommand < SlackBot::Command
    interaction MySlackBot::Game::StartInteraction
    view MySlackBot::Game::StartView
    def call
      open_modal :index_modal
    end
  end
end

Interaction example

module MySlackBot::Game
  class StartInteraction < SlackBot::Interaction
    view MySlackBot::Game::StartView
    def call
      return if interaction_type != "block_actions"

      update_callback_args do |action|
        action_id = action["action_id"]
        action_type = action["type"]
        case action_type
        when "static_select"
          if action_id == "games_users_list_select_user"
            callback.args[:user_id] = action["selected_option"]["value"]
          end
        else
          callback.args.raw_args = action["value"]
        end
      end

      update_modal :index_modal
    end
  end
end

App home interaction example:

module MySlackBot
  class AppHomeInteraction < SlackBot::Event
    view MySlackBot::AppHomeView

    def call
      action_id = payload.dig("actions", 0, "action_id")
      case action_id
      when "add_game"
        add_game
      end
    end

    private

    def add_game
      open_modal :add_game_modal
    end
  end
end

View example

Modal view example:

module MySlackBot::Game
  class MenuView < SlackBot::View
    def index_modal
      blocks = []

      blocks << {
        type: "section",
        block_id: "section_help_list",
        text: {
          type: "mrkdwn",
          text: "#{command} start - Start the game"
        }
      }

      cursor = Game.active
      pager = paginate(cursor)

      blocks << {
        type: "section",
        block_id: "section_games_list",
        text: {
          type: "mrkdwn",
          text: "*Games*"
        }
      }

      if pager.cursor.present?
        pager.cursor.find_each do |game|
          blocks << {
            type: "section",
            block_id: "section_game_#{game.id}",
            text: {
              type: "mrkdwn",
              text: "#{game.name}"
            },
            accessory: {
              type: "button",
              action_id: "games_users_list_join_game",
              text: {
                type: "plain_text",
                text: "Join"
              },
              value: args.merge(game_id: game.id).to_s
            }
          }
        end
      else
        blocks << {
          type: "section",
          block_id: "section_games_list_empty",
          text: {
            type: "mrkdwn",
            text: "No active games"
          }
        }
      end

      if pager.pages_count > 1
        pager_elements = []
        if pager.page > 1
          pager_elements << {
            type: "button",
            action_id: "games_list_previous_page",
            text: {
              type: "plain_text",
              text: ":arrow_left: Previous page"
            },
            value: args.merge(page: pager.page - 1).to_s
          }
        end
        if pager.page < pager.pages_count
          pager_elements << {
            type: "button",
            action_id: "games_list_next_page",
            text: {
              type: "plain_text",
              text: "Next page :arrow_right:"
            },
            value: args.merge(page: pager.page + 1).to_s
          }
        end
        if pager_elements.present?
          blocks << {
            type: "actions",
            elements: pager_elements
          }
        end
      end

      {
        title: {
          type: "plain_text",
          text: "Example help"
        },
        blocks: blocks
      }
    end
  end
end

App home view example:

module MySlackBot
  class AppHomeView < SlackBot::View
    def index_view
      blocks = []
      if current_user.present?
        blocks += {
          type: "section",
          text: {
            type: "mrkdwn",
            text:
              "*Hello, #{current_user.name}!*"
          }
        }
      else
        blocks << {
          type: "section",
          text: {
            type: "mrkdwn",
            text:
              "*Please login at https://example.com using Slack*"
          }
        }
      end
      blocks << {
        type: "context",
        elements: [
          {
            type: "mrkdwn",
            text: "Last updated at #{Time.current.strftime("%H:%M:%S %d.%m.%Y")}"
          }
        ]
      }
      { type: "home", blocks: blocks }
    end

    private

    def format_date(date)
      date.strftime("%d.%m.%Y")
    end
  end
end

Event example

module MySlackBot
  class AppHomeOpenedEvent < SlackBot::Event
    view MySlackBot::AppHomeView
    def call
      # NOTE: we have to create callback here in order to handle interactions
      self.callback = SlackBot::Callback.find_or_create(
        id: "app_home_opened",
        user: current_user,
        class_name: self.class.name
      )

      publish_view :index_view
    end
  end
end

Extensibility

You can patch any class or module in this gem to extend its functionality, most of parts are not hardly attached to each other.

Development and testing

For development and testing purposes you can use Cloudflare Argo Tunnel to expose your local development environment to the internet.

brew install cloudflare/cloudflare/cloudflared
cloudflared login
sudo cloudflared tunnel run --token <LONG_TOKEN_FROM_TUNNEL_PAGE>

For easiness of getting information, most of endpoints have SlackBot::DevConsole.log calls that will print out information to the console.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/amkisko/grape-slack-bot.rb

Contribution policy:

  • It might take up to 2 calendar weeks to review and merge critical fixes
  • It might take up to 6 calendar months to review and merge pull request
  • It might take up to 1 calendar year to review an issue
  • New Slack features are not nessessarily added to the gem
  • Pull request should have test coverage for affected parts
  • Pull request should have changelog entry

Publishing

Prefer using script usr/bin/release.sh, it will ensure that repository is synced and after publishing gem will create a tag.

GEM_VERSION=$(grep -Eo "VERSION\s*=\s*\".+\"" lib/slack_bot.rb  | grep -Eo "[0-9.]{5,}")
rm grape-slack-bot-*.gem
gem build grape-slack-bot.gemspec
gem push grape-slack-bot-$GEM_VERSION.gem
git tag $GEM_VERSION && git push --tags

License

The gem is available as open source under the terms of the MIT License.