├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── .travis.yml ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin └── lita ├── lib ├── lita.rb └── lita │ ├── adapter.rb │ ├── adapters │ ├── shell.rb │ └── test.rb │ ├── authorization.rb │ ├── callback.rb │ ├── cli.rb │ ├── configurable.rb │ ├── configuration_builder.rb │ ├── configuration_validator.rb │ ├── default_configuration.rb │ ├── errors.rb │ ├── handler.rb │ ├── handler │ ├── chat_router.rb │ ├── common.rb │ ├── event_router.rb │ └── http_router.rb │ ├── handlers │ ├── authorization.rb │ ├── help.rb │ ├── info.rb │ ├── room.rb │ └── users.rb │ ├── http_callback.rb │ ├── http_route.rb │ ├── logger.rb │ ├── message.rb │ ├── middleware_registry.rb │ ├── namespace.rb │ ├── plugin_builder.rb │ ├── rack_app.rb │ ├── registry.rb │ ├── response.rb │ ├── robot.rb │ ├── room.rb │ ├── route_validator.rb │ ├── rspec.rb │ ├── rspec │ ├── handler.rb │ └── matchers │ │ ├── chat_route_matcher.rb │ │ ├── event_route_matcher.rb │ │ └── http_route_matcher.rb │ ├── source.rb │ ├── store.rb │ ├── target.rb │ ├── template.rb │ ├── template_resolver.rb │ ├── timer.rb │ ├── user.rb │ ├── util.rb │ └── version.rb ├── lita.gemspec ├── spec ├── lita │ ├── adapter_spec.rb │ ├── adapters │ │ └── shell_spec.rb │ ├── authorization_spec.rb │ ├── configuration_builder_spec.rb │ ├── configuration_validator_spec.rb │ ├── default_configuration_spec.rb │ ├── handler │ │ ├── chat_router_spec.rb │ │ ├── common_spec.rb │ │ ├── event_router_spec.rb │ │ └── http_router_spec.rb │ ├── handler_spec.rb │ ├── handlers │ │ ├── authorization_spec.rb │ │ ├── help_spec.rb │ │ ├── info_spec.rb │ │ ├── room_spec.rb │ │ └── users_spec.rb │ ├── logger_spec.rb │ ├── message_spec.rb │ ├── plugin_builder_spec.rb │ ├── response_spec.rb │ ├── robot_spec.rb │ ├── room_spec.rb │ ├── rspec │ │ └── handler_spec.rb │ ├── rspec_spec.rb │ ├── source_spec.rb │ ├── store_spec.rb │ ├── template_resolver_spec.rb │ ├── template_spec.rb │ ├── timer_spec.rb │ ├── user_spec.rb │ └── util_spec.rb ├── lita_spec.rb ├── spec_helper.rb └── templates │ ├── basic.erb │ ├── basic.irc.erb │ ├── helpers.erb │ └── interpolated.erb └── templates ├── locales └── en.yml ├── plugin ├── Gemfile ├── README.tt ├── Rakefile ├── gemspec.tt ├── gitignore ├── lib │ ├── lita │ │ └── plugin_type │ │ │ └── plugin.tt │ └── plugin.tt ├── locales │ └── en.yml.tt ├── spec │ ├── lita │ │ └── plugin_type │ │ │ └── plugin_spec.tt │ └── spec_helper.tt └── templates │ └── gitkeep └── robot ├── Gemfile └── lita_config.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | .idea 19 | lita_config.rb 20 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: "enable" 3 | 4 | Layout/ArgumentAlignment: 5 | EnforcedStyle: "with_fixed_indentation" 6 | Layout/HashAlignment: 7 | EnforcedHashRocketStyle: "table" 8 | Layout/EndAlignment: 9 | EnforcedStyleAlignWith: "variable" 10 | Layout/LineLength: 11 | Max: 100 12 | 13 | Lint/EmptyClass: 14 | Enabled: false 15 | 16 | Metrics/AbcSize: 17 | Enabled: false 18 | Metrics/BlockLength: 19 | Enabled: false 20 | Metrics/ClassLength: 21 | Enabled: false 22 | Metrics/MethodLength: 23 | Enabled: false 24 | Metrics/ParameterLists: 25 | Max: 6 26 | 27 | Naming/MethodParameterName: 28 | Enabled: false 29 | Naming/VariableNumber: 30 | EnforcedStyle: "snake_case" 31 | 32 | Style/Documentation: 33 | Enabled: false 34 | Style/DoubleNegation: 35 | Enabled: false 36 | Style/EachWithObject: 37 | Enabled: false 38 | Style/GuardClause: 39 | Enabled: false 40 | Style/SpecialGlobalVars: 41 | Enabled: false 42 | Style/StringLiterals: 43 | EnforcedStyle: "double_quotes" 44 | Style/StringLiteralsInInterpolation: 45 | EnforcedStyle: "double_quotes" 46 | Style/TrailingCommaInArguments: 47 | Enabled: false 48 | Style/TrailingCommaInArrayLiteral: 49 | Enabled: false 50 | Style/TrailingCommaInHashLiteral: 51 | Enabled: false 52 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.0.1 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: "ruby" 2 | sudo: false 3 | cache: "bundler" 4 | matrix: 5 | include: 6 | - rvm: "3.0" 7 | fast_finish: true 8 | script: "bundle exec rake" 9 | before_install: 10 | - "gem update --system" 11 | - "gem update bundler" 12 | services: 13 | - "redis-server" 14 | if: "type != push OR (tag IS blank AND branch = main)" 15 | notifications: 16 | email: false 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Lita 2 | 3 | ## Issues 4 | 5 | Found a bug in Lita? Open an issue on [GitHub Issues](https://github.com/litaio/lita/issues). For general questions, feedback, and discussion, please visit [Google Groups](https://groups.google.com/group/litaio) or the `#lita.io` channel on the [Freenode IRC network](https://webchat.freenode.net/). 6 | 7 | ## Pull requests 8 | 9 | Interested in contributing to Lita? That's great, and thank you for your interest! 10 | 11 | In order to keep Lita's codebase from growing too large, you're encouraged to implement new functionality via a [plugin](https://www.lita.io/plugin-authoring). If you're not able to achieve what you want with a plugin, then a pull request may be in order. Out of respect for your time, open an issue to discuss any non-trivial changes you intend to make before starting to write code. If you are planning a pull request to improve documentation, fix a bug, or improve performance, then feel free to proceed without opening an issue for discussion. 12 | 13 | To get your contributions accepted, make sure: 14 | 15 | * All the tests pass. Run `rspec`. 16 | * No code quality warnings are generated by [RuboCop](https://github.com/bbatsov/rubocop). Run `rubocop`. 17 | * Any new code paths you've added are covered by tests. 18 | * Any new classes or methods you've added have API documentation compatible with [YARD](https://yardoc.org/). If you've significantly changed the behavior of an existing class or method, you should also update any existing API documentation. 19 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2021 Jimmy Cuadra 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lita 2 | 3 | [![Gem Version](https://badge.fury.io/rb/lita.svg)](https://rubygems.org/gems/lita) 4 | [![Build Status](https://travis-ci.com/litaio/lita.svg?branch=main)](https://travis-ci.com/litaio/lita) 5 | 6 | **Lita** is a chat bot written in [Ruby](https://www.ruby-lang.org/) with persistent storage provided by [Redis](https://redis.io/). 7 | It uses a plugin system to connect to different chat services and to provide new behavior. 8 | The plugin system uses the familiar tools of the Ruby ecosystem: [RubyGems](https://rubygems.org/) and [Bundler](https://bundler.io). 9 | 10 | Automate your business and have fun with your very own robot companion. 11 | 12 | ## Documentation 13 | 14 | Please visit [lita.io](https://www.lita.io/) for comprehensive documentation. 15 | 16 | ## Plugins 17 | 18 | A list of all publicly available Lita plugins is available on the [lita.io plugins page](https://www.lita.io/plugins). 19 | 20 | The plugins page automatically updates daily with information from RubyGems. See [publishing](https://docs.lita.io/plugin-authoring/#publishing) for more information. 21 | 22 | ## Contributing 23 | 24 | See the [contribution guide](https://github.com/litaio/lita/blob/main/CONTRIBUTING.md). 25 | 26 | ## History 27 | 28 | For a history of releases, see the [Releases](https://github.com/litaio/lita/releases) page. 29 | 30 | ## License 31 | 32 | [MIT](https://opensource.org/licenses/MIT) 33 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "rubocop/rake_task" 6 | 7 | RSpec::Core::RakeTask.new 8 | RuboCop::RakeTask.new 9 | 10 | task default: %i[spec rubocop] 11 | -------------------------------------------------------------------------------- /bin/lita: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler" 5 | require "lita/cli" 6 | 7 | Lita::CLI.start 8 | -------------------------------------------------------------------------------- /lib/lita.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "stringio" 4 | 5 | require "i18n" 6 | require "i18n/backend/fallbacks" 7 | require "redis-namespace" 8 | 9 | require_relative "lita/configuration_builder" 10 | require_relative "lita/configuration_validator" 11 | require_relative "lita/errors" 12 | require_relative "lita/logger" 13 | require_relative "lita/registry" 14 | require_relative "lita/robot" 15 | 16 | # The main namespace for Lita. Provides a global registry of adapters and 17 | # handlers, as well as global configuration, logger, and Redis store. 18 | module Lita 19 | class << self 20 | include Registry::Mixins 21 | 22 | # A mode that makes minor changes to the Lita runtime to improve testability. 23 | # @return [Boolean] Whether or not test mode is active. 24 | # @since 4.0.0 25 | attr_reader :test_mode 26 | alias test_mode? test_mode 27 | 28 | # Sets both I18n.default_locale and I18n.locale to the provided locale, if any. 29 | # @api private 30 | # @since 5.0.0 31 | def configure_i18n(new_locale) 32 | unless new_locale.nil? 33 | self.default_locale = new_locale 34 | self.locale = new_locale 35 | end 36 | end 37 | 38 | # Lita's global +Logger+. 39 | # 40 | # The log level is initially set according to the environment variable +LITA_LOG+, defaulting to 41 | # +info+ if the variable is not set. Once the user configuration is loaded, the log level will 42 | # be reset to whatever is specified in the configuration file. 43 | # @return [::Logger] A +Logger+ object. 44 | def logger 45 | @logger ||= Logger.get_logger( 46 | ENV["LITA_LOG"], 47 | io: test_mode? ? StringIO.new : $stderr, 48 | ) 49 | end 50 | 51 | # Adds one or more paths to the I18n load path and reloads I18n. 52 | # @param paths [String, Array] The path(s) to add. 53 | # @return [void] 54 | # @since 3.0.0 55 | def load_locales(paths) 56 | I18n.load_path.concat(Array(paths)) 57 | I18n.reload! 58 | end 59 | 60 | # Sets +I18n.locale+, normalizing the provided locale name. 61 | # 62 | # Note that setting this only affects the current thread. Since handler 63 | # methods are dispatched in new threads, changing the locale globally will 64 | # require calling this method at the start of every handler method. 65 | # Alternatively, use {Lita#default_locale=} which will affect all threads. 66 | # @param new_locale [Symbol, String] The code of the locale to use. 67 | # @return [void] 68 | # @since 3.0.0 69 | def locale=(new_locale) 70 | I18n.locale = new_locale.to_s.tr("_", "-") 71 | end 72 | 73 | # Sets +I18n.default_locale+, normalizing the provided locale name. 74 | # 75 | # This is preferred over {Lita#locale=} as it affects all threads. 76 | # @param new_locale [Symbol, String] The code of the locale to use. 77 | # @return [void] 78 | # @since 4.8.0 79 | def default_locale=(new_locale) 80 | I18n.default_locale = new_locale.to_s.tr("_", "-") 81 | end 82 | 83 | # The absolute path to Lita's templates directory. 84 | # @return [String] The path. 85 | # @since 3.0.0 86 | def template_root 87 | File.expand_path("../templates", __dir__) 88 | end 89 | 90 | # Loads user configuration. 91 | # @param config_path [String] The path to the user configuration file. 92 | # @return [void] 93 | def load_config(config_path = nil) 94 | hooks[:before_run].each { |hook| hook.call(config_path: config_path) } 95 | ConfigurationBuilder.load_user_config(config_path) 96 | ConfigurationBuilder.freeze_config(config) 97 | recreate_logger # Pick up value of `config.robot.log_level` and `config.robot.log_formatter`. 98 | ConfigurationValidator.new(self).call 99 | hooks[:config_finalized].each { |hook| hook.call(config_path: config_path) } 100 | 101 | if config.robot.default_locale || config.robot.locale 102 | logger.warn I18n.t("lita.config.locale_deprecated") 103 | self.default_locale = config.robot.default_locale if config.robot.default_locale 104 | self.locale = config.robot.locale if config.robot.locale 105 | end 106 | end 107 | 108 | # Loads user configuration and starts the robot. 109 | # @param config_path [String] The path to the user configuration file. 110 | # @return [void] 111 | def run(config_path = nil) 112 | load_config(config_path) 113 | Robot.new.run 114 | end 115 | 116 | # Turns test mode on or off. 117 | # @param mode [Boolean] Whether or not test mode should be enabled. 118 | # @return [void] 119 | # @see #test_mode 120 | def test_mode=(mode) 121 | @test_mode = mode 122 | # Reset the logger because its IO stream is determined by test mode. 123 | recreate_logger 124 | end 125 | 126 | # A special mode to ensure that tests written for Lita 3 plugins continue to work. Has no effect 127 | # in Lita 5+. 128 | # @return [Boolean] Whether or not version 3 compatibility mode is active. 129 | # @since 4.0.0 130 | # @deprecated Will be removed in Lita 6.0. 131 | def version_3_compatibility_mode(_value = nil) 132 | warn I18n.t("lita.rspec.lita_3_compatibility_mode") 133 | false 134 | end 135 | alias version_3_compatibility_mode? version_3_compatibility_mode 136 | alias version_3_compatibility_mode= version_3_compatibility_mode 137 | 138 | private 139 | 140 | # Recreate the logger, specifying the configured log level and output stream. Should be called 141 | # manually after user configuration has been loaded and whenever test mode is changed. This is 142 | # necessary because {#logger} does not access the config so as not to accidentally build the 143 | # {DefaultConfiguration} before all plugins have been loaded and registered. 144 | def recreate_logger 145 | @logger = Logger.get_logger( 146 | config.robot.log_level, 147 | formatter: config.robot.log_formatter, 148 | io: test_mode? ? StringIO.new : $stderr, 149 | ) 150 | end 151 | end 152 | end 153 | 154 | I18n::Backend::Simple.include(I18n::Backend::Fallbacks) 155 | I18n.enforce_available_locales = false 156 | Lita.load_locales(Dir[File.join(Lita.template_root, "locales", "*.yml")]) 157 | Lita.configure_i18n(ENV["LC_ALL"] || ENV["LC_MESSAGES"] || ENV["LANG"]) 158 | 159 | require_relative "lita/adapters/shell" 160 | require_relative "lita/adapters/test" 161 | 162 | require_relative "lita/handlers/authorization" 163 | require_relative "lita/handlers/help" 164 | require_relative "lita/handlers/info" 165 | require_relative "lita/handlers/room" 166 | require_relative "lita/handlers/users" 167 | -------------------------------------------------------------------------------- /lib/lita/adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "i18n" 4 | 5 | require_relative "configurable" 6 | require_relative "namespace" 7 | 8 | module Lita 9 | # Adapters are the glue between Lita's API and a chat service. 10 | class Adapter 11 | # The names of methods that should be implemented by an adapter. 12 | # @since 4.4.0 13 | REQUIRED_METHODS = %i[ 14 | chat_service 15 | join 16 | part 17 | roster 18 | run 19 | send_messages 20 | set_topic 21 | shut_down 22 | ].freeze 23 | 24 | extend Namespace 25 | extend Configurable 26 | 27 | # The instance of {Robot}. 28 | # @return [Robot] 29 | attr_reader :robot 30 | 31 | class << self 32 | # Returns the translation for a key, automatically namespaced to the adapter. 33 | # @param key [String] The key of the translation. 34 | # @param hash [Hash] An optional hash of values to be interpolated in the string. 35 | # @return [String] The translated string. 36 | def translate(key, hash = {}) 37 | I18n.translate("lita.adapters.#{namespace}.#{key}", **hash) 38 | end 39 | 40 | alias t translate 41 | end 42 | 43 | # @param robot [Robot] The currently running robot. 44 | def initialize(robot) 45 | @robot = robot 46 | end 47 | 48 | # The adapter's configuration object. 49 | # @return [Configuration] The adapter's configuration object. 50 | # @since 4.0.0 51 | def config 52 | robot.config.adapters.public_send(self.class.namespace) 53 | end 54 | 55 | # @!method chat_service 56 | # May return an object exposing chat-service-specific APIs. 57 | # @return [Object, nil] The chat service API object, if any. 58 | # @abstract This should be implemented by the adapter. 59 | # @since 4.6.0 60 | 61 | # @!method join(room_id) 62 | # Joins the room with the specified ID. 63 | # @param room_id [String] The ID of the room. 64 | # @return [void] 65 | # @abstract This should be implemented by the adapter. 66 | # @since 3.0.0 67 | 68 | # @!method part(room_id) 69 | # Parts from the room with the specified ID. 70 | # @param room_id [String] The ID of the room. 71 | # @return [void] 72 | # @abstract This should be implemented by the adapter. 73 | # @since 3.0.0 74 | 75 | # @!method roster(room) 76 | # Get a list of users that are online in the given room. 77 | # @param room [Room] The room to return a roster for. 78 | # @return [Array] An array of users. 79 | # @abstract This should be implemented by the adapter. 80 | # @since 4.4.0 81 | 82 | # @!method run 83 | # The main loop. Should connect to the chat service, listen for incoming 84 | # messages, create {Message} objects from them, and dispatch them to 85 | # the robot by calling {Robot#receive}. 86 | # @return [void] 87 | # @abstract This should be implemented by the adapter. 88 | 89 | # @!method send_messages(target, strings) 90 | # Sends one or more messages to a user or room. 91 | # @param target [Source] The user or room to send messages to. 92 | # @param strings [Array] An array of messages to send. 93 | # @return [void] 94 | # @abstract This should be implemented by the adapter. 95 | 96 | # @!method set_topic(target, topic) 97 | # Sets the topic for a room. 98 | # @param target [Source] The room to change the topic for. 99 | # @param topic [String] The new topic. 100 | # @return [void] 101 | # @abstract This should be implemented by the adapter. 102 | 103 | # @!method shut_down 104 | # Performs any clean up necessary when disconnecting from the chat service. 105 | # @return [void] 106 | # @abstract This should be implemented by the adapter. 107 | REQUIRED_METHODS.each do |method| 108 | define_method(method) do |*_args| 109 | robot.logger.warn(I18n.t("lita.adapter.method_not_implemented", method: method)) 110 | end 111 | end 112 | 113 | # The robot's logger. 114 | # @return [::Logger] The robot's logger. 115 | # @since 4.0.2 116 | def log 117 | robot.logger 118 | end 119 | 120 | # Formats a name for "mentioning" a user in a group chat. Override this 121 | # method in child classes to customize the mention format for the chat 122 | # service. 123 | # @param name [String] The name to format as a mention name. 124 | # @return [String] The formatted mention name. 125 | # @since 3.1.0 126 | def mention_format(name) 127 | "#{name}:" 128 | end 129 | 130 | # Runs a block of code concurrently. By default the block is run in a new thread. Override 131 | # this method in child classes to customize the mechanism for concurrent code execution. 132 | # 133 | # @yield A block of code to run concurrently. 134 | # @return [void] 135 | # @since 5.0.0 136 | def run_concurrently(&block) 137 | Thread.new(&block) 138 | end 139 | 140 | # @see .translate 141 | def translate(*args) 142 | self.class.translate(*args) 143 | end 144 | 145 | alias t translate 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/lita/adapters/shell.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rbconfig" 4 | 5 | require "readline" 6 | 7 | require_relative "../adapter" 8 | require_relative "../message" 9 | require_relative "../source" 10 | require_relative "../user" 11 | 12 | module Lita 13 | # A namespace to hold all subclasses of {Adapter}. 14 | module Adapters 15 | # An adapter that runs Lita in a UNIX shell. 16 | class Shell < Adapter 17 | config :private_chat, default: false 18 | 19 | def initialize(robot) 20 | super 21 | 22 | self.user = User.create(1, name: "Shell User") 23 | end 24 | 25 | # rubocop:disable Lint/UnusedMethodArgument 26 | 27 | # Returns the users in the room, which is only ever the "Shell User." 28 | # @param room [Room] The room to return a roster for. Not used in this adapter. 29 | # @return [Array] The users in the room. 30 | # @since 4.4.0 31 | def roster(room) 32 | [user] 33 | end 34 | 35 | # rubocop:enable Lint/UnusedMethodArgument 36 | 37 | # Displays a prompt and requests input in a loop, passing the incoming messages to the robot. 38 | # @return [void] 39 | def run 40 | room = robot.config.adapters.shell.private_chat ? nil : "shell" 41 | @source = Source.new(user: user, room: room) 42 | puts t("startup_message") 43 | robot.trigger(:connected) 44 | 45 | run_loop 46 | end 47 | 48 | # Overrides {run_concurrently} to block instead. Since there is no separate UI element for the 49 | # user to enter text, we need to wait for all output for the robot before printing the next 50 | # input prompt. 51 | # 52 | # @yield A block of code to run. 53 | # @return [void] 54 | # @since 5.0.0 55 | def run_concurrently(&block) 56 | block.call 57 | end 58 | 59 | # Outputs outgoing messages to the shell. 60 | # @param _target [Source] Unused, since there is only one user in the 61 | # shell environment. 62 | # @param strings [Array] An array of strings to output. 63 | # @return [void] 64 | def send_messages(_target, strings) 65 | strings = Array(strings) 66 | strings.reject!(&:empty?) 67 | unless RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ || !$stdout.tty? 68 | strings.map! { |string| "\e[32m#{string}\e[0m" } 69 | end 70 | puts strings 71 | end 72 | 73 | # Adds a blank line for a nice looking exit. 74 | # @return [void] 75 | def shut_down 76 | puts 77 | end 78 | 79 | private 80 | 81 | attr_accessor :user 82 | 83 | def build_message(input, source) 84 | message = Message.new(robot, input, source) 85 | message.command! if robot.config.adapters.shell.private_chat 86 | message 87 | end 88 | 89 | def normalize_history(input) 90 | if input == "" || (Readline::HISTORY.size >= 2 && input == Readline::HISTORY[-2]) 91 | Readline::HISTORY.pop 92 | end 93 | end 94 | 95 | def normalize_input(input) 96 | input.chomp.strip 97 | end 98 | 99 | def read_input 100 | input = Readline.readline("#{robot.name} > ", true) 101 | # Input read via rb-readline will always be encoded as US-ASCII. 102 | # @see https://github.com/ConnorAtherton/rb-readline/blob/9fba246073f78831b7c7129c76cc07d8476a8892/lib/readline.rb#L1 103 | input&.dup&.force_encoding(Encoding.default_external) 104 | end 105 | 106 | def run_loop 107 | exit_keywords = %w[exit quit].freeze 108 | 109 | loop do 110 | input = read_input 111 | if input.nil? 112 | puts 113 | break 114 | end 115 | input = normalize_input(input) 116 | normalize_history(input) 117 | break if exit_keywords.include?(input) 118 | 119 | robot.receive(build_message(input, @source)) 120 | end 121 | end 122 | end 123 | 124 | Lita.register_adapter(:shell, Shell) 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/lita/adapters/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../adapter" 4 | 5 | module Lita 6 | # A namespace to hold all subclasses of {Adapter}. 7 | module Adapters 8 | # An adapter for testing Lita and Lita plugins. 9 | # @since 4.6.0 10 | class Test < Adapter 11 | # When true, calls to {#run_concurrently} will block the current thread. This is the default 12 | # because it's desirable for the majority of tests. It should be set to +false+ for tests 13 | # specifically testing asynchrony. 14 | config :blocking, types: [TrueClass, FalseClass], default: true 15 | 16 | # Adapter-specific methods exposed through {Robot}. 17 | class ChatService 18 | def initialize(sent_messages) 19 | @sent_messages = sent_messages 20 | end 21 | 22 | # An array of recorded outgoing messages. 23 | def sent_messages 24 | @sent_messages.dup 25 | end 26 | end 27 | 28 | def initialize(robot) 29 | super 30 | 31 | self.sent_messages = [] 32 | end 33 | 34 | # Adapter-specific methods available via {Robot#chat_service}. 35 | def chat_service 36 | ChatService.new(sent_messages) 37 | end 38 | 39 | # Records outgoing messages. 40 | def send_messages(_target, strings) 41 | sent_messages.concat(strings) 42 | end 43 | 44 | # If the +blocking+ config attribute is +true+ (which is the default), the block will be run 45 | # on the current thread, so tests can be written without concern for asynchrony. 46 | def run_concurrently(&block) 47 | if config.blocking 48 | block.call 49 | else 50 | super 51 | end 52 | end 53 | 54 | private 55 | 56 | # An array of recorded outgoing messages. 57 | attr_accessor :sent_messages 58 | end 59 | 60 | Lita.register_adapter(:test, Test) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/lita/authorization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "redis-namespace" 4 | 5 | require_relative "user" 6 | 7 | module Lita 8 | # Methods for querying and manipulating authorization groups. 9 | class Authorization 10 | # @param robot [Robot] The currently running robot. 11 | def initialize(robot) 12 | self.robot = robot 13 | self.redis = Redis::Namespace.new("auth", redis: robot.redis) 14 | end 15 | 16 | # Adds a user to an authorization group. 17 | # @param requesting_user [User] The user who sent the command. 18 | # @param user [User] The user to add to the group. 19 | # @param group [Symbol, String] The name of the group. 20 | # @return [Symbol] :unauthorized if the requesting user is not authorized. 21 | # @return [Boolean] true if the user was added. false if the user was 22 | # already in the group. 23 | def add_user_to_group(requesting_user, user, group) 24 | return :unauthorized unless user_is_admin?(requesting_user) 25 | 26 | add_user_to_group!(user, group) 27 | end 28 | 29 | # Adds a user to an authorization group without validating the permissions 30 | # of the requesting user. 31 | # @param user [User] The user to add to the group. 32 | # @param group [Symbol, String] The name of the group. 33 | # @return [Boolean] true if the user was added. false if the user was 34 | # already in the group. 35 | # @since 4.0.0 36 | def add_user_to_group!(user, group) 37 | redis.sadd(normalize_group(group), user.id) 38 | end 39 | 40 | # Removes a user from an authorization group. 41 | # @param requesting_user [User] The user who sent the command. 42 | # @param user [User] The user to remove from the group. 43 | # @param group [Symbol, String] The name of the group. 44 | # @return [Symbol] :unauthorized if the requesting user is not authorized. 45 | # @return [Boolean] true if the user was removed. false if the user was 46 | # not in the group. 47 | def remove_user_from_group(requesting_user, user, group) 48 | return :unauthorized unless user_is_admin?(requesting_user) 49 | 50 | remove_user_from_group!(user, group) 51 | end 52 | 53 | # Removes a suer from an authorization group without validating the 54 | # permissions of the requesting user. 55 | # @param user [User] The user to remove from the group. 56 | # @param group [Symbol, String] The name of the group. 57 | # @return [Boolean] true if the user was removed. false if the user was 58 | # not in the group. 59 | # @since 4.0.0 60 | def remove_user_from_group!(user, group) 61 | redis.srem(normalize_group(group), user.id) 62 | end 63 | 64 | # Checks if a user is in an authorization group. 65 | # @param user [User] The user. 66 | # @param group [Symbol, String] The name of the group. 67 | # @return [Boolean] Whether or not the user is in the group. 68 | def user_in_group?(user, group) 69 | group = normalize_group(group) 70 | return user_is_admin?(user) if group == "admins" 71 | 72 | redis.sismember(group, user.id) 73 | end 74 | 75 | # Checks if a user is an administrator. 76 | # @param user [User] The user. 77 | # @return [Boolean] Whether or not the user is an administrator. 78 | def user_is_admin?(user) 79 | Array(robot.config.robot.admins).include?(user.id) 80 | end 81 | 82 | # Returns a list of all authorization groups. 83 | # @return [Array] The names of all authorization groups. 84 | def groups 85 | redis.keys("*").map(&:to_sym) 86 | end 87 | 88 | # Returns a hash of authorization group names and the users in them. 89 | # @return [Hash] A map of +Symbol+ group names to {User} objects. 90 | def groups_with_users 91 | groups.reduce({}) do |list, group| 92 | list[group] = redis.smembers(group).map do |user_id| 93 | User.find_by_id(user_id) 94 | end 95 | list 96 | end 97 | end 98 | 99 | private 100 | 101 | # Ensures that group names are stored consistently in Redis. 102 | def normalize_group(group) 103 | group.to_s.downcase.strip 104 | end 105 | 106 | # @return [Redis::Namespace] A Redis::Namespace for authorization data. 107 | attr_accessor :redis 108 | 109 | # @return [Robot] The currently running robot. 110 | attr_accessor :robot 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/lita/callback.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | # Represents the action that is taken when a route or event is triggered. It 5 | # can be a block or the name of a method on object. 6 | # @since 4.0.0 7 | # @api private 8 | class Callback 9 | # A block that should be used as the callback. 10 | attr_reader :block 11 | 12 | # The name of the method in the plugin that should be called as the callback. 13 | attr_reader :method_name 14 | 15 | # @overload initialize(method_name) 16 | # @param method_name [String, Symbol] The name of the instance method that serves as the 17 | # callback. 18 | # @overload initialize(callable) 19 | # @param callable [Proc] A callable object to use as the callback. 20 | def initialize(method_name_or_callable) 21 | if method_name_or_callable.respond_to?(:call) 22 | @block = method_name_or_callable 23 | else 24 | @method_name = method_name_or_callable 25 | end 26 | end 27 | 28 | # Invokes the callback. 29 | def call(host, *args) 30 | if block 31 | host.instance_exec(*args, &block) 32 | else 33 | host.public_send(method_name, *args) 34 | end 35 | 36 | true 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/lita/configurable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "configuration_builder" 4 | 5 | module Lita 6 | # Mixin to add the ability for a plugin to define configuration. 7 | # @since 4.0.0 8 | module Configurable 9 | # A block to be executed after configuration is finalized. 10 | # @return [#call, nil] The block. 11 | # @since 5.0.0 12 | # @api private 13 | attr_accessor :after_config_block 14 | 15 | # The plugins's {ConfigurationBuilder} object. 16 | # @return [ConfigurationBuilder] The configuration builder. 17 | # @since 4.0.0 18 | # @api public 19 | attr_accessor :configuration_builder 20 | 21 | # Registers a block to be executed after configuration is finalized. 22 | # @yieldparam config [Configuration] The handler's configuration object. 23 | # @return [void] 24 | # @since 5.0.0 25 | def after_config(&block) 26 | self.after_config_block = block 27 | end 28 | 29 | # Sets a configuration attribute on the plugin. 30 | # @return [void] 31 | # @since 4.0.0 32 | # @see ConfigurationBuilder#config 33 | def config(*args, **kwargs, &block) 34 | if block 35 | configuration_builder.config(*args, **kwargs, &block) 36 | else 37 | configuration_builder.config(*args, **kwargs) 38 | end 39 | end 40 | 41 | # Initializes the configuration builder for any inheriting classes. 42 | def inherited(klass) 43 | super 44 | klass.configuration_builder = ConfigurationBuilder.new 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/lita/configuration_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | # Validates a registry's configuration, checking for required attributes that are missing. 5 | # @since 4.0.0 6 | # @api private 7 | class ConfigurationValidator 8 | # @param registry [Registry] The registry containing the configuration to validate. 9 | def initialize(registry) 10 | self.registry = registry 11 | end 12 | 13 | # Validates adapter and handler configuration. Logs a fatal warning and aborts if any required 14 | # configuration attributes are missing. 15 | # @return [void] 16 | def call 17 | validate_adapters 18 | validate_handlers 19 | end 20 | 21 | private 22 | 23 | # The registry containing the configuration to validate. 24 | attr_accessor :registry 25 | 26 | # The registry's adapters. 27 | def adapters 28 | registry.adapters 29 | end 30 | 31 | # All a plugin's top-level configuration attributes. 32 | def children_for(plugin) 33 | plugin.configuration_builder.children 34 | end 35 | 36 | # Return the {Configuration} for the given plugin. 37 | # @param type [String, Symbol] Either "adapters" or "handlers". 38 | # @param name [String, Symbol] The name of the plugin's top-level {Configuration}. 39 | # @param namespace [Array] A list of nested config attributes to traverse to 40 | # find the desired {Configuration}. 41 | def config_for(type, name, namespace) 42 | config = registry.config.public_send(type).public_send(name) 43 | namespace.each { |n| config = config.public_send(n) } 44 | config 45 | end 46 | 47 | # Generates the fully qualified name of a configuration attribute. 48 | def full_attribute_name(names, name) 49 | (names + [name]).join(".") 50 | end 51 | 52 | # The registry's handlers. 53 | def handlers 54 | registry.handlers 55 | end 56 | 57 | # Validates the registry's adapters. 58 | def validate_adapters 59 | adapters.each do |adapter_name, adapter| 60 | validate(:adapter, adapter_name, adapter, children_for(adapter)) 61 | end 62 | end 63 | 64 | # Validates the registry's handlers. 65 | def validate_handlers 66 | handlers.each do |handler| 67 | validate(:handler, handler.namespace, handler, children_for(handler)) 68 | end 69 | end 70 | 71 | # Validates an array of attributes, recursing if any nested attributes are encountered. 72 | def validate(type, plugin_name, plugin, attributes, attribute_namespace = []) 73 | attributes.each do |attribute| 74 | config = config_for("#{type}s", plugin_name, attribute_namespace) 75 | 76 | if attribute.children? 77 | validate( 78 | type, 79 | plugin_name, 80 | plugin, 81 | attribute.children, 82 | attribute_namespace.clone.push(attribute.name), 83 | ) 84 | elsif attribute.required? && config.public_send(attribute.name).nil? 85 | Lita.logger.fatal I18n.t( 86 | "lita.config.missing_required_#{type}_attribute", 87 | type => plugin_name, 88 | attribute: full_attribute_name(attribute_namespace, attribute.name) 89 | ) 90 | exit(false) 91 | end 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/lita/default_configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "configuration_builder" 4 | require_relative "middleware_registry" 5 | 6 | module Lita 7 | # Builds the configuration object that is stored in each {Registry}. 8 | # @since 4.0.0 9 | # @api private 10 | class DefaultConfiguration 11 | # Valid levels for Lita's logger. 12 | LOG_LEVELS = %w[debug info warn error fatal].freeze 13 | 14 | # The default log formatter. 15 | # 16 | # This is specified as a constant instead of inline for +config.robot.log_formatter+ so it can 17 | # be used by {Lita.logger} for early logging without accessing it via the config attribute and 18 | # generating the default config too early, before plugins have been registered. 19 | DEFAULT_LOG_FORMATTER = lambda { |severity, datetime, _progname, msg| 20 | "[#{datetime.utc}] #{severity}: #{msg}\n" 21 | } 22 | 23 | # A {Registry} to extract configuration for plugins from. 24 | # @return [Registry] The registry. 25 | attr_reader :registry 26 | 27 | # The top-level {ConfigurationBuilder} attribute. 28 | # @return [Configuration] The root attribute. 29 | attr_reader :root 30 | 31 | # @param registry [Registry] The registry to build a default configuration object from. 32 | def initialize(registry) 33 | @registry = registry 34 | @root = ConfigurationBuilder.new 35 | 36 | adapters_config 37 | handlers_config 38 | http_config 39 | redis_config 40 | robot_config 41 | end 42 | 43 | # Processes the {ConfigurationBuilder} object to return a {Configuration}. 44 | # @return [Configuration] The built configuration object. 45 | def build 46 | root.build 47 | end 48 | 49 | private 50 | 51 | # Builds config.adapters 52 | def adapters_config 53 | adapters = registry.adapters 54 | 55 | root.config :adapters do 56 | adapters.each do |key, adapter| 57 | combine(key, adapter.configuration_builder) 58 | end 59 | end 60 | end 61 | 62 | # Builds config.handlers 63 | def handlers_config 64 | handlers = registry.handlers 65 | 66 | root.config :handlers do 67 | handlers.each do |handler| 68 | if handler.configuration_builder.children? 69 | combine(handler.namespace, handler.configuration_builder) 70 | end 71 | end 72 | end 73 | end 74 | 75 | # Builds config.http 76 | def http_config 77 | root.config :http do 78 | config :host, type: String, default: "0.0.0.0" 79 | config :port, type: [Integer, String], default: 8080 80 | config :min_threads, type: [Integer, String], default: 0 81 | config :max_threads, type: [Integer, String], default: 16 82 | config :middleware, type: MiddlewareRegistry, default: MiddlewareRegistry.new 83 | end 84 | end 85 | 86 | # Builds config.redis 87 | def redis_config 88 | root.config :redis, type: Hash, default: {} 89 | end 90 | 91 | # Builds config.robot 92 | def robot_config 93 | root.config :robot do 94 | config :name, type: String, default: "Lita" 95 | config :mention_name, type: String 96 | config :alias, type: String 97 | config :adapter, types: [String, Symbol], default: :shell 98 | config :locale, types: [String, Symbol], default: nil 99 | config :default_locale, types: [String, Symbol], default: nil 100 | config :redis_namespace, type: String, default: Lita.test_mode? ? "lita.test" : "lita" 101 | config :log_level, types: [String, Symbol], default: :info do 102 | validate do |value| 103 | unless LOG_LEVELS.include?(value.to_s.downcase.strip) 104 | "must be one of: #{LOG_LEVELS.join(", ")}" 105 | end 106 | end 107 | end 108 | config :log_formatter, default: DEFAULT_LOG_FORMATTER do 109 | validate do |value| 110 | "must respond to #call" unless value.respond_to?(:call) 111 | end 112 | end 113 | config :admins 114 | config :error_handler, default: ->(_error, _metadata) {} do 115 | validate do |value| 116 | "must respond to #call" unless value.respond_to?(:call) 117 | end 118 | end 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/lita/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | # The root exception class that all Lita-specific exceptions inherit from. 5 | # @since 4.0.0 6 | class Error < StandardError; end 7 | 8 | # An exception raised when a custom validation is set on a configuration attribute that is 9 | # violated by the default value of the attribute. 10 | # @since 4.0.0 11 | class ValidationError < Error; end 12 | 13 | # An exception raised when Lita can't connect to Redis in test mode. 14 | # @since 4.0.3 15 | class RedisError < Error; end 16 | 17 | # An exception raised when attempting to resolve a template that doesn't exist. 18 | # @since 4.2.0 19 | class MissingTemplateError < Error; end 20 | 21 | # An exception raised when a handler attempts to render a template without having set its 22 | # template root. 23 | # @since 4.2.0 24 | class MissingTemplateRootError < Error; end 25 | end 26 | -------------------------------------------------------------------------------- /lib/lita/handler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "handler/chat_router" 4 | require_relative "handler/http_router" 5 | require_relative "handler/event_router" 6 | 7 | module Lita 8 | # Base class for objects that add new behavior to Lita. {Handler} is simply a class with all 9 | # types of routers mixed in. 10 | class Handler 11 | extend ChatRouter 12 | extend HTTPRouter 13 | extend EventRouter 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/lita/handler/chat_router.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "i18n" 4 | 5 | require_relative "../callback" 6 | require_relative "../response" 7 | require_relative "../route_validator" 8 | require_relative "common" 9 | 10 | module Lita 11 | class Handler 12 | # A handler mixin that provides the methods necessary for responding to chat messages. 13 | # @since 4.0.0 14 | module ChatRouter 15 | # Includes common handler methods in any class that includes {ChatRouter}. 16 | def self.extended(klass) 17 | klass.include(Common) 18 | end 19 | 20 | # A Struct representing a chat route defined by a handler. 21 | Route = Struct.new( 22 | :pattern, 23 | :callback, 24 | :command, 25 | :required_groups, 26 | :help, 27 | :extensions 28 | ) 29 | 30 | class Route 31 | alias command? command 32 | end 33 | 34 | # @overload route(pattern, method_name, **options) 35 | # Creates a chat route. 36 | # @param pattern [Regexp] A regular expression to match incoming messages against. 37 | # @param method_name [Symbol, String] The name of the instance method to trigger. 38 | # @param command [Boolean] Whether or not the message must be directed at the robot. 39 | # @param restrict_to [Array, nil] An optional list of authorization 40 | # groups the user must be in to trigger the route. 41 | # @param help [Hash] An optional map of example invocations to descriptions. 42 | # @param options [Hash] Aribtrary additional data that can be used by Lita extensions. 43 | # @return [void] 44 | # @overload route(pattern, **options) 45 | # Creates a chat route. 46 | # @param pattern [Regexp] A regular expression to match incoming messages against. 47 | # @param command [Boolean] Whether or not the message must be directed at the robot. 48 | # @param restrict_to [Array, nil] An optional list of authorization 49 | # groups the user must be in to trigger the route. 50 | # @param help [Hash] An optional map of example invocations to descriptions. 51 | # @param options [Hash] Aribtrary additional data that can be used by Lita extensions. 52 | # @yield The body of the route's callback. 53 | # @return [void] 54 | # @since 4.0.0 55 | def route(pattern, method_name = nil, **options, &block) 56 | options = default_route_options.merge(options) 57 | options[:restrict_to] = options[:restrict_to].nil? ? nil : Array(options[:restrict_to]) 58 | routes << Route.new( 59 | pattern, 60 | Callback.new(method_name || block), 61 | options.delete(:command), 62 | options.delete(:restrict_to), 63 | options.delete(:help), 64 | options 65 | ) 66 | end 67 | 68 | # A list of chat routes defined by the handler. 69 | # @return [Array] 70 | def routes 71 | @routes ||= [] 72 | end 73 | 74 | # The main entry point for the handler at runtime. Checks if the message 75 | # matches any of the routes and invokes the route's method if it does. 76 | # Called by {Robot#receive}. 77 | # @param robot [Robot] The currently running robot. 78 | # @param message [Message] The incoming message. 79 | # @return [Boolean] Whether or not the message matched any routes. 80 | def dispatch(robot, message) 81 | routes.map do |route| 82 | next unless route_applies?(route, message, robot) 83 | 84 | log_dispatch(robot, route) 85 | 86 | robot.run_concurrently { dispatch_to_route(route, robot, message) } 87 | 88 | true 89 | end.any? 90 | end 91 | 92 | # Dispatch directly to a {Route}, ignoring route conditions. 93 | # @param route [Route] The route to invoke. 94 | # @param robot [Robot] The currently running robot. 95 | # @param message [Message] The incoming message. 96 | # @return [void] 97 | # @since 3.3.0 98 | def dispatch_to_route(route, robot, message) 99 | response = Response.new(message, route.pattern) 100 | robot.hooks[:trigger_route].each { |hook| hook.call(response: response, route: route) } 101 | handler = new(robot) 102 | route.callback.call(handler, response) 103 | robot.hooks[:post_route].each { |hook| hook.call(response: response, route: route) } 104 | robot.trigger( 105 | :message_dispatched, 106 | handler: self, 107 | route: route, 108 | message: message, 109 | robot: robot 110 | ) 111 | rescue StandardError => e 112 | log_error(robot, e, message: message) 113 | end 114 | 115 | private 116 | 117 | # The default options for every chat route. 118 | def default_route_options 119 | { 120 | command: false, 121 | restrict_to: nil, 122 | help: {} 123 | } 124 | end 125 | 126 | # Determines whether or not an incoming messages should trigger a route. 127 | def route_applies?(route, message, robot) 128 | RouteValidator.new(self, route, message, robot).call 129 | end 130 | 131 | # Logs the dispatch of message. 132 | def log_dispatch(robot, route) 133 | robot.logger.debug I18n.t( 134 | "lita.handler.dispatch", 135 | handler: name, 136 | method: route.callback.method_name || "(block)" 137 | ) 138 | end 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/lita/handler/event_router.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../callback" 4 | require_relative "common" 5 | 6 | module Lita 7 | class Handler 8 | # A handler mixin that provides the methods necessary for handling events. 9 | # @since 4.0.0 10 | module EventRouter 11 | # Includes common handler methods in any class that includes {EventRouter}. 12 | def self.extended(klass) 13 | klass.include(Common) 14 | end 15 | 16 | # @overload on(event_name, method_name) 17 | # Registers an event subscription. When an event is triggered with 18 | # {#trigger}, a new instance of the handler will be created and the 19 | # instance method name supplied to {#on} will be invoked with a payload 20 | # (a hash of arbitrary keys and values). 21 | # @param event_name [String, Symbol] The name of the event to subscribe to. 22 | # @param method_name [String, Symbol] The name of the instance method on 23 | # the handler that should be invoked when the event is triggered. 24 | # @return [void] 25 | # @overload on(event_name, callable) 26 | # Registers an event subscription. When an event is triggered with 27 | # {#trigger}, a new instance of the handler will be created and the 28 | # callable object supplied to {#on} will be evaluated within the context of the new 29 | # handler instance, and passed a payload (a hash of arbitrary keys and values). 30 | # @param event_name [String, Symbol] The name of the event to subscribe to. 31 | # @param callable [#call] A callable object to serve as the event callback. 32 | # @return [void] 33 | # @since 4.0.0 34 | # @overload on(event_name) 35 | # Registers an event subscription. When an event is triggered with 36 | # {#trigger}, a new instance of the handler will be created and the 37 | # block supplied to {#on} will be evaluated within the context of the new 38 | # handler instance, and passed a payload (a hash of arbitrary keys and values). 39 | # @param event_name [String, Symbol] The name of the event to subscribe to. 40 | # @yield The body of the event callback. 41 | # @return [void] 42 | # @since 4.0.0 43 | def on(event_name, method_name_or_callable = nil, &block) 44 | event_subscriptions[normalize_event(event_name)] << Callback.new( 45 | method_name_or_callable || block 46 | ) 47 | end 48 | 49 | # Returns an array of all callbacks registered for the named event. 50 | # @param event_name [String, Symbol] The name of the event to return callbacks for. 51 | # @return [Array] The array of callbacks. 52 | # @since 4.0.0 53 | def event_subscriptions_for(event_name) 54 | event_subscriptions[normalize_event(event_name)] 55 | end 56 | 57 | # Triggers an event, invoking methods previously registered with {#on} and 58 | # passing them a payload hash with any arbitrary data. 59 | # @param robot [Robot] The currently running robot instance. 60 | # @param event_name [String, Symbol], The name of the event to trigger. 61 | # @param payload [Hash] An optional hash of arbitrary data. 62 | # @return [Boolean] Whether or not the event triggered any callbacks. 63 | def trigger(robot, event_name, payload = {}) 64 | event_subscriptions_for(event_name).map do |callback| 65 | callback.call(new(robot), payload) 66 | rescue StandardError => e 67 | log_error(robot, e, payload: payload) 68 | end.any? 69 | end 70 | 71 | private 72 | 73 | # A hash of arrays used to store event subscriptions registered with {#on}. 74 | def event_subscriptions 75 | @event_subscriptions ||= Hash.new { |h, k| h[k] = [] } 76 | end 77 | 78 | # Normalize the event name, ignoring casing and spaces. 79 | def normalize_event(event_name) 80 | event_name.to_s.downcase.strip.to_sym 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/lita/handler/http_router.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../http_route" 4 | require_relative "common" 5 | 6 | module Lita 7 | class Handler 8 | # A handler mixin that provides the methods necessary for handling incoming HTTP requests. 9 | # @since 4.0.0 10 | module HTTPRouter 11 | # Includes common handler methods in any class that includes {HTTPRouter}. 12 | def self.extended(klass) 13 | klass.include(Common) 14 | end 15 | 16 | # Creates a new {HTTPRoute} which is used to define an HTTP route 17 | # for the built-in web server. 18 | # @see HTTPRoute 19 | # @return [HTTPRoute] The new {HTTPRoute}. 20 | def http 21 | HTTPRoute.new(self) 22 | end 23 | 24 | # An array of all HTTP routes defined for the handler. 25 | # @return [Array] The array of routes. 26 | def http_routes 27 | @http_routes ||= [] 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/lita/handlers/authorization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | # A namespace to hold all subclasses of {Handler}. 5 | module Handlers 6 | # Provides a chat interface for administering authorization groups. 7 | class Authorization 8 | extend Handler::ChatRouter 9 | 10 | route( 11 | /^auth\s+add/, 12 | :add, 13 | command: true, 14 | restrict_to: :admins, 15 | help: { t("help.add_key") => t("help.add_value") } 16 | ) 17 | route( 18 | /^auth\s+remove/, 19 | :remove, 20 | command: true, 21 | restrict_to: :admins, 22 | help: { t("help.remove_key") => t("help.remove_value") } 23 | ) 24 | route(/^auth\s+list/, :list, command: true, restrict_to: :admins, help: { 25 | t("help.list_key") => t("help.list_value") 26 | }) 27 | 28 | # Adds a user to an authorization group. 29 | # @param response [Response] The response object. 30 | # @return [void] 31 | def add(response) 32 | toggle_membership(response, :add_user_to_group, "user_added", "user_already_in") 33 | end 34 | 35 | # Removes a user from an authorization group. 36 | # @param response [Response] The response object. 37 | # @return [void] 38 | def remove(response) 39 | toggle_membership(response, :remove_user_from_group, "user_removed", "user_not_in") 40 | end 41 | 42 | # Lists all authorization groups (or only the specified group) and the 43 | # names of their members. 44 | # @param response [Response] The response object. 45 | # @return [void] 46 | def list(response) 47 | requested_group = response.args[1] 48 | output = get_groups_list(response.args[1]) 49 | if output.empty? 50 | response.reply(empty_state_for_list(requested_group)) 51 | else 52 | response.reply(output.join("\n")) 53 | end 54 | end 55 | 56 | private 57 | 58 | def empty_state_for_list(requested_group) 59 | if requested_group 60 | t("empty_state_group", group: requested_group) 61 | else 62 | t("empty_state") 63 | end 64 | end 65 | 66 | def get_groups_list(requested_group) 67 | groups_with_users = robot.auth.groups_with_users 68 | if requested_group 69 | requested_group = requested_group.downcase.strip.to_sym 70 | groups_with_users.select! { |group, _| group == requested_group } 71 | end 72 | groups_with_users.map do |group, users| 73 | user_names = users.map(&:name).join(", ") 74 | "#{group}: #{user_names}" 75 | end 76 | end 77 | 78 | def toggle_membership(response, method_name, success_key, failure_key) 79 | return unless valid_message?(response) 80 | 81 | if robot.auth.public_send(method_name, response.user, @user, @group) 82 | response.reply t(success_key, user: @user.name, group: @group) 83 | else 84 | response.reply t(failure_key, user: @user.name, group: @group) 85 | end 86 | end 87 | 88 | def valid_group?(response, identifier) 89 | unless identifier && @group 90 | response.reply "#{t("format")}: #{robot.name} auth add USER GROUP" 91 | return false 92 | end 93 | 94 | if @group.downcase.strip == "admins" 95 | response.reply t("admin_management") 96 | return false 97 | end 98 | 99 | true 100 | end 101 | 102 | # Validates that incoming messages have the right format and a valid user. 103 | # Also assigns the user and group to instance variables for the main 104 | # methods to use later. 105 | def valid_message?(response) 106 | _command, identifier, @group = response.args 107 | 108 | return unless valid_group?(response, identifier) 109 | 110 | return unless valid_user?(response, identifier) 111 | 112 | true 113 | end 114 | 115 | def valid_user?(response, identifier) 116 | @user = User.fuzzy_find(identifier) 117 | 118 | if @user 119 | true 120 | else 121 | response.reply t("no_user_found", identifier: identifier) 122 | false 123 | end 124 | end 125 | end 126 | 127 | Lita.register_handler(Authorization) 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/lita/handlers/help.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | # A namespace to hold all subclasses of {Handler}. 5 | module Handlers 6 | # Provides online help about Lita commands for users. 7 | class Help 8 | extend Handler::ChatRouter 9 | 10 | route(/^help\s*(?.+)?/i, :help, command: true, help: { 11 | "help" => t("help.help_value"), 12 | t("help.help_query_key") => t("help.help_query_value") 13 | }) 14 | 15 | # Outputs help information about Lita commands. 16 | # @param response [Response] The response object. 17 | # @return [void] 18 | def help(response) 19 | query = response.match_data["query"] 20 | 21 | if query.nil? 22 | return response.reply_privately( 23 | "#{t("info", address: address)}\n\n#{list_handlers.join("\n")}" 24 | ) 25 | end 26 | 27 | handlers = matching_handlers(query) 28 | handlers_to_messages = map_handlers_to_messages(response, handlers) 29 | messages = matching_messages(response, query, handlers_to_messages) 30 | response.reply_privately(format_reply(handlers_to_messages, messages, query)) 31 | end 32 | 33 | private 34 | 35 | # Checks if the user is authorized to at least one of the given groups. 36 | def authorized?(user, required_groups) 37 | required_groups.nil? || required_groups.any? do |group| 38 | robot.auth.user_in_group?(user, group) 39 | end 40 | end 41 | 42 | # Creates an alphabetically-sorted array containing the names of all 43 | # installed handlers. 44 | def list_handlers 45 | robot.handlers.flat_map do |handler| 46 | handler.namespace if handler.respond_to?(:routes) 47 | end.compact.uniq.sort 48 | end 49 | 50 | # Creates an array of handlers matching the given query. 51 | def matching_handlers(query) 52 | name = query.downcase.strip 53 | 54 | return [] unless list_handlers.include?(name) 55 | 56 | robot.handlers.select { |handler| handler.namespace == name } 57 | end 58 | 59 | # Creates a hash of handler namespaces and their associated help messages. 60 | def map_handlers_to_messages(response, handlers) 61 | handlers_to_messages = {} 62 | handlers.each do |handler| 63 | messages = if handler.respond_to?(:routes) 64 | handler.routes.map do |route| 65 | route.help.map do |command, description| 66 | help_command(response, route, command, description) 67 | end 68 | end.flatten 69 | else 70 | [] 71 | end 72 | 73 | (handlers_to_messages[handler.namespace] ||= []).push(*messages) 74 | end 75 | 76 | handlers_to_messages 77 | end 78 | 79 | # Creates an array of help messages for all registered routes. 80 | def all_help_messages(response) 81 | robot.handlers.map do |handler| 82 | next unless handler.respond_to?(:routes) 83 | 84 | handler.routes.map do |route| 85 | route.help.map do |command, description| 86 | help_command(response, route, command, description) 87 | end 88 | end 89 | end.flatten.compact 90 | end 91 | 92 | # Creates an array consisting of all help messages that match the given 93 | # query. 94 | def all_matching_messages(response, query) 95 | filter_messages(all_help_messages(response), query) 96 | end 97 | 98 | # Removes matching help messages that are already present in the 99 | # comprehensive array of help messages defined by the requested 100 | # handler(s). 101 | def dedup_messages(handlers_to_messages, messages) 102 | all_handler_messages = handlers_to_messages.values.flatten 103 | messages.reject { |m| all_handler_messages.include?(m) } 104 | end 105 | 106 | # Creates an array of help messages matching the given query, minus 107 | # duplicates. 108 | def matching_messages(response, query, handlers_to_messages) 109 | dedup_messages(handlers_to_messages, all_matching_messages(response, query)) 110 | end 111 | 112 | # Filters help messages matching a query. 113 | def filter_messages(messages, query) 114 | messages.select do |line| 115 | /(?:@?#{Regexp.escape(address)})?#{Regexp.escape(query)}/i.match?(line) 116 | end 117 | end 118 | 119 | # Formats a block of text associating a handler namespace with the help 120 | # messages it defines. 121 | def format_handler_messages(handler, messages) 122 | unless messages.empty? 123 | "#{t("handler_contains", handler: handler)}:\n\n" + messages.join("\n") 124 | end 125 | end 126 | 127 | # Formats a block of text for message patterns or descriptions that directly match the user's 128 | # query. 129 | def format_messages(messages, query) 130 | if messages.empty? 131 | messages 132 | else 133 | ["#{t("pattern_or_description_contains", query: query)}:\n"] + messages 134 | end 135 | end 136 | 137 | # Formats the message to be sent in response to a help command. 138 | def format_reply(handlers_to_messages, messages, query) 139 | return t("no_help_found") if handlers_to_messages.empty? && messages.empty? 140 | 141 | handler_messages = handlers_to_messages.keys.map do |handler| 142 | format_handler_messages(handler, handlers_to_messages[handler]) 143 | end.compact 144 | separator = handler_messages.empty? || messages.empty? ? "" : "\n\n" 145 | [handler_messages, format_messages(messages, query)].map do |m| 146 | m.join("\n") 147 | end.join(separator) 148 | end 149 | 150 | # Formats an individual command's help message. 151 | def help_command(response, route, command, description) 152 | command = "#{address}#{command}" if route.command? 153 | message = "#{command} - #{description}" 154 | message << t("unauthorized") unless authorized?(response.user, route.required_groups) 155 | message 156 | end 157 | 158 | # The way the bot should be addressed in order to trigger a command. 159 | def address 160 | robot.config.robot.alias || "#{name}: " 161 | end 162 | 163 | # Fallback in case no alias is defined. 164 | def name 165 | robot.config.robot.mention_name || robot.config.robot.name 166 | end 167 | end 168 | 169 | Lita.register_handler(Help) 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /lib/lita/handlers/info.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module Lita 6 | # A namespace to hold all subclasses of {Handler}. 7 | module Handlers 8 | # Provides information about the currently running robot. 9 | class Info 10 | extend Handler::ChatRouter 11 | extend Handler::HTTPRouter 12 | 13 | route(/^info$/i, :chat, command: true, help: { 14 | "info" => t("help.info_value") 15 | }) 16 | 17 | http.get "/lita/info", :web 18 | 19 | # Replies with the current version of Lita, the current version of Redis, 20 | # and Redis memory usage. 21 | # @param response [Response] The response object. 22 | # @return [void] 23 | # @since 3.0.0 24 | def chat(response) 25 | response.reply( 26 | %(Lita #{Lita::VERSION} - https://www.lita.io/), 27 | %(Redis #{redis_version} - Memory used: #{redis_memory_usage}) 28 | ) 29 | end 30 | 31 | # Returns JSON with basic information about the robot. 32 | # @param _request [Rack::Request] The HTTP request. 33 | # @param response [Rack::Response] The HTTP response. 34 | # @return [void] 35 | def web(_request, response) 36 | response.headers["Content-Type"] = "application/json" 37 | json = JSON.dump( 38 | adapter: robot.config.robot.adapter, 39 | lita_version: Lita::VERSION, 40 | redis_memory_usage: redis_memory_usage, 41 | redis_version: redis_version, 42 | robot_mention_name: robot.mention_name, 43 | robot_name: robot.name 44 | ) 45 | response.write(json) 46 | end 47 | 48 | # A hash of information about Redis. 49 | def redis_info 50 | @redis_info ||= Lita.redis.redis.info 51 | end 52 | 53 | # The current version of Redis. 54 | def redis_version 55 | redis_info["redis_version"] 56 | end 57 | 58 | # The amount of memory Redis is using. 59 | def redis_memory_usage 60 | redis_info["used_memory_human"] 61 | end 62 | end 63 | 64 | Lita.register_handler(Info) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/lita/handlers/room.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | # A namespace to hold all subclasses of {Handler}. 5 | module Handlers 6 | # Allows administrators to make Lita join and part from rooms. 7 | # @since 3.0.0 8 | class Room 9 | extend Handler::ChatRouter 10 | 11 | route(/^join\s+(.+)$/i, :join, command: true, restrict_to: :admins, help: { 12 | t("help.join_key") => t("help.join_value") 13 | }) 14 | 15 | route(/^part\s+(.+)$/i, :part, command: true, restrict_to: :admins, help: { 16 | t("help.part_key") => t("help.part_value") 17 | }) 18 | 19 | # Joins the room with the specified ID. 20 | # @param response [Response] The response object. 21 | # @return [void] 22 | def join(response) 23 | robot.join(response.args[0]) 24 | end 25 | 26 | # Parts from the room with the specified ID. 27 | # @param response [Response] The response object. 28 | # @return [void] 29 | def part(response) 30 | robot.part(response.args[0]) 31 | end 32 | end 33 | 34 | Lita.register_handler(Room) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/lita/handlers/users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | module Handlers 5 | # Provides information on Lita users. 6 | # @since 4.1.0 7 | class Users 8 | extend Handler::ChatRouter 9 | 10 | route(/^users\s+find\s+(.+)/i, :find, command: true, help: { 11 | t("help.find_key") => t("help.find_value") 12 | }) 13 | 14 | # Outputs the name, ID, and mention name of a user matching the search query. 15 | # @param response [Response] The response object. 16 | # @return [void] 17 | def find(response) 18 | user = User.fuzzy_find(response.args[1]) 19 | 20 | if user 21 | response.reply(formatted_user(user)) 22 | else 23 | response.reply(t("find_empty_state")) 24 | end 25 | end 26 | 27 | private 28 | 29 | # Extract and label the relevant user information. 30 | def formatted_user(user) 31 | "#{user.name} (ID: #{user.id}, Mention name: #{user.mention_name})" 32 | end 33 | end 34 | 35 | Lita.register_handler(Users) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/lita/http_callback.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack" 4 | 5 | module Lita 6 | # A wrapper around a handler's HTTP route callbacks that sets up the request and response. 7 | # @api private 8 | # @since 4.0.0 9 | class HTTPCallback 10 | # @param handler_class [Handler] The handler defining the callback. 11 | # @param callback [Proc] The callback. 12 | def initialize(handler_class, callback) 13 | @handler_class = handler_class 14 | @callback = callback 15 | end 16 | 17 | # Call the Rack endpoint with a standard environment hash. 18 | def call(env) 19 | request = Rack::Request.new(env) 20 | response = Rack::Response.new 21 | 22 | if request.head? 23 | response.status = 204 24 | else 25 | begin 26 | handler = @handler_class.new(env["lita.robot"]) 27 | 28 | @callback.call(handler, request, response) 29 | rescue StandardError => e 30 | robot = env["lita.robot"] 31 | error_handler = robot.config.robot.error_handler 32 | 33 | if error_handler.arity == 2 34 | error_handler.call(e, rack_env: env, robot: robot) 35 | else 36 | error_handler.call(e) 37 | end 38 | 39 | raise 40 | end 41 | end 42 | 43 | response.finish 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/lita/http_route.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "http_router" 4 | 5 | require_relative "callback" 6 | require_relative "http_callback" 7 | 8 | module Lita 9 | # Handlers use this class to define HTTP routes for the built-in web 10 | # server. 11 | class HTTPRoute 12 | # An +HttpRouter::Route+ class used for dispatch. 13 | # @since 3.0.0 14 | ExtendedRoute = Class.new(HttpRouter::Route) do 15 | include HttpRouter::RouteHelper 16 | include HttpRouter::GenerationHelper 17 | end 18 | 19 | # The handler registering the route. 20 | # @return [Handler] The handler. 21 | attr_reader :handler_class 22 | 23 | # @param handler_class [Handler] The handler registering the route. 24 | def initialize(handler_class) 25 | @handler_class = handler_class 26 | end 27 | 28 | class << self 29 | private 30 | 31 | # @!macro define_http_method 32 | # @overload $1(path, method_name, options = {}) 33 | # Defines a new route with the "$1" HTTP method. 34 | # @param path [String] The URL path component that will trigger the route. 35 | # @param method_name [Symbol, String] The name of the instance method in 36 | # the handler to call for the route. 37 | # @param options [Hash] Various options for controlling the behavior of the route. 38 | # @return [void] 39 | # @overload $1(path, options = {}) 40 | # Defines a new route with the "$1" HTTP method. 41 | # @param path [String] The URL path component that will trigger the route. 42 | # @param options [Hash] Various options for controlling the behavior of the route. 43 | # @yield The body of the route's callback. 44 | # @return [void] 45 | # @since 4.0.0 46 | def define_http_method(http_method) 47 | define_method(http_method) do |path, method_name = nil, options = {}, &block| 48 | register_route(http_method.to_s.upcase, path, Callback.new(method_name || block), options) 49 | end 50 | end 51 | end 52 | 53 | define_http_method :head 54 | define_http_method :get 55 | define_http_method :post 56 | define_http_method :put 57 | define_http_method :patch 58 | define_http_method :delete 59 | define_http_method :options 60 | define_http_method :link 61 | define_http_method :unlink 62 | 63 | private 64 | 65 | # Adds a new HTTP route for the handler. 66 | def register_route(http_method, path, callback, options) 67 | route = new_route(http_method, path, callback, options) 68 | route.to(HTTPCallback.new(handler_class, callback)) 69 | handler_class.http_routes << route 70 | end 71 | 72 | # Creates and configures a new HTTP route. 73 | def new_route(http_method, path, callback, options) 74 | route = ExtendedRoute.new 75 | route.path = path 76 | route.name = callback.method_name 77 | route.add_match_with(options) 78 | route.add_request_method(http_method) 79 | route.add_request_method("HEAD") if http_method == "GET" 80 | route 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/lita/logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | 5 | require_relative "default_configuration" 6 | 7 | module Lita 8 | # Creates a Logger with the proper configuration. 9 | # @api private 10 | module Logger 11 | class << self 12 | # Creates a new {::Logger} outputting to standard error with the given 13 | # severity level and a custom format. 14 | # @param level [Symbol, String] The name of the log level to use. 15 | # @param formatter [Proc] A proc to produce a custom log message format. 16 | # @param io [String, IO] Where to write the logs. When this value is a +String+, logs will be 17 | # written to the named file. When this value is an +IO+, logs will be written to the +IO+. 18 | # @return [::Logger] The {::Logger} object. 19 | def get_logger(level, formatter: DefaultConfiguration::DEFAULT_LOG_FORMATTER, io: $stderr) 20 | logger = ::Logger.new(io) 21 | logger.progname = "lita" 22 | logger.level = get_level_constant(level) 23 | logger.formatter = formatter if formatter 24 | logger 25 | end 26 | 27 | private 28 | 29 | # Gets the Logger constant for the given severity level. 30 | def get_level_constant(level) 31 | if level 32 | begin 33 | ::Logger.const_get(level.to_s.upcase) 34 | rescue NameError 35 | ::Logger::INFO 36 | end 37 | else 38 | ::Logger::INFO 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/lita/message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | require "shellwords" 5 | 6 | module Lita 7 | # Represents an incoming chat message. 8 | class Message 9 | extend Forwardable 10 | 11 | # The body of the message. 12 | # @return [String] The message body. 13 | attr_reader :body 14 | 15 | # The source of the message, which is a user and optional room. 16 | # @return [Source] The message source. 17 | attr_reader :source 18 | 19 | # A hash of arbitrary data that can be populated by Lita adapters and extensions. 20 | # @return [Hash] The extension data. 21 | # @since 4.7.0 22 | attr_reader :extensions 23 | 24 | # @!method user 25 | # The user who sent the message. 26 | # @return [User] The user. 27 | # @see Source#user 28 | # @!method room_object 29 | # The room where the message came from. 30 | # @return [Room] The room. 31 | # @see Source#room_object 32 | # @since 4.5.0 33 | # @!method private_message? 34 | # Flag indicating that the message was sent to the robot privately. 35 | # @return [Boolean] The boolean flag. 36 | # @see Source#private_message? 37 | # @since 4.5.0 38 | def_delegators :source, :user, :room_object, :private_message? 39 | 40 | # @param robot [Robot] The currently running robot. 41 | # @param body [String] The body of the message. 42 | # @param source [Source] The source of the message. 43 | def initialize(robot, body, source) 44 | @robot = robot 45 | @body = body 46 | @source = source 47 | @extensions = {} 48 | 49 | name_pattern = "@?#{Regexp.escape(@robot.mention_name)}[:,]?\\s+" 50 | alias_pattern = "#{Regexp.escape(@robot.alias)}\\s*" if @robot.alias 51 | command_regex = if alias_pattern 52 | /\A\s*(?:#{name_pattern}|#{alias_pattern})/i 53 | else 54 | /\A\s*#{name_pattern}/i 55 | end 56 | 57 | @body = @body.sub(command_regex) do 58 | @command = true 59 | 60 | "" 61 | end 62 | end 63 | 64 | # An array of arguments created by shellsplitting the message body, as if 65 | # it were a shell command. 66 | # @return [Array] The array of arguments. 67 | def args 68 | begin 69 | _command, *args = body.shellsplit 70 | rescue ArgumentError 71 | _command, *args = 72 | body.split(/\s+/).map(&:shellescape).join(" ").shellsplit 73 | end 74 | 75 | args 76 | end 77 | 78 | # Marks the message as a command, meaning it was directed at the robot 79 | # specifically. 80 | # @return [void] 81 | def command! 82 | @command = true 83 | end 84 | 85 | # A boolean representing whether or not the message was a command. 86 | # @return [Boolean] +true+ if the message was a command, +false+ if not. 87 | def command? 88 | @command 89 | end 90 | 91 | # An array of matches against the message body for the given {::Regexp}. 92 | # @param pattern [Regexp] A pattern to match. 93 | # @return [Array, Array>] An array of matches. 94 | def match(pattern) 95 | body.scan(pattern) 96 | end 97 | 98 | # Replies by sending the given strings back to the source of the message. 99 | # @param strings [String, Array] The strings to send back. 100 | # @return [void] 101 | def reply(*strings) 102 | @robot.send_messages(source, *strings) 103 | end 104 | 105 | # Replies by sending the given strings back to the user who sent the 106 | # message directly, even if the message was sent in a room. 107 | # @param strings [String, Array] The strings to send back. 108 | # @return [void] 109 | def reply_privately(*strings) 110 | private_source = source.clone 111 | private_source.private_message! 112 | @robot.send_messages(private_source, *strings) 113 | end 114 | 115 | # Replies by sending the given strings back to the source of the message. 116 | # Each message is prefixed with the user's mention name. 117 | # @param strings [String, Array] The strings to send back. 118 | # @return [void] 119 | # @since 3.1.0 120 | def reply_with_mention(*strings) 121 | @robot.send_messages_with_mention(source, *strings) 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/lita/middleware_registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Lita 6 | # Stores Rack middleware for later use in a +Rack::Builder+. 7 | # @since 4.0.2 8 | # @api private 9 | class MiddlewareRegistry 10 | # A Rack middleware and its initialization arguments. 11 | MiddlewareWrapper = Struct.new(:middleware, :args, :block) 12 | 13 | extend Forwardable 14 | 15 | def_delegators :@registry, :each, :empty? 16 | 17 | def initialize 18 | @registry = [] 19 | end 20 | 21 | # Adds a Rack middleware with no initialization arguments. 22 | # @param middleware [#call] A Rack middleware. 23 | # @return [void] 24 | def push(middleware) 25 | @registry << MiddlewareWrapper.new(middleware, [], nil) 26 | end 27 | alias << push 28 | 29 | # Adds a Rack middleware with initialization argumens. Uses the same interface as 30 | # +Rack::Builder#use+. 31 | # @param middleware [#call] A Rack middleware. 32 | # @param args [Array] Arbitrary initialization arguments for the middleware. 33 | # @yield An optional block to be passed to the constructor of the middleware. 34 | # @return [void] 35 | def use(middleware, *args, &block) 36 | @registry << MiddlewareWrapper.new(middleware, args, block) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/lita/namespace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "i18n" 4 | 5 | require_relative "util" 6 | 7 | module Lita 8 | # A mixin for setting and getting a plugin's namespace. 9 | # @since 4.0.0 10 | module Namespace 11 | # Gets (and optionally sets) the namespace for a plugin. The namespace is generated from the 12 | # class's name by default. 13 | # @param value [String] If provided, sets the namespace of the plugin to the value. 14 | # @return [String] The namespace. 15 | # @raise [RuntimeError] If the plugin is an anonymous class, does not define +self.name+, and 16 | # has not set a namespace manually. 17 | def namespace(value = nil) 18 | @namespace = value.to_s if value 19 | 20 | string_name = defined?(@namespace) ? @namespace : name 21 | 22 | if string_name 23 | Util.underscore(string_name.split("::").last) 24 | else 25 | raise I18n.t("lita.plugin.name_required") 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/lita/plugin_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "adapter" 4 | require_relative "handler" 5 | 6 | module Lita 7 | # Constructs a Lita plugin from a block. 8 | # @since 4.0.0 9 | # @api private 10 | class PluginBuilder 11 | # @param namespace [String, Symbol] The Redis namespace to use for the plugin. 12 | # @yield The class body of the plugin. 13 | def initialize(namespace, &block) 14 | @namespace = namespace.to_s 15 | @block = block 16 | end 17 | 18 | # Constructs an {Adapter} from the provided block. 19 | # @return [Adapter] 20 | def build_adapter 21 | adapter = create_plugin(Adapter) 22 | adapter.class_exec(&@block) 23 | adapter 24 | end 25 | 26 | # Constructs a {Handler} from the provided block. 27 | # @return [Handler] 28 | def build_handler 29 | handler = create_plugin(Handler) 30 | handler.class_exec(&@block) 31 | handler 32 | end 33 | 34 | private 35 | 36 | # Creates a class of the relevant plugin type and sets its namespace. 37 | def create_plugin(plugin_type) 38 | plugin = Class.new(plugin_type) 39 | plugin.namespace(@namespace) 40 | plugin 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/lita/rack_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cgi" 4 | require "uri" 5 | 6 | require "http_router" 7 | require "rack" 8 | 9 | # The http_router gem still uses URI.escape which has been removed from Ruby 3.0. 10 | unless URI.respond_to?(:escape) 11 | def URI.escape(*args) 12 | CGI.escape(*args) 13 | end 14 | end 15 | 16 | # The http_router gem still uses URI.unescape which has been removed from Ruby 3.0. 17 | unless URI.respond_to?(:unescape) 18 | def URI.unescape(*args) 19 | CGI.unescape(*args) 20 | end 21 | end 22 | 23 | module Lita 24 | # A +Rack+ application to serve HTTP routes registered by handlers. 25 | # @api private 26 | class RackApp 27 | # The currently running robot. 28 | # @return [Robot] The robot. 29 | attr_reader :robot 30 | 31 | # An +HttpRouter+ used for dispatch. 32 | # @return [HttpRouter] The router. 33 | attr_reader :router 34 | 35 | # Constructs a {RackApp} inside a +Rack::Builder+, including any configured middleware. 36 | # @param robot [Robot] The currently running robot. 37 | # @return [RackApp, Class] The Rack application. 38 | def self.build(robot) 39 | builder = Rack::Builder.new 40 | builder.run(new(robot)) 41 | 42 | robot.config.http.middleware.each do |wrapper| 43 | if wrapper.block 44 | builder.use(wrapper.middleware, *wrapper.args, &wrapper.block) 45 | else 46 | builder.use(wrapper.middleware, *wrapper.args) 47 | end 48 | end 49 | 50 | builder.to_app 51 | end 52 | 53 | # @param robot [Robot] The currently running robot. 54 | def initialize(robot) 55 | @robot = robot 56 | @router = HttpRouter.new 57 | compile 58 | end 59 | 60 | # Entry point for Lita's HTTP routes. Invokes the Rack application. 61 | # @param env [Hash] A Rack environment. 62 | # @return [void] 63 | def call(env) 64 | env["lita.robot"] = robot 65 | router.call(env) 66 | end 67 | 68 | # Overrides the default inspect implementation to make output less verbose and more readable. 69 | def inspect 70 | hex_address = (object_id << 1).to_s(16).rjust(14, "0") 71 | "#" 72 | end 73 | 74 | # Finds the first route that matches the request environment, if any. Does not trigger the 75 | # route. 76 | # @param env [Hash] A Rack environment. 77 | # @return [Array] An array of the name of the first matching route. 78 | # @since 4.0.0 79 | def recognize(env) 80 | env["lita.robot"] = robot 81 | recognized_routes_for(env).map { |match| match.route.name } 82 | end 83 | 84 | private 85 | 86 | # Registers routes in the router for each handler's defined routes. 87 | def compile 88 | robot.handlers.each do |handler| 89 | next unless handler.respond_to?(:http_routes) 90 | 91 | handler.http_routes.each { |route| router.add_route(route) } 92 | end 93 | end 94 | 95 | # Returns an array containing the first recongnized route, if any. 96 | def recognized_routes_for(env) 97 | Array(router.recognize(env).first) 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/lita/registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "set" 4 | 5 | require "i18n" 6 | require "redis-namespace" 7 | 8 | require_relative "../lita" 9 | require_relative "default_configuration" 10 | require_relative "plugin_builder" 11 | 12 | module Lita 13 | # An object to hold various types of data including configuration and plugins. 14 | # @since 4.0.0 15 | class Registry 16 | # Allows a registry to be added to another object. 17 | module Mixins 18 | # The primary configuration object. Provides user settings for the robot. 19 | # @return [Configuration] The configuration object. 20 | def config 21 | @config ||= DefaultConfiguration.new(self).build 22 | end 23 | 24 | # Yields the configuration object. Called by the user in a +lita_config.rb+ file. 25 | # @yieldparam [Configuration] config The configuration object. 26 | # @return [void] 27 | def configure 28 | yield config 29 | end 30 | 31 | # A registry of adapters. 32 | # @return [Hash] A map of adapter keys to adapter classes. 33 | def adapters 34 | @adapters ||= {} 35 | end 36 | 37 | # A registry of handlers. 38 | # @return [Set] The set of handlers. 39 | def handlers 40 | @handlers ||= Set.new 41 | end 42 | 43 | # A registry of hook handler objects. 44 | # @return [Hash] A hash mapping hook names to sets of objects that handle them. 45 | # @since 3.2.0 46 | def hooks 47 | @hooks ||= Hash.new { |h, k| h[k] = Set.new } 48 | end 49 | 50 | # The root Redis object. 51 | # @return [Redis::Namespace] The root Redis object. 52 | def redis 53 | @redis ||= begin 54 | redis = Redis.new(config.redis) 55 | Redis::Namespace.new(config.robot.redis_namespace, redis: redis).tap(&:ping) 56 | end 57 | rescue Redis::BaseError => e 58 | if Lita.test_mode? 59 | raise RedisError, I18n.t("lita.redis.test_mode_exception", message: e.message) 60 | else 61 | Lita.logger.fatal I18n.t( 62 | "lita.redis.exception", 63 | message: e.message, 64 | backtrace: e.backtrace.join("\n") 65 | ) 66 | exit(false) 67 | end 68 | end 69 | 70 | # @overload register_adapter(key, adapter) 71 | # Adds an adapter to the registry under the provided key. 72 | # @param key [String, Symbol] The key that identifies the adapter. 73 | # @param adapter [Class] The adapter class. 74 | # @return [void] 75 | # @overload register_adapter(key) 76 | # Adds an adapter to the registry under the provided key. 77 | # @param key [String, Symbol] The key that identifies the adapter. 78 | # @yield The body of the adapter class. 79 | # @return [void] 80 | # @since 4.0.0 81 | def register_adapter(key, adapter = nil, &block) 82 | adapter = PluginBuilder.new(key, &block).build_adapter if block 83 | 84 | unless adapter.is_a?(Class) 85 | raise ArgumentError, I18n.t("lita.core.register_adapter.block_or_class_required") 86 | end 87 | 88 | adapters[key.to_sym] = adapter 89 | end 90 | 91 | # @overload register_handler(handler) 92 | # Adds a handler to the registry. 93 | # @param handler [Handler] The handler class. 94 | # @return [void] 95 | # @overload register_handler(key) 96 | # Adds a handler to the registry. 97 | # @param key [String] The namespace of the handler. 98 | # @yield The body of the handler class. 99 | # @return [void] 100 | # @since 4.0.0 101 | def register_handler(handler_or_key, &block) 102 | if block 103 | handler = PluginBuilder.new(handler_or_key, &block).build_handler 104 | else 105 | handler = handler_or_key 106 | 107 | unless handler.is_a?(Class) 108 | raise ArgumentError, I18n.t("lita.core.register_handler.block_or_class_required") 109 | end 110 | end 111 | 112 | handlers << handler 113 | end 114 | 115 | # Adds a hook handler object to the registry for the given hook. 116 | # @return [void] 117 | # @since 3.2.0 118 | def register_hook(name, hook) 119 | hooks[name.to_s.downcase.strip.to_sym] << hook 120 | end 121 | 122 | # Clears the configuration object and the adapter, handler, and hook registries. 123 | # @return [void] 124 | # @since 3.2.0 125 | def reset 126 | reset_adapters 127 | reset_config 128 | reset_handlers 129 | reset_hooks 130 | end 131 | 132 | # Resets the adapter registry, removing all registered adapters. 133 | # @return [void] 134 | # @since 3.2.0 135 | def reset_adapters 136 | @adapters = nil 137 | end 138 | 139 | # Resets the configuration object. The next call to {#config} 140 | # will create a fresh config object. 141 | # @return [void] 142 | def reset_config 143 | @config = nil 144 | end 145 | alias clear_config reset_config 146 | 147 | # Resets the handler registry, removing all registered handlers. 148 | # @return [void] 149 | # @since 3.2.0 150 | def reset_handlers 151 | @handlers = nil 152 | end 153 | 154 | # Resets the hooks registry, removing all registered hook handlers. 155 | # @return [void] 156 | # @since 3.2.0 157 | def reset_hooks 158 | @hooks = nil 159 | end 160 | end 161 | 162 | include Mixins 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/lita/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Lita 6 | # A wrapper object that provides the primary interface for handlers to 7 | # respond to incoming chat messages. 8 | class Response 9 | extend Forwardable 10 | 11 | # The incoming message. 12 | # @return [Message] The message. 13 | attr_accessor :message 14 | 15 | # A hash of arbitrary data that can be populated by Lita extensions. 16 | # @return [Hash] The extensions data. 17 | # @since 3.2.0 18 | attr_accessor :extensions 19 | 20 | # The pattern the incoming message matched. 21 | # @return [Regexp] The pattern. 22 | attr_accessor :pattern 23 | 24 | # @!method args 25 | # @see Message#args 26 | # @!method reply(*strings) 27 | # @see Message#reply 28 | # @!method reply_privately(*strings) 29 | # @see Message#reply_privately 30 | # @!method reply_with_mention(*strings) 31 | # @see Message#reply_with_mention 32 | # @!method user 33 | # @see Message#user 34 | # @!method private_message? 35 | # @see Message#private_message? 36 | # @since 4.5.0 37 | def_delegators :message, :args, :reply, :reply_privately, 38 | :reply_with_mention, :user, :private_message?, :command? 39 | 40 | # @!method room 41 | # @see Message#room_object 42 | # @since 4.5.0 43 | def_delegator :message, :room_object, :room 44 | 45 | # @param message [Message] The incoming message. 46 | # @param pattern [Regexp] The pattern the incoming message matched. 47 | def initialize(message, pattern) 48 | self.message = message 49 | self.extensions = {} 50 | self.pattern = pattern 51 | end 52 | 53 | # An array of matches from scanning the message against the route pattern. 54 | # @return [Array, Array>] The array of matches. 55 | def matches 56 | @matches ||= message.match(pattern) 57 | end 58 | 59 | # A +MatchData+ object from running the pattern against the message body. 60 | # @return [MatchData] The +MatchData+. 61 | def match_data 62 | @match_data ||= pattern.match(message.body) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/lita/room.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "redis-namespace" 4 | 5 | require_relative "util" 6 | 7 | module Lita 8 | # A room in the chat service. Persisted in Redis. 9 | # @since 4.4.0 10 | class Room 11 | class << self 12 | # Creates a new room with the given ID, or merges and saves supplied 13 | # metadata to a room with the given ID. 14 | # @param id [Integer, String] A unique identifier for the room. 15 | # @param metadata [Hash] An optional hash of metadata about the room. 16 | # @option metadata [String] name (id) The display name of the room. 17 | # @return [Room] The room. 18 | def create_or_update(id, metadata = {}) 19 | existing_room = find_by_id(id) 20 | metadata = Util.stringify_keys(metadata) 21 | metadata = existing_room.metadata.merge(metadata) if existing_room 22 | room = new(id, metadata) 23 | room.save 24 | room 25 | end 26 | 27 | # Finds a room by ID. 28 | # @param id [Integer, String] The room's unique ID. 29 | # @return [Room, nil] The room or +nil+ if no such room is known. 30 | def find_by_id(id) 31 | metadata = redis.hgetall("id:#{id}") 32 | new(id, metadata) if metadata.key?("name") 33 | end 34 | 35 | # Finds a room by display name. 36 | # @param name [String] The room's name. 37 | # @return [Room, nil] The room or +nil+ if no such room is known. 38 | def find_by_name(name) 39 | id = redis.get("name:#{name}") 40 | find_by_id(id) if id 41 | end 42 | 43 | # Finds a room by ID or name 44 | # @param identifier [Integer, String] The room's ID or name. 45 | # @return [Room, nil] The room or +nil+ if no room was found. 46 | def fuzzy_find(identifier) 47 | find_by_id(identifier) || find_by_name(identifier) 48 | end 49 | 50 | # The +Redis::Namespace+ for room persistence. 51 | # @return [Redis::Namespace] The Redis connection. 52 | def redis 53 | @redis ||= Redis::Namespace.new("rooms", redis: Lita.redis) 54 | end 55 | end 56 | 57 | # The room's unique ID. 58 | # @return [String] The room's ID. 59 | attr_reader :id 60 | 61 | # A hash of arbitrary metadata about the room. 62 | # @return [Hash] The room's metadata. 63 | attr_reader :metadata 64 | 65 | # The room's name as displayed in a standard user interface. 66 | # @return [String] The room's name. 67 | attr_reader :name 68 | 69 | # @param id [Integer, String] The room's unique ID. 70 | # @param metadata [Hash] Arbitrary room metadata. 71 | # @option metadata [String] name (id) The room's display name. 72 | def initialize(id, metadata = {}) 73 | @id = id.to_s 74 | @metadata = Util.stringify_keys(metadata) 75 | @name = @metadata["name"] || @id 76 | end 77 | 78 | # Compares the room against another room object to determine equality. Rooms 79 | # are considered equal if they have the same ID. 80 | # @param other [Room] The room to compare against. 81 | # @return [Boolean] True if rooms are equal, false otherwise. 82 | def ==(other) 83 | other.respond_to?(:id) && id == other.id 84 | end 85 | alias eql? == 86 | 87 | # Generates a +Fixnum+ hash value for this user object. Implemented to support equality. 88 | # @return [Fixnum] The hash value. 89 | # @see Object#hash 90 | def hash 91 | id.hash 92 | end 93 | 94 | # Saves the room record to Redis, overwriting any previous data for the current ID. 95 | # @return [void] 96 | def save 97 | ensure_name_metadata_set 98 | 99 | redis.pipelined do 100 | redis.hmset("id:#{id}", *metadata.to_a.flatten) 101 | redis.set("name:#{name}", id) 102 | end 103 | end 104 | 105 | private 106 | 107 | # Ensure the room's metadata contains its name, to ensure their Redis hash contains at least 108 | # one value. It's not possible to store an empty hash key in Redis. 109 | def ensure_name_metadata_set 110 | room_name = metadata.delete("name") 111 | metadata["name"] = room_name || id 112 | end 113 | 114 | # The Redis connection for room persistence. 115 | def redis 116 | self.class.redis 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/lita/route_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | # Determines if an incoming message should trigger a route. 5 | # @api private 6 | class RouteValidator 7 | # The handler class the route belongs to. 8 | attr_reader :handler 9 | 10 | # The incoming message. 11 | attr_reader :message 12 | 13 | # The currently running robot. 14 | attr_reader :robot 15 | 16 | # The route being checked. 17 | attr_reader :route 18 | 19 | # @param handler [Handler] The handler the route belongs to. 20 | # @param route [Handler::ChatRouter::Route] The route being validated. 21 | # @param message [Message] The incoming message. 22 | # @param robot [Robot] The currently running robot. 23 | def initialize(handler, route, message, robot) 24 | @handler = handler 25 | @route = route 26 | @message = message 27 | @robot = robot 28 | end 29 | 30 | # Returns a boolean indicating whether or not the route should be triggered. 31 | # @return [Boolean] Whether or not the route should be triggered. 32 | def call 33 | return unless command_satisfied?(route, message) 34 | return if from_self?(message, robot) 35 | return unless matches_pattern?(route, message) 36 | 37 | unless authorized?(robot, message.user, route.required_groups) 38 | robot.trigger( 39 | :route_authorization_failed, 40 | message: message, 41 | robot: robot, 42 | route: route, 43 | ) 44 | return 45 | end 46 | return unless passes_route_hooks?(route, message, robot) 47 | 48 | true 49 | end 50 | 51 | private 52 | 53 | # Message must be a command if the route requires a command 54 | def command_satisfied?(route, message) 55 | !route.command? || message.command? 56 | end 57 | 58 | # Messages from self should be ignored to prevent infinite loops 59 | def from_self?(message, robot) 60 | message.user.name == robot.name 61 | end 62 | 63 | # Message must match the pattern 64 | def matches_pattern?(route, message) 65 | route.pattern.match?(message.body) 66 | end 67 | 68 | # Allow custom route hooks to reject the route 69 | def passes_route_hooks?(route, message, robot) 70 | robot.hooks[:validate_route].all? do |hook| 71 | hook.call(handler: handler, route: route, message: message, robot: robot) 72 | end 73 | end 74 | 75 | # User must be in auth group if route is restricted. 76 | def authorized?(robot, user, required_groups) 77 | required_groups.nil? || required_groups.any? do |group| 78 | robot.auth.user_in_group?(user, group) 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/lita/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require "rspec" 5 | require "rspec/expectations" 6 | require "rspec/mocks" 7 | rescue LoadError 8 | abort I18n.t("lita.rspec.full_suite_required") 9 | end 10 | 11 | major, *_unused = RSpec::Core::Version::STRING.split(/\./) 12 | abort I18n.t("lita.rspec.version_3_required") if major.to_i < 3 13 | 14 | require_relative "../lita" 15 | require_relative "rspec/handler" 16 | 17 | module Lita 18 | # Extras for +RSpec+ that facilitate the testing of Lita code. 19 | module RSpec 20 | class << self 21 | # Causes all interaction with Redis to use a test-specific namespace. 22 | # Clears Redis before each example. Stubs the logger to prevent log 23 | # messages from cluttering test output. Clears Lita's global 24 | # configuration. 25 | # @param base [Object] The class including the module. 26 | # @return [void] 27 | def included(base) 28 | base.class_eval do 29 | let(:registry) { Registry.new } 30 | 31 | before do 32 | logger = double("Logger").as_null_object 33 | allow(Lita).to receive(:logger).and_return(logger) 34 | keys = Lita.redis.keys("*") 35 | Lita.redis.del(keys) unless keys.empty? 36 | end 37 | end 38 | end 39 | end 40 | end 41 | end 42 | 43 | Lita.test_mode = true 44 | 45 | RSpec.configure do |config| 46 | config.include Lita::RSpec, lita: true 47 | config.include Lita::RSpec::Handler, lita_handler: true 48 | end 49 | -------------------------------------------------------------------------------- /lib/lita/rspec/handler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "set" 4 | 5 | require "i18n" 6 | require "faraday" 7 | 8 | require_relative "../adapters/test" 9 | require_relative "../message" 10 | require_relative "../rspec" 11 | require_relative "../robot" 12 | require_relative "../source" 13 | require_relative "../user" 14 | require_relative "matchers/chat_route_matcher" 15 | require_relative "matchers/http_route_matcher" 16 | require_relative "matchers/event_route_matcher" 17 | 18 | module Lita 19 | module RSpec 20 | # Extras for +RSpec+ to facilitate testing Lita handlers. 21 | module Handler 22 | include Matchers::ChatRouteMatcher 23 | include Matchers::HTTPRouteMatcher 24 | include Matchers::EventRouteMatcher 25 | 26 | class << self 27 | # Sets up the RSpec environment to easily test Lita handlers. 28 | def included(base) 29 | base.include(Lita::RSpec) 30 | 31 | prepare_handlers(base) 32 | prepare_adapter(base) 33 | prepare_let_blocks(base) 34 | prepare_subject(base) 35 | end 36 | 37 | private 38 | 39 | # Register the test adapter. 40 | def prepare_adapter(base) 41 | base.class_eval do 42 | before do 43 | registry.register_adapter(:test, Lita::Adapters::Test) 44 | registry.config.robot.adapter = :test 45 | end 46 | end 47 | end 48 | 49 | # Register the handler(s) under test. 50 | def prepare_handlers(base) 51 | base.class_eval do 52 | before do 53 | handlers = Set.new( 54 | [described_class] + Array(base.metadata[:additional_lita_handlers]) 55 | ) 56 | 57 | handlers.each do |handler| 58 | registry.register_handler(handler) 59 | end 60 | end 61 | end 62 | end 63 | 64 | # Create common test objects. 65 | def prepare_let_blocks(base) 66 | base.class_eval do 67 | let(:robot) { Robot.new(registry) } 68 | let(:source) { Source.new(user: user) } 69 | let(:user) { User.create("1", name: "Test User") } 70 | end 71 | end 72 | 73 | # Set up a working test subject. 74 | def prepare_subject(base) 75 | base.class_eval do 76 | subject { described_class.new(robot) } 77 | end 78 | end 79 | end 80 | 81 | # An array of strings that have been sent by the robot during the course of a test. 82 | # @return [Array] The replies. 83 | def replies 84 | robot.chat_service.sent_messages 85 | end 86 | 87 | # Sends a message to the robot. 88 | # @param body [String] The message to send. 89 | # @param as [User] The user sending the message. 90 | # @param from [Room] The room where the message is received from. 91 | # @return [void] 92 | def send_message(body, as: user, from: nil, privately: false) 93 | message = Message.new( 94 | robot, 95 | body, 96 | Source.new(user: as, room: from, private_message: privately) 97 | ) 98 | 99 | robot.receive(message) 100 | end 101 | 102 | # Sends a "command" message to the robot. 103 | # @param body [String] The message to send. 104 | # @param as [User] The user sending the message. 105 | # @param from [Room] The room where the message is received from. 106 | # @return [void] 107 | def send_command(body, as: user, from: nil, privately: false) 108 | send_message("#{robot.mention_name}: #{body}", as: as, from: from, privately: privately) 109 | end 110 | 111 | # Returns a Faraday connection hooked up to the currently running robot's Rack app. 112 | # @return [Faraday::Connection] The connection. 113 | # @since 4.0.0 114 | def http 115 | unless Rack.const_defined?(:Test) 116 | begin 117 | require "rack/test" 118 | rescue LoadError 119 | raise LoadError, I18n.t("lita.rspec.rack_test_required") 120 | end 121 | end 122 | 123 | Faraday::Connection.new { |c| c.adapter(:rack, robot.app) } 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/lita/rspec/matchers/chat_route_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../message" 4 | require_relative "../../route_validator" 5 | 6 | module Lita 7 | module RSpec 8 | module Matchers 9 | # RSpec matchers for chat routes. 10 | # @since 4.0.0 11 | module ChatRouteMatcher 12 | extend ::RSpec::Matchers::DSL 13 | 14 | matcher :route do |message_body| 15 | match do 16 | message = Message.new(robot, message_body, source) 17 | 18 | if defined?(@group) && @group.to_s.casecmp("admins").zero? 19 | robot.config.robot.admins = Array(robot.config.robot.admins) + [source.user.id] 20 | elsif defined?(@group) 21 | robot.auth.add_user_to_group!(source.user, @group) 22 | end 23 | 24 | matching_routes = described_class.routes.select do |route| 25 | RouteValidator.new(described_class, route, message, robot).call 26 | end 27 | 28 | if defined?(@method_name) 29 | matching_routes.any? { |route| route.callback.method_name == @method_name } 30 | else 31 | !matching_routes.empty? 32 | end 33 | end 34 | 35 | chain :with_authorization_for do |group| 36 | @group = group 37 | end 38 | 39 | chain :to do |method_name| 40 | @method_name = method_name 41 | end 42 | end 43 | 44 | # Sets an expectation that the provided message routes to a command. 45 | # @param message_body [String] The body of the message. 46 | # @return [void] 47 | def route_command(message_body) 48 | route("#{robot.mention_name} #{message_body}") 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/lita/rspec/matchers/event_route_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | module RSpec 5 | module Matchers 6 | # RSpec matchers for event routes. 7 | # @since 4.0.0 8 | module EventRouteMatcher 9 | extend ::RSpec::Matchers::DSL 10 | 11 | matcher :route_event do |event_name| 12 | match do 13 | callbacks = described_class.event_subscriptions_for(event_name) 14 | 15 | if defined?(@method_name) 16 | callbacks.any? { |callback| callback.method_name.equal?(@method_name) } 17 | else 18 | !callbacks.empty? 19 | end 20 | end 21 | 22 | chain :to do |method_name| 23 | @method_name = method_name 24 | end 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/lita/rspec/matchers/http_route_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack" 4 | 5 | module Lita 6 | module RSpec 7 | # A namespace to hold all of Lita's RSpec matchers. 8 | module Matchers 9 | # RSpec matchers for HTTP routes. 10 | # @since 4.0.0 11 | module HTTPRouteMatcher 12 | extend ::RSpec::Matchers::DSL 13 | 14 | matcher :route_http do |http_method, path| 15 | match do 16 | env = Rack::MockRequest.env_for(path, method: http_method) 17 | 18 | matching_routes = robot.app.recognize(env) 19 | 20 | if defined?(@method_name) 21 | matching_routes.include?(@method_name) 22 | else 23 | !matching_routes.empty? 24 | end 25 | end 26 | 27 | chain :to do |method_name| 28 | @method_name = method_name 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/lita/source.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "i18n" 4 | 5 | require_relative "room" 6 | 7 | module Lita 8 | # A wrapper object representing the source of an incoming message (either the 9 | # user who sent it, the room they sent it from, or both). If a room is set, 10 | # the message is from a group chat room. If no room is set, the message is 11 | # assumed to be a private message, though Source objects can be explicitly 12 | # marked as private messages. Source objects are also used as "target" objects 13 | # when sending an outgoing message or performing another operation on a user 14 | # or a room. 15 | class Source 16 | # A flag indicating that a message was sent to the robot privately. 17 | # @return [Boolean] The boolean flag. 18 | attr_reader :private_message 19 | alias private_message? private_message 20 | 21 | # The room the message came from or should be sent to, as a string. 22 | # @return [String, NilClass] A string uniquely identifying the room. 23 | attr_reader :room 24 | 25 | # The room the message came from or should be sent to, as a {Room} object. 26 | # @return [Room, NilClass] The room. 27 | # @since 4.4.0 28 | attr_reader :room_object 29 | 30 | # The user who sent the message or should receive the outgoing message. 31 | # @return [User, NilClass] The user. 32 | attr_reader :user 33 | 34 | # @param user [User] The user who sent the message or should receive 35 | # the outgoing message. 36 | # @param room [Room, String] A string or {Room} uniquely identifying the room 37 | # the user sent the message from, or the room where a reply should go. The format of this 38 | # string (or the ID of the {Room} object) will differ depending on the chat service. 39 | # @param private_message [Boolean] A flag indicating whether or not the 40 | # message was sent privately. 41 | def initialize(user: nil, room: nil, private_message: false) 42 | @user = user 43 | 44 | case room 45 | when String 46 | @room = room 47 | @room_object = load_with_metadata(room) 48 | when Room 49 | @room = room.id 50 | @room_object = room 51 | end 52 | 53 | @private_message = private_message 54 | 55 | raise ArgumentError, I18n.t("lita.source.user_or_room_required") if user.nil? && room.nil? 56 | 57 | @private_message = true if room.nil? 58 | end 59 | 60 | # Destructively marks the source as a private message, meaning an incoming 61 | # message was sent to the robot privately, or an outgoing message should be 62 | # sent to a user privately. 63 | # @return [void] 64 | def private_message! 65 | @private_message = true 66 | end 67 | 68 | private 69 | 70 | def load_with_metadata(room_id) 71 | Room.find_by_id(room_id) || Room.new(room_id) 72 | end 73 | end 74 | 75 | # An alias for {Source}. Since source objects are used for both incoming and outgoing messages, it 76 | # might be helpful to think of an outgoing messsage's source as a "target" rather than a source. 77 | # 78 | # @see Source 79 | # @since 5.0.0 80 | Target = Source 81 | end 82 | -------------------------------------------------------------------------------- /lib/lita/store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | # A simple, in-memory, thread-safe key-value store. 5 | # @since 5.0.0 6 | class Store 7 | # @param internal_store [Hash] A hash-like object to use internally to store data. 8 | def initialize(internal_store = {}) 9 | @store = internal_store 10 | @lock = Mutex.new 11 | end 12 | 13 | # Get a key from the store. 14 | def [](key) 15 | @lock.synchronize { @store[key] } 16 | end 17 | 18 | # Set a key to the given value. 19 | def []=(key, value) 20 | @lock.synchronize { @store[key] = value } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/lita/target.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "source" 4 | -------------------------------------------------------------------------------- /lib/lita/template.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "erb" 4 | require "set" 5 | 6 | module Lita 7 | # A simple wrapper around ERB to render text from files or strings. 8 | # @since 4.2.0 9 | class Template 10 | # A clean room object to use as the binding for ERB rendering. 11 | # @api private 12 | class TemplateEvaluationContext 13 | # Returns the evaluation context's binding. 14 | # @return [Binding] The binding. 15 | def __get_binding 16 | binding 17 | end 18 | end 19 | 20 | class << self 21 | # Initializes a new Template with the contents of the file at the given path. 22 | # @param path [String] The path to the file to use as the template content. 23 | # @return Template 24 | def from_file(path) 25 | new(File.read(path).chomp) 26 | end 27 | end 28 | 29 | # @param source [String] A string to use as the template's content. 30 | def initialize(source) 31 | @erb = ERB.new(source, trim_mode: "<>") 32 | self.helpers = Set.new 33 | end 34 | 35 | # Add a module of helpers methods to be added to the template evalutation context. 36 | # @param helper [Module] The module to extend onto the template evalutation context. 37 | # @return [void] 38 | # @since 4.5.0 39 | def add_helper(helper) 40 | helpers << helper 41 | end 42 | 43 | # Render the template with the provided variables. 44 | # @param variables [Hash] A collection of variables for interpolation. Each key-value pair will 45 | # make the value available inside the template as an instance variable with the key as its 46 | # name. 47 | def render(variables = {}) 48 | erb.result(context_binding(variables)) 49 | end 50 | 51 | private 52 | 53 | attr_accessor :helpers 54 | 55 | # Create an empty object to use as the ERB context and set any provided variables in it. 56 | def context_binding(variables) 57 | context = TemplateEvaluationContext.new 58 | 59 | helpers.each { |helper| context.extend(helper) } 60 | 61 | variables.each do |k, v| 62 | context.instance_variable_set("@#{k}", v) 63 | end 64 | 65 | context.__get_binding 66 | end 67 | 68 | # The underlying ERB object. 69 | attr_reader :erb 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/lita/template_resolver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "i18n" 4 | 5 | require_relative "errors" 6 | 7 | module Lita 8 | # Finds the file path of the most appropriate template for the given adapter. 9 | # @api private 10 | # @since 4.2.0 11 | class TemplateResolver 12 | # @param template_root [String] The directory to search for templates. 13 | # @param template_name [String] The name of the template to search for. 14 | # @param adapter_name [String, Symbol] The name of the current adapter. 15 | def initialize(template_root, template_name, adapter_name) 16 | @template_root = template_root 17 | @template_name = template_name 18 | @adapter_name = adapter_name 19 | end 20 | 21 | # Returns the adapter-specific template, falling back to a generic template. 22 | # @return [String] The path of the template to use. 23 | # @raise [MissingTemplateError] If no templates with the given name exist. 24 | def resolve 25 | return adapter_template if File.exist?(adapter_template) 26 | return generic_template if File.exist?(generic_template) 27 | 28 | raise MissingTemplateError, I18n.t("lita.template.missing_template", path: generic_template) 29 | end 30 | 31 | private 32 | 33 | # The directory to search for templates. 34 | attr_reader :template_root 35 | 36 | # The name of the template to search for. 37 | attr_reader :template_name 38 | 39 | # The name of the current adapter. 40 | attr_reader :adapter_name 41 | 42 | # Path to the adapter-specific template. 43 | def adapter_template 44 | @adapter_template ||= File.join(template_root, "#{template_name}.#{adapter_name}.erb") 45 | end 46 | 47 | # Path to the generic template. 48 | def generic_template 49 | @generic_template ||= File.join(template_root, "#{template_name}.erb") 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/lita/timer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | # A timer that executes a block after a certain number of seconds, either once or repeatedly. 5 | # @since 3.0.0 6 | class Timer 7 | # @param interval [Integer] The number of seconds to wait before calling the block. 8 | # @param recurring [Boolean] If true, the timer will fire repeatedly until stopped. 9 | # @yieldparam timer [Timer] The current {Timer} instance. 10 | def initialize(interval: 0, recurring: false, &block) 11 | @interval = interval 12 | @recurring = recurring 13 | @running = false 14 | @block = block 15 | end 16 | 17 | # Starts running the timer. 18 | def start 19 | @running = true 20 | run 21 | end 22 | 23 | # Stops the timer, preventing any further invocations of the block until started again. 24 | def stop 25 | @running = false 26 | end 27 | 28 | private 29 | 30 | # Is this a recurring timer? 31 | def recurring? 32 | @recurring 33 | end 34 | 35 | # Sleep for the given interval, call the block, then run again if it's a recurring timer. 36 | def run 37 | loop do 38 | sleep @interval 39 | @block.call(self) if running? && @block 40 | break unless running? && recurring? 41 | end 42 | end 43 | 44 | # Is the timer currently running? 45 | def running? 46 | @running 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/lita/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "redis-namespace" 4 | 5 | require_relative "util" 6 | 7 | module Lita 8 | # A user in the chat service. Persisted in Redis. 9 | class User 10 | class << self 11 | # The +Redis::Namespace+ for user persistence. 12 | # @return [Redis::Namespace] The Redis connection. 13 | def redis 14 | @redis ||= Redis::Namespace.new("users", redis: Lita.redis) 15 | end 16 | 17 | # Creates a new user with the given ID, or merges and saves supplied 18 | # metadata to an existing user with the given ID. 19 | # @param id [Integer, String] A unique identifier for the user. 20 | # @param metadata [Hash] An optional hash of metadata about the user. 21 | # @option metadata [String] name (id) The display name of the user. 22 | # @return [User] The user. 23 | def create(id, metadata = {}) 24 | existing_user = find_by_id(id) 25 | metadata = Util.stringify_keys(metadata) 26 | metadata = existing_user.metadata.merge(metadata) if existing_user 27 | user = new(id, metadata) 28 | user.save 29 | user 30 | end 31 | 32 | # Finds a user by ID. 33 | # @param id [Integer, String] The user's unique ID. 34 | # @return [User, nil] The user or +nil+ if no such user is known. 35 | def find_by_id(id) 36 | metadata = redis.hgetall("id:#{id}") 37 | new(id, metadata) if metadata.key?("name") 38 | end 39 | 40 | # Finds a user by mention name. 41 | # @param mention_name [String] The user's mention name. 42 | # @return [User, nil] The user or +nil+ if no such user is known. 43 | # @since 3.0.0 44 | def find_by_mention_name(mention_name) 45 | id = redis.get("mention_name:#{mention_name}") 46 | find_by_id(id) if id 47 | end 48 | 49 | # Finds a user by display name. 50 | # @param name [String] The user's name. 51 | # @return [User, nil] The user or +nil+ if no such user is known. 52 | def find_by_name(name) 53 | id = redis.get("name:#{name}") 54 | find_by_id(id) if id 55 | end 56 | 57 | # Attempts to find a user with a name starting with the provided string. 58 | # @param name [String] The first characters in the user's name. 59 | # @return [User, nil] The user, or +nil+ if zero or greater than 1 matches were found. 60 | # @since 3.0.0 61 | def find_by_partial_name(name) 62 | keys = redis.keys("name:#{name}*") 63 | 64 | if keys.length == 1 65 | id = redis.get(keys.first) 66 | find_by_id(id) 67 | end 68 | end 69 | 70 | # Finds a user by ID, mention name, name, or partial name. 71 | # @param identifier [String] The user's ID, name, partial name, or mention name. 72 | # @return [User, nil] The user or +nil+ if no users were found. 73 | # @since 3.0.0 74 | def fuzzy_find(identifier) 75 | find_by_id(identifier) || find_by_mention_name(identifier) || 76 | find_by_name(identifier) || find_by_partial_name(identifier) 77 | end 78 | end 79 | 80 | # The user's unique ID. 81 | # @return [String] The user's ID. 82 | attr_reader :id 83 | 84 | # A hash of arbitrary metadata about the user. 85 | # @return [Hash] The user's metadata. 86 | attr_reader :metadata 87 | 88 | # The user's name as displayed in the chat. 89 | # @return [String] The user's name. 90 | attr_reader :name 91 | 92 | # @param id [Integer, String] The user's unique ID. 93 | # @param metadata [Hash] Arbitrary user metadata. 94 | # @option metadata [String] name (id) The user's display name. 95 | def initialize(id, metadata = {}) 96 | @id = id.to_s 97 | @metadata = Util.stringify_keys(metadata) 98 | @name = @metadata["name"] || @id 99 | ensure_name_metadata_set 100 | end 101 | 102 | # The name used to "mention" the user in a group chat. 103 | # @return [String] The user's mention name. 104 | # @since 3.1.0 105 | def mention_name 106 | metadata["mention_name"] || name 107 | end 108 | 109 | # Saves the user record to Redis, overwriting any previous data for the 110 | # current ID and user name. 111 | # @return [void] 112 | def save 113 | mention_name = metadata[:mention_name] || metadata["mention_name"] 114 | 115 | current_keys = metadata.keys 116 | redis_keys = redis.hkeys("id:#{id}") 117 | delete_keys = (redis_keys - current_keys) 118 | 119 | redis.pipelined do 120 | redis.hdel("id:#{id}", *delete_keys) if delete_keys.any? 121 | redis.hmset("id:#{id}", *metadata.to_a.flatten) 122 | redis.set("name:#{name}", id) 123 | redis.set("mention_name:#{mention_name}", id) if mention_name 124 | end 125 | end 126 | 127 | # Compares the user against another user object to determine equality. Users 128 | # are considered equal if they have the same ID and name. 129 | # @param other (User) The user to compare against. 130 | # @return [Boolean] True if users are equal, false otherwise. 131 | def ==(other) 132 | other.respond_to?(:id) && id == other.id && other.respond_to?(:name) && name == other.name 133 | end 134 | alias eql? == 135 | 136 | # Generates a +Fixnum+ hash value for this user object. Implemented to support equality. 137 | # @return [Fixnum] The hash value. 138 | # @see Object#hash 139 | def hash 140 | id.hash ^ name.hash 141 | end 142 | 143 | private 144 | 145 | # Ensure the user's metadata contains their name, to ensure their Redis hash contains at least 146 | # one value. It's not possible to store an empty hash key in Redis. 147 | def ensure_name_metadata_set 148 | username = metadata.delete("name") 149 | metadata["name"] = username || id 150 | end 151 | 152 | # The Redis connection for user persistence. 153 | def redis 154 | self.class.redis 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /lib/lita/util.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | # Handy utilities used by other Lita classes. 5 | # @api private 6 | module Util 7 | class << self 8 | # Returns a hash with any symbol keys converted to strings. 9 | # @param hash [Hash] The hash to convert. 10 | # @return [Hash] The converted hash. 11 | def stringify_keys(hash) 12 | result = {} 13 | hash.each_key { |key| result[key.to_s] = hash[key] } 14 | result 15 | end 16 | 17 | # Transforms a camel-cased string into a snaked-cased string. Taken from +ActiveSupport.+ 18 | # @param camel_cased_word [String] The word to transform. 19 | # @return [String] The transformed word. 20 | def underscore(camel_cased_word) 21 | word = camel_cased_word.to_s.dup 22 | word.gsub!("::", "/") 23 | word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2') 24 | word.gsub!(/([a-z\d])([A-Z])/, '\1_\2') 25 | word.tr!("-", "_") 26 | word.downcase! 27 | word 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/lita/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | # The current version of Lita. 5 | VERSION = "5.0.0" 6 | end 7 | -------------------------------------------------------------------------------- /lita.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "lita/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "lita" 9 | spec.version = Lita::VERSION 10 | spec.authors = ["Jimmy Cuadra"] 11 | spec.email = ["jimmy@jimmycuadra.com"] 12 | spec.description = "ChatOps for Ruby." 13 | spec.summary = "ChatOps framework for Ruby. Lita is a robot companion for your chat room." 14 | spec.homepage = "https://github.com/litaio/lita" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files`.split($/) 18 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 19 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 20 | spec.require_paths = ["lib"] 21 | 22 | spec.required_ruby_version = ">= 3.0.0" 23 | 24 | spec.metadata = { 25 | "bug_tracker_uri" => "https://github.com/litaio/lita/issues", 26 | "changelog_uri" => "https://github.com/litaio/lita/releases", 27 | "documentation_uri" => "https://docs.lita.io/", 28 | "homepage_uri" => "https://www.lita.io/", 29 | "mailing_list_uri" => "https://groups.google.com/group/litaio", 30 | "source_code_uri" => "https://github.com/litaio/lita", 31 | } 32 | 33 | spec.add_runtime_dependency "bundler", "~> 2.2.3" 34 | spec.add_runtime_dependency "faraday", "~> 1.6.0" 35 | spec.add_runtime_dependency "http_router", "~> 0.11.2" 36 | spec.add_runtime_dependency "i18n", "~> 1.8.10" 37 | spec.add_runtime_dependency "ice_nine", "~> 0.11.2" 38 | spec.add_runtime_dependency "puma", "~> 5.4.0" 39 | spec.add_runtime_dependency "rack", "~> 2.2.3" 40 | spec.add_runtime_dependency "rb-readline", "~> 0.5.5" 41 | spec.add_runtime_dependency "redis-namespace", "~> 1.8.1" 42 | spec.add_runtime_dependency "thor", "~> 1.1.0" 43 | 44 | spec.add_development_dependency "pry-byebug", "~> 3.9.0" 45 | spec.add_development_dependency "rack-test", "~> 1.1.0" 46 | spec.add_development_dependency "rake", "~> 13.0.3" 47 | spec.add_development_dependency "rspec", "~> 3.10.0" 48 | spec.add_development_dependency "rubocop", "~> 1.17.0" 49 | spec.add_development_dependency "simplecov", "~> 0.21.2" 50 | end 51 | -------------------------------------------------------------------------------- /spec/lita/adapter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Adapter, lita: true do 6 | let(:robot) { Lita::Robot.new(registry) } 7 | 8 | let(:required_methods) { described_class::REQUIRED_METHODS } 9 | 10 | subject { described_class.new(robot) } 11 | 12 | it "stores a Robot" do 13 | expect(subject.robot).to eql(robot) 14 | end 15 | 16 | it "logs a warning if a required method has not been implemented" do 17 | expect(robot.logger).to receive(:warn).exactly(required_methods.size).times 18 | required_methods.each do |method| 19 | subject.public_send(method) 20 | end 21 | end 22 | 23 | describe "#config" do 24 | let(:adapter) do 25 | Class.new(described_class) do 26 | namespace "test" 27 | 28 | config :foo, default: :bar 29 | end 30 | end 31 | 32 | let(:robot) { Lita::Robot.new(registry) } 33 | 34 | before { registry.register_adapter(:test, adapter) } 35 | 36 | subject { adapter.new(robot) } 37 | 38 | it "provides access to the adapter's configuration object" do 39 | expect(subject.config.foo).to eq(:bar) 40 | end 41 | end 42 | 43 | describe "#log" do 44 | it "returns the robot's logger" do 45 | expect(subject.log).to eq(robot.logger) 46 | end 47 | end 48 | 49 | describe "#mention_format" do 50 | it "formats the provided name for mentioning the user" do 51 | expect(subject.mention_format("carl")).to eq("carl:") 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/lita/adapters/shell_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Adapters::Shell, lita: true do 6 | let(:robot) do 7 | instance_double( 8 | "Lita::Robot", 9 | name: "Lita", 10 | mention_name: "LitaBot", 11 | alias: "/", 12 | config: registry.config 13 | ) 14 | end 15 | 16 | subject { described_class.new(robot) } 17 | 18 | describe "#roster" do 19 | let(:room) { instance_double("Lita::Room") } 20 | 21 | it "returns the shell user" do 22 | expect(subject.roster(room).first.name).to eq("Shell User") 23 | end 24 | end 25 | 26 | describe "#run" do 27 | let(:user) { Lita::User.create(1, name: "Shell User") } 28 | 29 | before do 30 | registry.register_adapter(:shell, described_class) 31 | allow(subject).to receive(:puts) 32 | allow(Readline).to receive(:readline).and_return("foo", "exit") 33 | allow(robot).to receive(:trigger) 34 | allow(robot).to receive(:receive) 35 | allow(Lita::User).to receive(:create).and_return(user) 36 | end 37 | 38 | it "passes input to the Robot and breaks on an exit message" do 39 | expect(Readline).to receive(:readline).with("#{robot.name} > ", true).twice 40 | expect(robot).to receive(:receive).with(an_instance_of(Lita::Message)) 41 | subject.run 42 | end 43 | 44 | it "marks messages as commands if config.adapters.shell.private_chat is true" do 45 | registry.config.adapters.shell.private_chat = true 46 | expect_any_instance_of(Lita::Message).to receive(:command!) 47 | subject.run 48 | end 49 | 50 | it "sets the room to 'shell' if config.adapters.shell.private_chat is false" do 51 | registry.config.adapters.shell.private_chat = false 52 | expect(Lita::Source).to receive(:new).with(user: user, room: "shell") 53 | subject.run 54 | end 55 | 56 | it "sets the room to nil if config.adapters.shell.private_chat is true" do 57 | registry.config.adapters.shell.private_chat = true 58 | expect(Lita::Source).to receive(:new).with(user: user, room: nil) 59 | subject.run 60 | end 61 | 62 | it "triggers a connected event" do 63 | expect(robot).to receive(:trigger).with(:connected) 64 | subject.run 65 | end 66 | 67 | it "exits cleanly when EOF is received" do 68 | allow(Readline).to receive(:readline).and_return(nil) 69 | subject.run 70 | end 71 | 72 | it "removes empty input from readline history" do 73 | allow(Readline).to receive(:readline).and_return("", "exit") 74 | expect(Readline::HISTORY).to receive(:pop) 75 | subject.run 76 | end 77 | end 78 | 79 | describe "#send_message" do 80 | it "prints its input" do 81 | expect(subject).to receive(:puts) do |messages| 82 | expect(messages.first).to include("bar") 83 | end 84 | subject.send_messages(instance_double("Lita::Source"), "bar") 85 | end 86 | 87 | it "doesn't output empty messages" do 88 | expect(subject).to receive(:puts).with([]) 89 | subject.send_messages(instance_double("Lita::Source"), "") 90 | end 91 | end 92 | 93 | describe "#shut_down" do 94 | it "outputs a blank line" do 95 | expect(subject).to receive(:puts) 96 | subject.shut_down 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/lita/authorization_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Authorization, lita: true do 6 | let(:requesting_user) { instance_double("Lita::User", id: "1") } 7 | let(:robot) { Lita::Robot.new(registry) } 8 | let(:user) { instance_double("Lita::User", id: "2") } 9 | 10 | subject { described_class.new(robot) } 11 | 12 | before do 13 | registry.config.robot.admins = ["1"] 14 | end 15 | 16 | describe "#add_user_to_group" do 17 | it "adds users to an auth group" do 18 | subject.add_user_to_group(requesting_user, user, "employees") 19 | expect(subject.user_in_group?(user, "employees")).to be true 20 | end 21 | 22 | it "can only be called by admins" do 23 | registry.config.robot.admins = nil 24 | result = subject.add_user_to_group( 25 | requesting_user, 26 | user, 27 | "employees" 28 | ) 29 | expect(result).to eq(:unauthorized) 30 | expect(subject.user_in_group?(user, "employees")).to be false 31 | end 32 | 33 | it "normalizes the group name" do 34 | subject.add_user_to_group(requesting_user, user, "eMPLoYeeS") 35 | expect(subject.user_in_group?(user, " EmplOyEEs ")).to be true 36 | end 37 | end 38 | 39 | describe "#remove_user_from_group" do 40 | it "removes users from an auth group" do 41 | subject.add_user_to_group(requesting_user, user, "employees") 42 | subject.remove_user_from_group(requesting_user, user, "employees") 43 | expect(subject.user_in_group?(user, "employees")).to be false 44 | end 45 | 46 | it "can only be called by admins" do 47 | subject.add_user_to_group(requesting_user, user, "employees") 48 | registry.config.robot.admins = nil 49 | result = subject.remove_user_from_group( 50 | requesting_user, 51 | user, 52 | "employees" 53 | ) 54 | expect(result).to eq(:unauthorized) 55 | expect(subject.user_in_group?(user, "employees")).to be true 56 | end 57 | 58 | it "normalizes the group name" do 59 | subject.add_user_to_group(requesting_user, user, "eMPLoYeeS") 60 | subject.remove_user_from_group(requesting_user, user, "EmployeeS") 61 | expect(subject.user_in_group?(user, " EmplOyEEs ")).to be false 62 | end 63 | end 64 | 65 | describe "#user_in_group?" do 66 | it "returns false if the user is in the group" do 67 | expect(subject.user_in_group?(user, "employees")).to be false 68 | end 69 | 70 | it "delegates to .user_is_admin? if the group is admins" do 71 | expect(subject).to receive(:user_is_admin?) 72 | subject.user_in_group?(user, "admins") 73 | end 74 | end 75 | 76 | describe "#user_is_admin?" do 77 | it "returns true if the user's ID is in the config" do 78 | expect(subject.user_is_admin?(requesting_user)).to be true 79 | end 80 | 81 | it "returns false if the user's ID is not in the config" do 82 | registry.config.robot.admins = nil 83 | expect(subject.user_is_admin?(user)).to be false 84 | end 85 | end 86 | 87 | describe "#groups" do 88 | before do 89 | %i[foo bar baz].each do |group| 90 | subject.add_user_to_group(requesting_user, user, group) 91 | end 92 | end 93 | 94 | it "returns a list of all authorization groups" do 95 | expect(subject.groups).to match_array(%i[foo bar baz]) 96 | end 97 | end 98 | 99 | describe "#groups_with_users" do 100 | before do 101 | %i[foo bar baz].each do |group| 102 | subject.add_user_to_group(requesting_user, user, group) 103 | subject.add_user_to_group( 104 | requesting_user, 105 | requesting_user, 106 | group 107 | ) 108 | end 109 | allow(Lita::User).to receive(:find_by_id).with("1").and_return(requesting_user) 110 | allow(Lita::User).to receive(:find_by_id).with("2").and_return(user) 111 | end 112 | 113 | it "returns a hash of all authorization groups and their members" do 114 | groups = %i[foo bar baz] 115 | groups_with_users = subject.groups_with_users 116 | expect(groups_with_users.keys).to match_array(groups) 117 | groups.each do |group| 118 | expect(groups_with_users[group]).to match_array([user, requesting_user]) 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /spec/lita/configuration_validator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::ConfigurationValidator, lita: true do 6 | subject { described_class.new(registry) } 7 | 8 | describe "#call" do 9 | it "has no effect if there are no plugins registered" do 10 | expect { subject.call }.not_to raise_error 11 | end 12 | 13 | it "has no effect if all adapters have valid configuration" do 14 | registry.register_adapter(:config_validator_test) do 15 | config :foo, required: true, default: :bar 16 | end 17 | 18 | expect { subject.call }.not_to raise_error 19 | end 20 | 21 | it "raises if a required adapter configuration attribute is missing" do 22 | registry.register_adapter(:config_validator_test) do 23 | config :foo, required: true 24 | end 25 | 26 | expect(Lita.logger).to receive(:fatal).with( 27 | /Configuration attribute "foo" is required for "config_validator_test" adapter/ 28 | ) 29 | expect { subject.call }.to raise_error(SystemExit) 30 | end 31 | 32 | it "has no effect if all adapters with nested configuration have valid configuration" do 33 | registry.register_adapter(:config_validator_test) do 34 | config :foo do 35 | config :bar, required: true, default: :baz 36 | end 37 | end 38 | 39 | expect { subject.call }.not_to raise_error 40 | end 41 | 42 | it "raises if a required nested adapter configuration attribute is missing" do 43 | registry.register_adapter(:config_validator_test) do 44 | config :foo do 45 | config :bar, required: true 46 | end 47 | end 48 | 49 | expect(Lita.logger).to receive(:fatal).with( 50 | /Configuration attribute "foo\.bar" is required for "config_validator_test" adapter/ 51 | ) 52 | expect { subject.call }.to raise_error(SystemExit) 53 | end 54 | 55 | it "uses the right namespace for a nested attribute when a previous nesting has been visited" do 56 | registry.register_adapter(:config_validator_test) do 57 | config :foo do 58 | config :bar 59 | end 60 | 61 | config :one do 62 | config :two, required: true 63 | end 64 | end 65 | 66 | expect(Lita.logger).to receive(:fatal).with( 67 | /Configuration attribute "one\.two" is required for "config_validator_test" adapter/ 68 | ) 69 | expect { subject.call }.to raise_error(SystemExit) 70 | end 71 | 72 | it "has no effect if all handlers have valid configuration" do 73 | registry.register_handler(:config_validator_test) do 74 | config :foo, required: true, default: :bar 75 | end 76 | 77 | expect { subject.call }.not_to raise_error 78 | end 79 | 80 | it "raises if a required handler configuration attribute is missing" do 81 | registry.register_handler(:config_validator_test) do 82 | config :foo, required: true 83 | end 84 | 85 | expect(Lita.logger).to receive(:fatal).with( 86 | /Configuration attribute "foo" is required for "config_validator_test" handler/ 87 | ) 88 | expect { subject.call }.to raise_error(SystemExit) 89 | end 90 | 91 | it "has no effect if all handlers with nested configuration have valid configuration" do 92 | registry.register_handler(:config_validator_test) do 93 | config :foo do 94 | config :bar, required: true, default: :baz 95 | end 96 | end 97 | 98 | expect { subject.call }.not_to raise_error 99 | end 100 | 101 | it "raises if a required nested handler configuration attribute is missing" do 102 | registry.register_handler(:config_validator_test) do 103 | config :foo do 104 | config :bar, required: true 105 | end 106 | end 107 | 108 | expect(Lita.logger).to receive(:fatal).with( 109 | /Configuration attribute "foo\.bar" is required for "config_validator_test" handler/ 110 | ) 111 | expect { subject.call }.to raise_error(SystemExit) 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/lita/default_configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::DefaultConfiguration, lita: true do 6 | subject { described_class.new(registry) } 7 | 8 | let(:config) { subject.build } 9 | 10 | describe "adapters config" do 11 | context "with no adapters with config attributes" do 12 | it "has an adapters attribute" do 13 | expect(config).to respond_to(:adapters) 14 | end 15 | end 16 | 17 | context "with one adapter with no configuration" do 18 | it "has an attribute for the adapter" do 19 | # rubocop:disable Lint/EmptyBlock 20 | registry.register_adapter(:foo) {} 21 | # rubocop:enable Lint/EmptyBlock 22 | 23 | expect(config.adapters).to respond_to(:foo) 24 | end 25 | end 26 | 27 | context "with an adapter with configuration" do 28 | it "has an attribute for the handler with its own attributes" do 29 | registry.register_adapter(:foo) { config :bar, default: :baz } 30 | 31 | expect(config.adapters.foo.bar).to eq(:baz) 32 | end 33 | end 34 | end 35 | 36 | describe "handlers config" do 37 | context "with no handlers with config attributes" do 38 | it "has a handlers attribute" do 39 | expect(config).to respond_to(:handlers) 40 | end 41 | end 42 | 43 | context "with one handler with no configuration" do 44 | it "has no attribute for the handler" do 45 | # rubocop:disable Lint/EmptyBlock 46 | registry.register_handler(:foo) {} 47 | # rubocop:enable Lint/EmptyBlock 48 | 49 | expect(config.handlers).not_to respond_to(:foo) 50 | end 51 | end 52 | 53 | context "with a handler with configuration" do 54 | it "has an attribute for the handler with its own attributes" do 55 | registry.register_handler(:foo) { config :bar, default: :baz } 56 | 57 | expect(config.handlers.foo.bar).to eq(:baz) 58 | end 59 | end 60 | end 61 | 62 | describe "http config" do 63 | it "has a default host" do 64 | expect(config.http.host).to eq("0.0.0.0") 65 | end 66 | 67 | it "can set the host" do 68 | config.http.host = "127.0.0.1" 69 | 70 | expect(config.http.host).to eq("127.0.0.1") 71 | end 72 | 73 | it "has a default port" do 74 | expect(config.http.port).to eq(8080) 75 | end 76 | 77 | it "can set the port" do 78 | config.http.port = 80 79 | 80 | expect(config.http.port).to eq(80) 81 | end 82 | 83 | it "has a default minimum thread count" do 84 | expect(config.http.min_threads).to eq(0) 85 | end 86 | 87 | it "can set the minimum threads" do 88 | config.http.min_threads = 4 89 | 90 | expect(config.http.min_threads).to eq(4) 91 | end 92 | 93 | it "has a default maximum thread count" do 94 | expect(config.http.max_threads).to eq(16) 95 | end 96 | 97 | it "can set the maximum threads" do 98 | config.http.max_threads = 8 99 | 100 | expect(config.http.max_threads).to eq(8) 101 | end 102 | 103 | it "has an empty middleware stack" do 104 | expect(config.http.middleware).to be_empty 105 | end 106 | 107 | it "can add middleware to the stack" do 108 | middleware = double("a rack middleware") 109 | 110 | config.http.middleware.push(middleware) 111 | 112 | expect(config.http.middleware).not_to be_empty 113 | end 114 | 115 | it "can add middleware with arguments" do 116 | middleware = double("a rack middleware") 117 | 118 | config.http.middleware.use(middleware, "argument") do 119 | "block" 120 | end 121 | 122 | expect(config.http.middleware).not_to be_empty 123 | end 124 | end 125 | 126 | describe "redis config" do 127 | it "has empty default options" do 128 | expect(config.redis).to eq({}) 129 | end 130 | 131 | it "can set options" do 132 | options = { port: 1234, password: "secret" } 133 | 134 | config.redis = options 135 | 136 | expect(config.redis).to eq(options) 137 | end 138 | end 139 | 140 | describe "robot config" do 141 | it "has a default name" do 142 | expect(config.robot.name).to eq("Lita") 143 | end 144 | 145 | it "can set a name" do 146 | config.robot.name = "Not Lita" 147 | 148 | expect(config.robot.name).to eq("Not Lita") 149 | end 150 | 151 | it "has no default mention name" do 152 | expect(config.robot.mention_name).to be_nil 153 | end 154 | 155 | it "can set a mention name" do 156 | config.robot.mention_name = "notlita" 157 | 158 | expect(config.robot.mention_name).to eq("notlita") 159 | end 160 | 161 | it "has no default alias" do 162 | expect(config.robot.alias).to be_nil 163 | end 164 | 165 | it "can set an alias" do 166 | config.robot.alias = "/" 167 | 168 | expect(config.robot.alias).to eq("/") 169 | end 170 | 171 | it "has a default adapter" do 172 | expect(config.robot.adapter).to eq(:shell) 173 | end 174 | 175 | it "can set an adapter" do 176 | config.robot.adapter = :hipchat 177 | 178 | expect(config.robot.adapter).to eq(:hipchat) 179 | end 180 | 181 | it "has no default locale" do 182 | expect(config.robot.locale).to be_nil 183 | end 184 | 185 | it "can set a locale" do 186 | config.robot.locale = :es 187 | 188 | expect(config.robot.locale).to eq(:es) 189 | end 190 | 191 | it "has no default locale" do 192 | expect(config.robot.default_locale).to be_nil 193 | end 194 | 195 | it "can set a default locale" do 196 | config.robot.default_locale = :es 197 | 198 | expect(config.robot.default_locale).to eq(:es) 199 | end 200 | 201 | it "has a default log level" do 202 | expect(config.robot.log_level).to eq(:info) 203 | end 204 | 205 | it "can set a log level" do 206 | config.robot.log_level = :debug 207 | 208 | expect(config.robot.log_level).to eq(:debug) 209 | end 210 | 211 | it "has a default redis namespace" do 212 | expect(config.robot.redis_namespace).to eq("lita.test") 213 | end 214 | 215 | it "can set a redis namespace" do 216 | config.robot.redis_namespace = "mylitabot" 217 | 218 | expect(config.robot.redis_namespace).to eq("mylitabot") 219 | end 220 | 221 | it "allows strings and mixed case as log levels" do 222 | expect { config.robot.log_level = "dEbUg" }.not_to raise_error 223 | end 224 | 225 | it "raises a validation error for invalid log levels" do 226 | expect(Lita.logger).to receive(:fatal).with( 227 | /Validation error on attribute "log_level": must be one of/ 228 | ) 229 | expect { config.robot.log_level = :not_a_level }.to raise_error(Lita::ValidationError) 230 | end 231 | 232 | it "has no default admins" do 233 | expect(config.robot.admins).to be_nil 234 | end 235 | 236 | it "can set admins" do 237 | config.robot.admins = %w[1 2 3] 238 | 239 | expect(config.robot.admins).to eq(%w[1 2 3]) 240 | end 241 | end 242 | end 243 | -------------------------------------------------------------------------------- /spec/lita/handler/event_router_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Handler::EventRouter, lita: true do 6 | let(:robot) { Lita::Robot.new(registry) } 7 | 8 | subject do 9 | Class.new do 10 | extend Lita::Handler::EventRouter 11 | 12 | def self.name 13 | "Test" 14 | end 15 | 16 | on :connected, :greet 17 | 18 | def greet(payload) 19 | robot.send_message("Hi, #{payload[:name]}! Lita has started!") 20 | end 21 | 22 | on :block_test do |payload| 23 | robot.send_message("#{payload[:data]} received via block!") 24 | end 25 | 26 | on :callable_test, lambda { |payload| 27 | robot.send_message("#{payload[:data]} received via callable!") 28 | } 29 | 30 | on(:multiple_callbacks) { robot.send_message("first callback") } 31 | on(:multiple_callbacks) { robot.send_message("second callback") } 32 | 33 | on(:multiple_errors) do 34 | robot.send_message("first error") 35 | raise ArgumentError, "first" 36 | end 37 | 38 | on(:multiple_errors) do 39 | robot.send_message("second error") 40 | raise ArgumentError, "second" 41 | end 42 | end 43 | end 44 | 45 | describe ".trigger" do 46 | it "invokes methods registered with .on and passes an arbitrary payload" do 47 | expect(robot).to receive(:send_message).with( 48 | "Hi, Carl! Lita has started!" 49 | ) 50 | subject.trigger(robot, :connected, name: "Carl") 51 | end 52 | 53 | it "calls blocks that were passed to .on" do 54 | expect(robot).to receive(:send_message).with("Data received via block!") 55 | subject.trigger(robot, :block_test, data: "Data") 56 | end 57 | 58 | it "calls arbitrary callables that were passed to .on" do 59 | expect(robot).to receive(:send_message).with("Data received via callable!") 60 | subject.trigger(robot, :callable_test, data: "Data") 61 | end 62 | 63 | it "doesn't stop triggering callbacks after the first is triggered" do 64 | allow(robot).to receive(:send_message) 65 | 66 | expect(robot).to receive(:send_message).with("second callback") 67 | 68 | subject.trigger(robot, :multiple_callbacks) 69 | end 70 | 71 | it "normalizes the event name" do 72 | expect(robot).to receive(:send_message).twice 73 | subject.trigger(robot, "connected") 74 | subject.trigger(robot, " ConNected ") 75 | end 76 | 77 | context "not in test mode" do 78 | before { stub_const("STDERR", StringIO.new) } 79 | 80 | around do |example| 81 | test_mode = Lita.test_mode? 82 | Lita.test_mode = false 83 | begin 84 | example.run 85 | ensure 86 | Lita.test_mode = test_mode 87 | end 88 | end 89 | 90 | it "doesn't stop triggering callbacks after an exception is raised" do 91 | expect(robot).to receive(:send_message).with("first error").once 92 | expect(robot).to receive(:send_message).with("second error").once 93 | subject.trigger(robot, :multiple_errors) 94 | end 95 | 96 | it "reports callback exceptions to the error handler" do 97 | allow(robot).to receive(:send_message) 98 | expect(registry.config.robot.error_handler).to receive(:call).twice 99 | subject.trigger(robot, :multiple_errors) 100 | end 101 | end 102 | 103 | context "in test mode" do 104 | around do |example| 105 | test_mode = Lita.test_mode? 106 | Lita.test_mode = true 107 | begin 108 | example.run 109 | ensure 110 | Lita.test_mode = test_mode 111 | end 112 | end 113 | 114 | it "re-raises callback exceptions immediately" do 115 | allow(robot).to receive(:send_message) 116 | expect(registry.config.robot.error_handler).to receive(:call).once 117 | expect { subject.trigger(robot, :multiple_errors) }.to raise_error(ArgumentError, "first") 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/lita/handler/http_router_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | handler = Class.new do 6 | extend Lita::Handler::HTTPRouter 7 | 8 | namespace "test" 9 | 10 | http.get "web", :web 11 | http.post "path/with/:id", :variable 12 | http.link "foo", :foo 13 | http.get "heres/*a/glob/in/a/path", :glob 14 | http.get ":var/otherwise/identical/path", :constraint, var: /\d+/ 15 | http.get ":var/otherwise/identical/path", :no_constraint 16 | http.get("block") { |_request, response| response.write("block") } 17 | http.get "middleware", :middleware 18 | http.get "boom", :boom 19 | 20 | def web(_request, response) 21 | response.write("it worked") 22 | end 23 | 24 | def variable(request, response) 25 | id = request.env["router.params"][:id] 26 | response.write("id is #{id}") 27 | end 28 | 29 | def glob(request, response) 30 | segments = request.env["router.params"][:a] 31 | response.write(segments.join("/")) 32 | end 33 | 34 | def constraint(_request, response) 35 | response.write("constraint") 36 | end 37 | 38 | def no_constraint(_request, response) 39 | response.write("no constraint") 40 | end 41 | 42 | def middleware(request, response) 43 | response["Custom-Header"] = request.env["header_value"] if request.env["use_header"] 44 | response.write("middleware worked") if request.env["custom_rack_middleware_working"] 45 | end 46 | 47 | def boom(_request, _response) 48 | # rubocop:disable Style/StringConcatenation 49 | 1 + "2" 50 | # rubocop:enable Style/StringConcatenation 51 | end 52 | end 53 | 54 | describe handler, lita_handler: true do 55 | it "responds to requests for simple paths" do 56 | response = http.get("/web") 57 | expect(response.status).to eq(200) 58 | expect(response.body).to eq("it worked") 59 | end 60 | 61 | it "responds to requests with variable paths" do 62 | response = http.post("/path/with/some_id") 63 | expect(response.status).to eq(200) 64 | expect(response.body).to eq("id is some_id") 65 | end 66 | 67 | it "responds to requests with globs in their paths" do 68 | response = http.get("heres/a/giant/glob/in/a/path") 69 | expect(response.status).to eq(200) 70 | expect(response.body).to eq("a/giant") 71 | end 72 | 73 | it "responds to requests with variable path constraints" do 74 | response = http.get("/123/otherwise/identical/path") 75 | expect(response.status).to eq(200) 76 | expect(response.body).to eq("constraint") 77 | 78 | response = http.get("/an/otherwise/identical/path") 79 | expect(response.status).to eq(200) 80 | expect(response.body).to eq("no constraint") 81 | end 82 | 83 | it "responds to HEAD requests for GET routes" do 84 | response = http.head("/web") 85 | expect(response.status).to eq(204) 86 | expect(response.body).to be_empty 87 | end 88 | 89 | it "allows route callbacks to be provided as blocks" do 90 | response = http.get("/block") 91 | expect(response.status).to eq(200) 92 | expect(response.body).to eq("block") 93 | end 94 | 95 | context "when the handler raises an exception" do 96 | it "calls the error handler with the exception as argument" do 97 | expect(registry.config.robot.error_handler).to receive(:call) do |error, *_args| 98 | expect(error).to be_an_instance_of(TypeError) 99 | end 100 | 101 | expect { http.get("/boom") }.to raise_error(TypeError, "String can't be coerced into Integer") 102 | end 103 | end 104 | end 105 | 106 | describe handler, lita_handler: true do 107 | let(:middleware) do 108 | Class.new do 109 | def initialize(app) 110 | @app = app 111 | end 112 | 113 | def call(env) 114 | env["custom_rack_middleware_working"] = true 115 | @app.call(env) 116 | end 117 | end 118 | end 119 | 120 | prepend_before { registry.config.http.middleware.push(middleware) } 121 | 122 | it "uses any custom middlewares registered" do 123 | response = http.get("/middleware") 124 | expect(response.body).to eq("middleware worked") 125 | end 126 | end 127 | 128 | describe handler, lita_handler: true do 129 | let(:middleware) do 130 | Class.new do 131 | # rubocop:disable Style/OptionalBooleanParameter 132 | def initialize(app, use_header = false, &block) 133 | @app = app 134 | @use_header = use_header 135 | @block = block 136 | end 137 | # rubocop:enable Style/OptionalBooleanParameter 138 | 139 | def call(env) 140 | env["use_header"] = @use_header 141 | env["header_value"] = @block.call 142 | @app.call(env) 143 | end 144 | end 145 | end 146 | 147 | prepend_before do 148 | registry.config.http.middleware.use(middleware, true) { "header value" } 149 | end 150 | 151 | it "uses any custom middlewares registered" do 152 | response = http.get("/middleware") 153 | expect(response["Custom-Header"]).to eq("header value") 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /spec/lita/handler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Handler, lita_handler: true do 6 | before { registry.handlers.delete(described_class) } 7 | 8 | prepend_before(after_config: true) do 9 | registry.register_handler(:foo) do 10 | config :foo_response, required: true, type: String 11 | 12 | after_config do |config| 13 | route(/foo/) do |response| 14 | response.reply(config.foo_response) 15 | end 16 | end 17 | end 18 | end 19 | 20 | it "includes chat routes" do 21 | registry.register_handler(:foo) do 22 | route(/foo/) do |response| 23 | response.reply("bar") 24 | end 25 | end 26 | 27 | send_message("foo") 28 | 29 | expect(replies.last).to include("bar") 30 | end 31 | 32 | it "includes HTTP routes" do 33 | registry.register_handler(:foo) do 34 | http.get "foo" do |_request, response| 35 | response.write("bar") 36 | end 37 | end 38 | 39 | http_client = Faraday::Connection.new { |c| c.adapter(:rack, Lita::RackApp.new(robot)) } 40 | response = http_client.get("/foo") 41 | 42 | expect(response.body).to eq("bar") 43 | end 44 | 45 | it "includes event routes" do 46 | registry.register_handler(:foo) do 47 | on(:some_event) { robot.send_message("payload received") } 48 | end 49 | 50 | expect(robot).to receive(:send_message).with("payload received") 51 | 52 | robot.trigger(:some_event) 53 | end 54 | 55 | it "runs the after_config block configuration is finalized", after_config: true do 56 | registry.config.handlers.foo.foo_response = "baz" 57 | 58 | send_message("foo") 59 | 60 | expect(replies.last).to include("baz") 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/lita/handlers/authorization_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Handlers::Authorization, lita_handler: true do 6 | before do 7 | allow(robot.auth).to receive(:user_is_admin?).with(user).and_return(true) 8 | end 9 | 10 | let(:target_user) { instance_double("Lita::User", id: "1", name: "Carl") } 11 | 12 | it { is_expected.to route_command("auth add foo bar").to(:add) } 13 | it { is_expected.to route_command("auth add foo@bar.com baz").to(:add) } 14 | it { is_expected.to route_command("auth remove foo bar").to(:remove) } 15 | it { is_expected.to route_command("auth remove foo@bar.com baz").to(:remove) } 16 | it { is_expected.to route_command("auth list").to(:list) } 17 | it { is_expected.to route_command("auth list foo").to(:list) } 18 | 19 | describe "#add" do 20 | it "replies with the proper format if the require commands are missing" do 21 | send_command("auth add foo") 22 | expect(replies.last).to match(/^Format:/) 23 | end 24 | 25 | it "replies with a warning if target user is not known" do 26 | send_command("auth add foo bar") 27 | expect(replies.last).to match(/No user was found/) 28 | end 29 | 30 | it "replies with success if a valid user and group were supplied" do 31 | allow(Lita::User).to receive(:find_by_id).and_return(target_user) 32 | send_command("auth add foo bar") 33 | expect(replies.last).to eq("#{target_user.name} was added to bar.") 34 | end 35 | 36 | it "replies with a warning if the user was already in the group" do 37 | allow(Lita::User).to receive(:find_by_id).and_return(target_user) 38 | send_command("auth add foo bar") 39 | send_command("auth add foo bar") 40 | expect(replies.last).to eq("#{target_user.name} was already in bar.") 41 | end 42 | 43 | it 'replies with a warning if the group was "admins"' do 44 | send_command("auth add foo admins") 45 | expect(replies.last).to match(/Administrators can only be managed/) 46 | end 47 | end 48 | 49 | describe "#remove" do 50 | before do 51 | allow(Lita::User).to receive(:find_by_id).and_return(target_user) 52 | send_command("auth add foo bar") 53 | end 54 | 55 | it "replies with success if a valid user and group were supplied" do 56 | send_command("auth remove foo bar") 57 | expect(replies.last).to eq("#{target_user.name} was removed from bar.") 58 | end 59 | 60 | it "replies with a warning if the user was already in the group" do 61 | send_command("auth remove foo bar") 62 | send_command("auth remove foo bar") 63 | expect(replies.last).to eq("#{target_user.name} was not in bar.") 64 | end 65 | 66 | it 'replies with a warning if the group was "admins"' do 67 | send_command("auth add foo admins") 68 | expect(replies.last).to match(/Administrators can only be managed/) 69 | end 70 | end 71 | 72 | describe "#list" do 73 | context "when there are populated groups" do 74 | let(:groups) { %i[foo bar] } 75 | let(:user_1) { Lita::User.create(3, name: "Bongo") } 76 | let(:user_2) { Lita::User.create(4, name: "Carl") } 77 | 78 | before do 79 | groups.each do |group| 80 | subject.robot.auth.add_user_to_group(user, user_1, group) 81 | subject.robot.auth.add_user_to_group(user, user_2, group) 82 | end 83 | end 84 | 85 | it "lists all authorization groups and their members" do 86 | send_command("auth list") 87 | groups.each do |group| 88 | expect(replies.last).to include( 89 | "#{group}: #{user_1.name}, #{user_2.name}" 90 | ) 91 | end 92 | end 93 | 94 | it "lists only the requested group" do 95 | send_command("auth list foo") 96 | expect(replies.last).to include("foo") 97 | expect(replies.last).not_to include("bar") 98 | end 99 | end 100 | 101 | it "replies that there are no groups" do 102 | send_command("auth list") 103 | expect(replies.last).to include("no authorization groups yet") 104 | end 105 | 106 | it "replies that the specified group doesn't exist" do 107 | send_command("auth list nothing") 108 | expect(replies.last).to include("no authorization group named nothing") 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /spec/lita/handlers/help_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Handlers::Help, lita_handler: true do 6 | it { is_expected.to route_command("help").to(:help) } 7 | it { is_expected.to route_command("help foo").to(:help) } 8 | 9 | describe "#help" do 10 | let(:dummy_handler_class) do 11 | Class.new(Lita::Handler) do 12 | def self.name 13 | "Dummy" 14 | end 15 | 16 | route(/secret/, :secret, restrict_to: :the_nobodies, help: { 17 | "secret" => "This help message should be accompanied by a caveat" 18 | }) 19 | 20 | def secret(_response); end 21 | end 22 | end 23 | 24 | let(:dummy_handler_class_2) do 25 | Class.new(Lita::Handler) do 26 | def self.name 27 | "Dummy2" 28 | end 29 | 30 | namespace "Dummy" 31 | 32 | route(/foo/, :foo, help: { "foo" => "foo" }) 33 | 34 | def foo(_response); end 35 | end 36 | end 37 | 38 | let(:another_handler) do 39 | Class.new(Lita::Handler) do 40 | def self.name 41 | "Another" 42 | end 43 | 44 | route(/bar dummy/, :bar, help: { "bar dummy" => "bar" }) 45 | route(/baz/, :baz, help: { "baz" => "baz dummy" }) 46 | 47 | def bar(_response); end 48 | 49 | def baz(_response); end 50 | end 51 | end 52 | 53 | before do 54 | registry.register_handler(dummy_handler_class) 55 | registry.register_handler(dummy_handler_class_2) 56 | registry.register_handler(another_handler) 57 | registry.config.robot.alias = "!" 58 | end 59 | 60 | it "lists all installed handlers in alphabetical order with duplicates removed" do 61 | send_command("help") 62 | expect(replies.last).to match( 63 | /^Send the message "!help QUERY".+installed:\n\nanother\ndummy\nhelp$/ 64 | ) 65 | end 66 | 67 | it "sends help information for all commands under a given handler" do 68 | send_command("help another") 69 | expect(replies.last).to match(/bar.+baz/m) 70 | end 71 | 72 | it "sends help information for all commands matching a given substring" do 73 | send_command("help foo") 74 | expect(replies.last).to match(/foo/) 75 | end 76 | 77 | it("sends help information for all relevant commands "\ 78 | "when the given substring matches a handler + individual help messages") do 79 | send_command("help dummy") 80 | expect(replies.last).to match(/secret.+foo.+bar.+baz/m) 81 | end 82 | 83 | it "uses the mention name when no alias is defined" do 84 | allow(robot.config.robot).to receive(:alias).and_return(nil) 85 | send_command("help help") 86 | expect(replies.last).to match(/#{robot.mention_name}: help/) 87 | end 88 | 89 | it "responds with an error if the given substring has no matches" do 90 | send_command("help asdf") 91 | expect(replies.last).to eq("No matching handlers, message patterns, or descriptions found.") 92 | end 93 | 94 | it "doesn't crash if a handler doesn't have routes" do 95 | event_handler = Class.new do 96 | extend Lita::Handler::EventRouter 97 | end 98 | 99 | registry.register_handler(event_handler) 100 | 101 | expect { send_command("help") }.not_to raise_error 102 | end 103 | 104 | describe "restricted routes" do 105 | let(:authorized_user) do 106 | user = Lita::User.create(2, name: "Authorized User") 107 | Lita::Authorization.new(robot).add_user_to_group!(user, :the_nobodies) 108 | user 109 | end 110 | 111 | it "shows the unauthorized message for commands the user doesn't have access to" do 112 | send_command("help secret") 113 | expect(replies.last).to include("secret") 114 | expect(replies.last).to include("Unauthorized") 115 | end 116 | 117 | it "omits the unauthorized message if the user has access" do 118 | send_command("help secret", as: authorized_user) 119 | expect(replies.last).to include("secret") 120 | expect(replies.last).not_to include("Unauthorized") 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /spec/lita/handlers/info_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Handlers::Info, lita_handler: true do 6 | it { is_expected.to route_command("info").to(:chat) } 7 | it { is_expected.to route_http(:get, "/lita/info").to(:web) } 8 | 9 | let(:request) { double("Rack::Request") } 10 | let(:response) { Rack::Response.new } 11 | 12 | describe "#chat" do 13 | it "responds with the current version of Lita" do 14 | send_command("info") 15 | expect(replies.first).to include(Lita::VERSION) 16 | end 17 | 18 | it "responds with a link to the website" do 19 | send_command("info") 20 | expect(replies.first).to include("lita.io") 21 | end 22 | 23 | it "responds with the Redis version and memory usage" do 24 | send_command("info") 25 | expect(replies.last).to match(/Redis [\d.]+ - Memory used: [\d.]+[BKMG]/) 26 | end 27 | end 28 | 29 | describe "#web" do 30 | let(:json) { JSON.parse(response.body.join) } 31 | 32 | it "returns JSON" do 33 | subject.web(request, response) 34 | expect(response.headers["Content-Type"]).to eq("application/json") 35 | end 36 | 37 | it "includes the current version of Lita" do 38 | subject.web(request, response) 39 | expect(json).to include("lita_version" => Lita::VERSION) 40 | end 41 | 42 | it "includes the adapter being used" do 43 | subject.web(request, response) 44 | expect(json).to include("adapter" => registry.config.robot.adapter.to_s) 45 | end 46 | 47 | it "includes the robot's name" do 48 | subject.web(request, response) 49 | expect(json).to include("robot_name" => robot.name) 50 | end 51 | 52 | it "includes the robot's mention name" do 53 | subject.web(request, response) 54 | expect(json).to include("robot_mention_name" => robot.mention_name) 55 | end 56 | 57 | it "includes the Redis version" do 58 | subject.web(request, response) 59 | expect(json).to have_key("redis_version") 60 | end 61 | 62 | it "includes the Redis memory usage" do 63 | subject.web(request, response) 64 | expect(json).to have_key("redis_memory_usage") 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/lita/handlers/room_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Handlers::Room, lita_handler: true do 6 | it { is_expected.to route_command("join #lita.io").to(:join) } 7 | it { is_expected.to route_command("part #lita.io").to(:part) } 8 | 9 | before { allow(robot.auth).to receive(:user_is_admin?).with(user).and_return(true) } 10 | 11 | describe "#join" do 12 | it "calls Robot#join with the provided ID" do 13 | expect(robot).to receive(:join).with("#lita.io") 14 | send_command("join #lita.io") 15 | end 16 | end 17 | 18 | describe "#part" do 19 | it "calls Robot#part with the provided ID" do 20 | expect(robot).to receive(:part).with("#lita.io") 21 | send_command("part #lita.io") 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/lita/handlers/users_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Handlers::Users, lita_handler: true do 6 | it { is_expected.to route_command("users find carl").to(:find) } 7 | 8 | describe "#find" do 9 | it "finds users by ID" do 10 | send_command("users find 1") 11 | 12 | expect(replies.first).to eq("Test User (ID: 1, Mention name: Test User)") 13 | end 14 | 15 | it "finds users by name" do 16 | send_command("users find 'Test User'") 17 | 18 | expect(replies.first).to eq("Test User (ID: 1, Mention name: Test User)") 19 | end 20 | 21 | it "finds users by mention name" do 22 | Lita::User.create(2, name: "Mr. Pug", mention_name: "carl") 23 | 24 | send_command("users find carl") 25 | 26 | expect(replies.first).to eq("Mr. Pug (ID: 2, Mention name: carl)") 27 | end 28 | 29 | it "replies with a message when no matches are found" do 30 | send_command("users find nobody") 31 | 32 | expect(replies.first).to eq("No matching users found.") 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/lita/logger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Logger do 6 | let(:io) { StringIO.new } 7 | 8 | it "uses a custom log level" do 9 | logger = described_class.get_logger(:debug) 10 | expect(logger.level).to eq(Logger::DEBUG) 11 | end 12 | 13 | it "uses the info level if the config is nil" do 14 | logger = described_class.get_logger(nil) 15 | expect(logger.level).to eq(Logger::INFO) 16 | end 17 | 18 | it "uses the info level if the config level is invalid" do 19 | logger = described_class.get_logger(:foo) 20 | expect(logger.level).to eq(Logger::INFO) 21 | end 22 | 23 | it "logs messages with a custom format" do 24 | logger = described_class.get_logger(:debug, io: io) 25 | logger.fatal "foo" 26 | expect(io.string).to match(/^\[.+\] FATAL: foo$/) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/lita/message_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Message do 6 | let(:mention_name) { "LitaBot" } 7 | 8 | let(:robot) do 9 | instance_double("Lita::Robot", name: "Lita", mention_name: mention_name, alias: ".") 10 | end 11 | 12 | let(:source) { instance_double("Lita::Source") } 13 | 14 | subject do 15 | described_class.new(robot, "Hello", source) 16 | end 17 | 18 | it "has a body" do 19 | expect(subject.body).to eq("Hello") 20 | end 21 | 22 | it "has a source" do 23 | expect(subject.source).to eq(source) 24 | end 25 | 26 | describe "#extensions" do 27 | it "can be populated with arbitrary data" do 28 | subject.extensions[:foo] = :bar 29 | 30 | expect(subject.extensions[:foo]).to eq(:bar) 31 | end 32 | end 33 | 34 | describe "#args" do 35 | it "returns an array of the 2nd through nth word in the message" do 36 | subject = described_class.new(robot, "args foo bar", source) 37 | expect(subject.args).to eq(%w[foo bar]) 38 | end 39 | 40 | it "escapes messages that have mismatched quotes" do 41 | subject = described_class.new(robot, "args it's working", source) 42 | expect(subject.args).to eq(%w[it's working]) 43 | end 44 | end 45 | 46 | describe "#command!" do 47 | it "marks a message as a command" do 48 | subject.command! 49 | expect(subject).to be_a_command 50 | end 51 | end 52 | 53 | describe "#command?" do 54 | context "when the message is addressed to the robot" do 55 | subject { described_class.new(robot, "#{robot.mention_name}: hello", source) } 56 | 57 | it "is true" do 58 | expect(subject).to be_a_command 59 | end 60 | end 61 | 62 | context "when the message is addressed to the robot with different capitalization" do 63 | subject { described_class.new(robot, "#{robot.mention_name.upcase}: hello", source) } 64 | 65 | it "is true" do 66 | expect(subject).to be_a_command 67 | end 68 | end 69 | 70 | context "when the message is addressed to the robot with a comma" do 71 | subject { described_class.new(robot, "#{robot.mention_name.upcase}, hello", source) } 72 | 73 | it "is true" do 74 | expect(subject).to be_a_command 75 | end 76 | end 77 | 78 | context "when the message is addressed to the robot with no trailing punctuation" do 79 | subject { described_class.new(robot, "#{robot.mention_name.upcase} hello", source) } 80 | 81 | it "is true" do 82 | expect(subject).to be_a_command 83 | end 84 | end 85 | 86 | context "when the message is addressed to the bot via alias with no space after it" do 87 | subject { described_class.new(robot, "#{robot.alias}hello", source) } 88 | 89 | it "is true" do 90 | expect(subject).to be_a_command 91 | end 92 | end 93 | 94 | context "when the message is addressed to the bot via alias with space after it" do 95 | subject { described_class.new(robot, "#{robot.alias} hello", source) } 96 | 97 | it "is true" do 98 | expect(subject).to be_a_command 99 | end 100 | end 101 | 102 | context "when the message incidentally starts with the mention name" do 103 | let(:mention_name) { "sa" } 104 | 105 | subject { described_class.new(robot, "salmon", source) } 106 | 107 | it "is false" do 108 | expect(subject).not_to be_a_command 109 | end 110 | 111 | it "does not affect the message body" do 112 | expect(subject.body).to eq("salmon") 113 | end 114 | end 115 | 116 | context "when a multi-line message contains a command past the beginning of the message" do 117 | subject { described_class.new(robot, "```\n#{robot.mention_name}: hello\n```", source) } 118 | 119 | it "is false" do 120 | expect(subject).not_to be_a_command 121 | end 122 | end 123 | 124 | it "is false when the message is not addressed to the Robot" do 125 | expect(subject).not_to be_a_command 126 | end 127 | end 128 | 129 | describe "#user" do 130 | it "delegates to #source" do 131 | expect(subject.source).to receive(:user) 132 | subject.user 133 | end 134 | end 135 | 136 | describe "#room_object" do 137 | it "delegates to #source" do 138 | expect(subject.source).to receive(:room_object) 139 | subject.room_object 140 | end 141 | end 142 | 143 | describe "#private_message?" do 144 | it "delegates to #source" do 145 | expect(subject.source).to receive(:private_message?) 146 | subject.private_message? 147 | end 148 | end 149 | 150 | describe "#reply" do 151 | it "sends strings back to the source through the robot" do 152 | expect(robot).to receive(:send_messages).with(source, "foo", "bar") 153 | subject.reply("foo", "bar") 154 | end 155 | end 156 | 157 | describe "#reply_privately" do 158 | it "sends strings directly to the source user" do 159 | subject = described_class.new( 160 | robot, 161 | "Hello", 162 | Lita::Source.new(user: "Carl", room: "#room") 163 | ) 164 | expect(robot).to receive(:send_messages) do |source, *strings| 165 | expect(source).to be_a_private_message 166 | expect(strings).to eq(%w[foo bar]) 167 | end 168 | subject.reply_privately("foo", "bar") 169 | end 170 | end 171 | 172 | describe "#reply_with_mention" do 173 | it "prefixes strings with a user mention and sends them back to the source" do 174 | expect(robot).to receive(:send_messages_with_mention).with(source, "foo", "bar") 175 | subject.reply_with_mention("foo", "bar") 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /spec/lita/plugin_builder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::PluginBuilder, lita: true do 6 | let(:robot) { instance_double("Lita::Robot") } 7 | subject { plugin.new(robot) } 8 | 9 | describe "#build_adapter" do 10 | let(:builder) do 11 | described_class.new(:test_adapter) do 12 | def run 13 | self.class.namespace 14 | end 15 | end 16 | end 17 | 18 | let(:plugin) { builder.build_adapter } 19 | 20 | it "builds an adapter" do 21 | expect(subject.run).to eq("test_adapter") 22 | end 23 | end 24 | 25 | describe "#build_handler" do 26 | builder = described_class.new(:test_handler) do 27 | route(/namespace/) { |response| response.reply(self.class.namespace) } 28 | end 29 | 30 | plugin = builder.build_handler 31 | 32 | describe plugin, lita_handler: true do 33 | before { registry.register_handler(plugin) } 34 | 35 | it "builds a handler from a block" do 36 | send_message("namespace") 37 | expect(replies.last).to eq("test_handler") 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/lita/response_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Response do 6 | subject { described_class.new(message, instance_double("Regexp")) } 7 | 8 | let(:message) { instance_double("Lita::Message").as_null_object } 9 | 10 | %i[args reply reply_privately reply_with_mention user command?].each do |method| 11 | it "delegates :#{method} to #message" do 12 | expect(message).to receive(method) 13 | subject.public_send(method) 14 | end 15 | end 16 | 17 | describe "#matches" do 18 | it "matches the pattern against the message" do 19 | expect(message).to receive(:match).with(subject.pattern) 20 | subject.matches 21 | end 22 | end 23 | 24 | describe "#match_data" do 25 | let(:body) { instance_double("String") } 26 | 27 | it "matches the message body against the pattern" do 28 | allow(message).to receive(:body).and_return(body) 29 | expect(subject.pattern).to receive(:match).with(message.body) 30 | subject.match_data 31 | end 32 | end 33 | 34 | describe "#extensions" do 35 | it "can be populated with arbitrary data" do 36 | subject.extensions[:foo] = :bar 37 | 38 | expect(subject.extensions[:foo]).to eq(:bar) 39 | end 40 | end 41 | 42 | describe "#user" do 43 | it "delegates to #message" do 44 | expect(subject.message).to receive(:user) 45 | subject.user 46 | end 47 | end 48 | 49 | describe "#room" do 50 | it "delegates to #message" do 51 | expect(subject.message).to receive(:room_object) 52 | subject.room 53 | end 54 | end 55 | 56 | describe "#private_message?" do 57 | it "delegates to #message" do 58 | expect(subject.message).to receive(:private_message?) 59 | subject.private_message? 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/lita/room_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Room, lita: true do 6 | describe ".create_or_update" do 7 | subject { described_class.find_by_id(1) } 8 | 9 | context "when no room with the given ID already exists" do 10 | it "creates the room" do 11 | described_class.create_or_update(1, name: "foo") 12 | 13 | expect(subject.name).to eq("foo") 14 | end 15 | end 16 | 17 | context "when a room with the given ID already exists" do 18 | before { described_class.create_or_update(1, name: "foo") } 19 | 20 | it "merges in new metadata" do 21 | described_class.create_or_update(1, foo: "bar") 22 | 23 | expect(subject.name).to eq("foo") 24 | expect(subject.metadata["foo"]).to eq("bar") 25 | end 26 | end 27 | end 28 | 29 | describe ".find_by_id" do 30 | context "when a matching room exists" do 31 | before { described_class.new(1).save } 32 | 33 | it "is found by ID" do 34 | expect(described_class.find_by_id(1).id).to eq("1") 35 | end 36 | end 37 | 38 | context "when no matching room exists" do 39 | it "is not found" do 40 | expect(described_class.find_by_id(1)).to be_nil 41 | end 42 | end 43 | end 44 | 45 | describe ".find_by_name" do 46 | context "when a matching room exists" do 47 | before { described_class.new(1, name: "foo").save } 48 | 49 | it "is found by name" do 50 | expect(described_class.find_by_name("foo").id).to eq("1") 51 | end 52 | end 53 | 54 | context "when no matching room exists" do 55 | it "is not found" do 56 | expect(described_class.find_by_name("foo")).to be_nil 57 | end 58 | end 59 | end 60 | 61 | describe ".fuzzy_find" do 62 | context "when a matching room exists" do 63 | before { described_class.new(1, name: "foo").save } 64 | 65 | it "is found by ID" do 66 | expect(described_class.fuzzy_find(1).id).to eq("1") 67 | end 68 | 69 | it "is found by name" do 70 | expect(described_class.fuzzy_find("foo").id).to eq("1") 71 | end 72 | end 73 | 74 | context "when no matching room exists" do 75 | it "is not found by ID" do 76 | expect(described_class.fuzzy_find(1)).to be_nil 77 | end 78 | 79 | it "is not found by name" do 80 | expect(described_class.fuzzy_find("foo")).to be_nil 81 | end 82 | end 83 | end 84 | 85 | context "with only an ID" do 86 | subject { described_class.new(1) } 87 | 88 | it "has a string ID" do 89 | expect(subject.id).to eq("1") 90 | end 91 | 92 | it "is named with its ID" do 93 | expect(subject.name).to eq("1") 94 | end 95 | end 96 | 97 | context "with metadata" do 98 | subject { described_class.new(1, foo: :bar) } 99 | 100 | it "stores the metadata with string keys" do 101 | expect(subject.metadata["foo"]).to eq(:bar) 102 | end 103 | end 104 | 105 | describe "#==" do 106 | subject { described_class.new(1) } 107 | 108 | context "when the other room has the same ID" do 109 | let(:other) { described_class.new(1) } 110 | 111 | it "is equal" do 112 | expect(subject).to eq(other) 113 | end 114 | end 115 | 116 | context "when the other room has a different ID" do 117 | let(:other) { described_class.new(2) } 118 | 119 | it "is not equal" do 120 | expect(subject).not_to eq(other) 121 | end 122 | end 123 | end 124 | 125 | describe "#save" do 126 | context "with metadata not including name" do 127 | subject { described_class.new(1, {}) } 128 | 129 | it "adds the name to the metadata" do 130 | subject.save 131 | 132 | expect(subject.metadata["name"]).to eq("1") 133 | end 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /spec/lita/rspec/handler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | handler_class = Class.new(Lita::Handler) do 6 | namespace "testclass" 7 | 8 | def self.name 9 | "Lita::Handlers::Test" 10 | end 11 | end 12 | 13 | additional_handler_class = Class.new(Lita::Handler) do 14 | namespace "testclass" 15 | 16 | config :test_property, type: String, default: "a string" 17 | 18 | def self.name 19 | "Lita::Handlers::TestBase" 20 | end 21 | end 22 | 23 | describe handler_class, lita_handler: true, additional_lita_handlers: additional_handler_class do 24 | context 'when the "additional_lita_handlers" metadata is provided' do 25 | it "loads additional handlers into the registry" do 26 | expect(registry.handlers).to include(additional_handler_class) 27 | end 28 | 29 | it "populates config from additional handlers" do 30 | expect(registry.config.handlers.testclass.test_property).to eq("a string") 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/lita/rspec_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | handler_class = Class.new(Lita::Handler) do 6 | route(/^message$/, :message) 7 | route(/^channel$/, :channel) 8 | route(/^private message$/, :private_message) 9 | route(/^command$/, :command, command: true) 10 | route("restricted", :restricted, restrict_to: :some_group) 11 | route("admins only", :admins_only, restrict_to: :admins) 12 | 13 | http.get "web", :web 14 | 15 | on :connected, :greet 16 | 17 | def message(response) 18 | response.reply(response.user.name) 19 | end 20 | 21 | def channel(response) 22 | if (room = response.message.source.room_object) 23 | response.reply(room.id) 24 | response.reply(room.name) 25 | else 26 | response.reply("No room") 27 | end 28 | end 29 | 30 | def private_message(response) 31 | if response.private_message? 32 | response.reply("Private") 33 | else 34 | response.reply("Public") 35 | end 36 | end 37 | 38 | def command(response) 39 | response.reply("a", "command") 40 | end 41 | 42 | def restricted(_response); end 43 | 44 | def web(_request, _response); end 45 | 46 | def greet(_payload); end 47 | 48 | def self.name 49 | "Lita::Handlers::Test" 50 | end 51 | end 52 | 53 | describe handler_class, lita_handler: true do 54 | describe "routing messages" do 55 | it { is_expected.to route("message") } 56 | it { is_expected.to route("message").to(:message) } 57 | it { is_expected.not_to route("message").to(:not_a_message) } 58 | end 59 | 60 | describe "routing channels" do 61 | it { is_expected.to route("channel") } 62 | it { is_expected.to route("channel").to(:channel) } 63 | it { is_expected.not_to route("channel").to(:not_a_channel) } 64 | end 65 | 66 | describe "routing commands" do 67 | it { is_expected.to route_command("command") } 68 | it { is_expected.not_to route("command") } 69 | it { is_expected.not_to route_command("not a command") } 70 | it { is_expected.to route_command("command").to(:command) } 71 | it { is_expected.not_to route_command("command").to(:not_a_command) } 72 | end 73 | 74 | describe "routing to restricted routes" do 75 | it { is_expected.not_to route("restricted") } 76 | it { is_expected.to route("restricted").with_authorization_for(:some_group) } 77 | it { is_expected.not_to route("restricted").with_authorization_for(:wrong_group) } 78 | it { is_expected.to route("admins only").with_authorization_for(:admins) } 79 | it { is_expected.to route("restricted").with_authorization_for(:some_group).to(:restricted) } 80 | it { is_expected.not_to route("restricted").with_authorization_for(:some_group).to(:nothing) } 81 | end 82 | 83 | describe "routing HTTP routes" do 84 | it { is_expected.to route_http(:get, "web") } 85 | it { is_expected.to route_http(:get, "web").to(:web) } 86 | it { is_expected.not_to route_http(:get, "web").to(:message) } 87 | it { is_expected.not_to route_http(:post, "web") } 88 | end 89 | 90 | describe "routing events" do 91 | it { is_expected.to route_event(:connected) } 92 | it { is_expected.to route_event(:connected).to(:greet) } 93 | it { is_expected.not_to route_event(:not_an_event) } 94 | it { is_expected.not_to route_event(:connected).to(:message) } 95 | end 96 | 97 | describe "#message" do 98 | it "replies with a string" do 99 | send_message("message") 100 | expect(replies).to eq(["Test User"]) 101 | end 102 | 103 | it "does not memoize #replies on first access" do 104 | replies 105 | send_message("message") 106 | expect(replies).to eq(["Test User"]) 107 | end 108 | end 109 | 110 | describe "#channel" do 111 | it "replies with channel id if sent from room" do 112 | room = Lita::Room.create_or_update(1, name: "Room") 113 | send_message("channel", from: room) 114 | expect(replies).to eq(%w[1 Room]) 115 | end 116 | 117 | it "replies with no channel if not sent from room" do 118 | send_message("channel") 119 | expect(replies).to eq(["No room"]) 120 | end 121 | end 122 | 123 | describe "#private_message" do 124 | let(:another_user) do 125 | Lita::User.create(2, name: "Another User") 126 | end 127 | 128 | let(:room) do 129 | Lita::Room.create_or_update(1, name: "Room") 130 | end 131 | 132 | it "replies with Private in response to a private message" do 133 | send_message("private message", as: another_user, privately: true) 134 | expect(source).to be_a_private_message 135 | expect(replies.last).to eq("Private") 136 | end 137 | 138 | it "replies with Private in response to a private command" do 139 | send_command("private message", as: another_user, privately: true) 140 | expect(source).to be_a_private_message 141 | expect(replies.last).to eq("Private") 142 | end 143 | 144 | it "replies with Public in response to a public message" do 145 | send_message("private message", as: another_user, from: room) 146 | expect(replies.last).to eq("Public") 147 | end 148 | end 149 | 150 | describe "#command" do 151 | it "replies with two strings" do 152 | send_command("command") 153 | expect(replies).to eq(%w[a command]) 154 | end 155 | end 156 | 157 | it "allows the sending user to be specified" do 158 | another_user = Lita::User.create(2, name: "Another User") 159 | send_message("message", as: another_user) 160 | expect(replies.last).to eq("Another User") 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /spec/lita/source_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Source do 6 | subject { described_class.new(user: user, room: room, private_message: pm) } 7 | 8 | let(:pm) { false } 9 | let(:room) { Lita::Room.new(1) } 10 | let(:user) { Lita::User.new(1) } 11 | 12 | describe "#room" do 13 | it "returns the room as a string" do 14 | expect(subject.room).to eq("1") 15 | end 16 | end 17 | 18 | describe "#room_object" do 19 | it "returns the room as a Lita::Room" do 20 | expect(subject.room_object).to eq(room) 21 | end 22 | end 23 | 24 | describe "#user" do 25 | it "returns the user object" do 26 | expect(subject.user).to eq(user) 27 | end 28 | end 29 | 30 | context "when the private_message argument is true" do 31 | let(:pm) { true } 32 | 33 | it "is marked as a private message" do 34 | expect(subject).to be_a_private_message 35 | end 36 | end 37 | 38 | it "can be manually marked as private" do 39 | subject.private_message! 40 | 41 | expect(subject).to be_a_private_message 42 | end 43 | 44 | context "with a string for the room argument" do 45 | let(:room) { "#channel" } 46 | 47 | it "sets #room to the string" do 48 | expect(subject.room).to eq(room) 49 | end 50 | 51 | it "sets #room_object to a Lita::Room with the string as its ID" do 52 | expect(subject.room_object).to eq(Lita::Room.new(room)) 53 | end 54 | 55 | context "room exists in database" do 56 | let(:name) { "example" } 57 | 58 | it "finds room by its ID" do 59 | Lita::Room.create_or_update(room, name: name) 60 | expect(subject.room_object.name).to eq(name) 61 | end 62 | end 63 | end 64 | 65 | it "requires either a user or a room" do 66 | expect { described_class.new }.to raise_error(ArgumentError) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/lita/store_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Store do 6 | it "has nil values by default" do 7 | expect(subject[:foo]).to be_nil 8 | end 9 | 10 | it "sets and gets values" do 11 | subject[:foo] = :bar 12 | 13 | expect(subject[:foo]).to eq(:bar) 14 | end 15 | 16 | it "allows a custom internal store" do 17 | subject = described_class.new(Hash.new { |h, k| h[k] = described_class.new }) 18 | 19 | subject[:foo][:bar] = :baz 20 | 21 | expect(subject[:foo][:bar]).to eq(:baz) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/lita/template_resolver_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::TemplateResolver do 6 | subject do 7 | described_class.new(template_root, template_name, adapter_name) 8 | end 9 | 10 | let(:adapter_name) { :shell } 11 | let(:generic_template) { File.join(template_root, "basic.erb") } 12 | let(:irc_template) { File.join(template_root, "basic.irc.erb") } 13 | let(:template_name) { "basic" } 14 | let(:template_root) { File.expand_path(File.join("..", "..", "templates"), __FILE__) } 15 | 16 | describe "#resolve" do 17 | context "when there is a template for the adapter" do 18 | let(:adapter_name) { :irc } 19 | 20 | it "returns the path to the adapter-specific template" do 21 | expect(subject.resolve).to eq(irc_template) 22 | end 23 | end 24 | 25 | context "when there is no template for the adapter" do 26 | it "returns the path for the generic template" do 27 | expect(subject.resolve).to eq(generic_template) 28 | end 29 | end 30 | 31 | context "when there is no template with the given name" do 32 | let(:template_name) { "nonexistent" } 33 | 34 | it "raises an exception" do 35 | expect { subject.resolve }.to raise_error( 36 | Lita::MissingTemplateError, 37 | %r{templates/nonexistent\.erb} 38 | ) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/lita/template_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Template do 6 | describe ".from_file" do 7 | context "with a path to an ERB template" do 8 | subject do 9 | described_class.from_file(File.expand_path("../templates/basic.erb", __dir__)) 10 | end 11 | 12 | it "uses the source in the file" do 13 | expect(subject.render).to eq("Template rendered from a file!") 14 | end 15 | end 16 | end 17 | 18 | describe "#add_helper" do 19 | subject { described_class.new("<%= reverse_name(@first, @last) %>") } 20 | let(:helper) do 21 | Module.new do 22 | def reverse_name(first, last) 23 | "#{last}, #{first}" 24 | end 25 | end 26 | end 27 | 28 | it "adds the helper to the evaluation context" do 29 | subject.add_helper(helper) 30 | 31 | expect(subject.render(first: "Carl", last: "Pug")).to eq("Pug, Carl") 32 | end 33 | end 34 | 35 | describe "#render" do 36 | context "with a static source template" do 37 | subject { described_class.new("Hello, Lita!") } 38 | 39 | it "renders the text" do 40 | expect(subject.render).to eq("Hello, Lita!") 41 | end 42 | end 43 | 44 | context "with interpolation variables" do 45 | subject { described_class.new("Hello, <%= @name %>!") } 46 | 47 | it "renders the text with interpolated values" do 48 | expect(subject.render(name: "Carl")).to eq("Hello, Carl!") 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/lita/timer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Timer do 6 | let(:queue) { Queue.new } 7 | 8 | before { allow(subject).to receive(:sleep) } 9 | 10 | after { subject.stop } 11 | 12 | it "runs single timers" do 13 | subject = described_class.new { queue.push(true) } 14 | expect(subject).to receive(:sleep).with(0).once 15 | subject.start 16 | expect(queue.pop(true)).to be(true) 17 | expect { queue.pop(true) }.to raise_error(ThreadError) 18 | end 19 | 20 | it "runs recurring timers" do 21 | halt = false 22 | subject = described_class.new(interval: 1, recurring: true) do |timer| 23 | queue.push(true) 24 | timer.stop if halt 25 | halt = true 26 | end 27 | expect(subject).to receive(:sleep).with(1).twice 28 | subject.start 29 | 2.times { expect(queue.pop(true)).to be(true) } 30 | expect { queue.pop(true) }.to raise_error(ThreadError) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lita/user_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::User, lita: true do 6 | describe ".create" do 7 | it "creates and returns new users" do 8 | user = described_class.create(1, name: "Carl") 9 | expect(user.id).to eq("1") 10 | expect(user.name).to eq("Carl") 11 | persisted_user = described_class.find_by_id(1) 12 | expect(user).to eq(persisted_user) 13 | end 14 | 15 | it "returns existing users" do 16 | described_class.create(1, name: "Carl") 17 | user = described_class.find_by_id(1) 18 | expect(user.id).to eq("1") 19 | expect(user.name).to eq("Carl") 20 | end 21 | 22 | it "merges and saves new metadata for existing users" do 23 | described_class.create(1, name: "Carl") 24 | described_class.create(1, name: "Mr. Carl", foo: "bar") 25 | user = described_class.find_by_id(1) 26 | expect(user.name).to eq("Mr. Carl") 27 | expect(user.metadata["foo"]).to eq("bar") 28 | end 29 | end 30 | 31 | describe ".find_by_id" do 32 | it "finds users with no metadata stored" do 33 | described_class.create(1) 34 | user = described_class.find_by_id(1) 35 | expect(user.id).to eq("1") 36 | end 37 | end 38 | 39 | describe ".find_by_mention_name" do 40 | it "returns nil if no user matches the provided mention name" do 41 | expect(described_class.find_by_mention_name("carlthepug")).to be_nil 42 | end 43 | 44 | it "returns a user that matches the provided mention name" do 45 | described_class.create(1, mention_name: "carlthepug") 46 | user = described_class.find_by_mention_name("carlthepug") 47 | expect(user.id).to eq("1") 48 | end 49 | end 50 | 51 | describe ".find_by_name" do 52 | it "returns nil if no user matches the provided name" do 53 | expect(described_class.find_by_name("Carl")).to be_nil 54 | end 55 | 56 | it "returns existing users" do 57 | described_class.create(1, name: "Carl") 58 | user = described_class.find_by_name("Carl") 59 | expect(user.id).to eq("1") 60 | end 61 | end 62 | 63 | describe ".find_by_partial_name" do 64 | before { described_class.create(1, name: "José Vicente Cuadra") } 65 | 66 | it "finds users by partial name match" do 67 | user = described_class.find_by_partial_name("José") 68 | expect(user.id).to eq("1") 69 | end 70 | 71 | it "returns nil if no users' names start with the provided string" do 72 | expect(described_class.find_by_partial_name("Foo")).to be_nil 73 | end 74 | 75 | it "returns nil if more than one match was found" do 76 | described_class.create(2, name: "José Contreras") 77 | expect(described_class.find_by_partial_name("José")).to be_nil 78 | end 79 | end 80 | 81 | describe ".fuzzy_find" do 82 | let!(:user) { described_class.create(1, name: "Carl the Pug", mention_name: "carlthepug") } 83 | 84 | it "finds by ID" do 85 | expect(described_class.fuzzy_find(1)).to eq(user) 86 | end 87 | 88 | it "finds by mention name" do 89 | expect(described_class.fuzzy_find("carlthepug")).to eq(user) 90 | end 91 | 92 | it "finds by name" do 93 | expect(described_class.fuzzy_find("Carl the Pug")).to eq(user) 94 | end 95 | 96 | it "finds by partial mention name" do 97 | expect(described_class.fuzzy_find("Carl")).to eq(user) 98 | end 99 | end 100 | 101 | describe "#mention_name" do 102 | it "returns the user's mention name from metadata" do 103 | subject = described_class.new(1, name: "Carl", mention_name: "carlthepug") 104 | expect(subject.mention_name).to eq("carlthepug") 105 | end 106 | 107 | it "returns the user's name if there is no mention name in the metadata" do 108 | subject = described_class.new(1, name: "Carl") 109 | expect(subject.mention_name).to eq("Carl") 110 | end 111 | end 112 | 113 | describe "#save" do 114 | subject { described_class.new(1, name: "Carl", mention_name: "carlthepug") } 115 | 116 | it "saves an ID to name mapping for the user in Redis" do 117 | subject.save 118 | expect(described_class.redis.hgetall("id:1")).to include("name" => "Carl") 119 | end 120 | 121 | it "saves a name to ID mapping for the user in Redis" do 122 | subject.save 123 | expect(described_class.redis.get("name:Carl")).to eq("1") 124 | end 125 | 126 | it "saves a mention name to ID mapping for the user in Redis" do 127 | subject.save 128 | expect(described_class.redis.get("mention_name:carlthepug")).to eq("1") 129 | end 130 | 131 | context "when a key is deleted" do 132 | before do 133 | subject.metadata["example"] = "hello" 134 | subject.save 135 | expect(described_class.redis.hkeys("id:1")).to include("example") 136 | subject.metadata.delete("example") 137 | subject.save 138 | end 139 | 140 | it "deletes that key from redis" do 141 | expect(described_class.redis.hkeys("id:1")).not_to include("example") 142 | end 143 | end 144 | end 145 | 146 | describe "equality" do 147 | it "considers two users equal if they share an ID and name" do 148 | user_1 = described_class.new(1, name: "Carl") 149 | user_2 = described_class.new(1, name: "Carl") 150 | expect(user_1).to eq(user_2) 151 | expect(user_1).to eql(user_2) 152 | end 153 | 154 | it "doesn't assume the comparison object is a Lita::User" do 155 | user = described_class.new(1, name: "Carl") 156 | expect(user).not_to eq("not a Lita::User object") 157 | expect(user).not_to eql("not a Lita::User object") 158 | end 159 | 160 | it "consistently hashes equal users" do 161 | user_1 = described_class.new(1, name: "Carl") 162 | user_2 = described_class.new(1, name: "Carl") 163 | 164 | expect(user_1.hash).to eq(user_2.hash) 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /spec/lita/util_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::Util do 6 | describe ".stringify_keys" do 7 | it "converts symbol hash keys to strings" do 8 | stringified = described_class.stringify_keys(foo: "bar") 9 | expect(stringified).to eq("foo" => "bar") 10 | end 11 | end 12 | 13 | describe ".underscore" do 14 | it "converts camel cased strings into snake case" do 15 | expect(described_class.underscore("FooBarBaz")).to eq("foo_bar_baz") 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Generate code coverage metrics outside CI. 4 | unless ENV["CI"] 5 | require "simplecov" 6 | SimpleCov.start { add_filter "/spec/" } 7 | end 8 | 9 | require "pry" 10 | require "lita/rspec" 11 | 12 | RSpec.configure do |config| 13 | config.mock_with :rspec do |mocks_config| 14 | mocks_config.verify_doubled_constant_names = true 15 | mocks_config.verify_partial_doubles = true 16 | end 17 | 18 | # Lita calls `exit(false)` in a few places. If an RSpec example hits one of these calls and it 19 | # wasn't explicitly stubbed, the example will stop at exactly that point, but will be reported by 20 | # RSpec as having passed, and will also change RSpec's exit code to 1. This situation indicates 21 | # either a missing stub or a real bug, so we catch it here and fail loudly. 22 | # 23 | # https://github.com/rspec/rspec-core/issues/2246 24 | config.around do |example| 25 | example.run 26 | rescue SystemExit => e 27 | raise <<~ERROR 28 | Unhandled SystemExit! This will cause RSpec to exit 1 but show the example as passing!" 29 | 30 | Full backtrace: 31 | 32 | #{e.backtrace.join("\n")} 33 | ERROR 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/templates/basic.erb: -------------------------------------------------------------------------------- 1 | Template rendered from a file! 2 | -------------------------------------------------------------------------------- /spec/templates/basic.irc.erb: -------------------------------------------------------------------------------- 1 | IRC template rendered from a file! 2 | -------------------------------------------------------------------------------- /spec/templates/helpers.erb: -------------------------------------------------------------------------------- 1 | <%= reverse_name(@first, @last) %> 2 | -------------------------------------------------------------------------------- /spec/templates/interpolated.erb: -------------------------------------------------------------------------------- 1 | I love <%= @first %> <%= @last %>! 2 | -------------------------------------------------------------------------------- /templates/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | lita: 3 | adapters: 4 | shell: 5 | startup_message: Type "exit" or "quit" to end the session. 6 | core: 7 | register_adapter: 8 | block_or_class_required: Lita.register_adapter requires a class in its two argument form. 9 | register_handler: 10 | block_or_class_required: Lita.register_handler requires a class in its single argument form. 11 | adapter: 12 | method_not_implemented: "This adapter has not implemented #%{method}." 13 | cli: 14 | no_gemfile_warning: >- 15 | The default command "start" must be run inside a Lita project. Try running `lita new` to 16 | generate a new Lita project or `lita help` to see all commands. 17 | license_notice: >- 18 | If you plan to release this plugin as open source software, consider adding a LICENSE file 19 | to the root of the repository. 20 | 21 | Common open source software licenses can be found at https://choosealicense.com/. 22 | github_user_question: What is your GitHub username? 23 | config: 24 | exception: | 25 | Lita configuration file could not be processed. The exception was: 26 | %{message} 27 | Full backtrace: 28 | %{backtrace} 29 | locale_deprecated: >- 30 | `config.robot.locale` and `config.robot.default_locale` are deprecated and will be removed 31 | in Lita 6.0. Use the environment variable LC_ALL, LC_MESSAGES, or LANG to set the program's 32 | locale instead. 33 | missing_required_adapter_attribute: >- 34 | Configuration attribute "%{attribute}" is required for "%{adapter}" adapter. 35 | missing_required_handler_attribute: >- 36 | Configuration attribute "%{attribute}" is required for "%{handler}" handler. 37 | type_error: >- 38 | Configuration type error: "%{attribute}" must be one of: %{types}. 39 | validation_error: >- 40 | Validation error on attribute "%{attribute}": %{message} 41 | handler: 42 | dispatch: "Dispatching message to %{handler}#%{method}." 43 | exception: | 44 | %{handler} crashed. The exception was: 45 | %{message} 46 | Full backtrace: 47 | %{backtrace} 48 | handlers: 49 | authorization: 50 | help: 51 | add_key: auth add USER GROUP 52 | add_value: Add USER to authorization group GROUP. Requires admin privileges. 53 | remove_key: auth remove USER GROUP 54 | remove_value: Remove USER from authorization group GROUP. Requires admin privileges. 55 | list_key: "auth list [GROUP]" 56 | list_value: >- 57 | List authorization groups and the users in them. If GROUP is supplied, 58 | only lists that group. 59 | user_added: "%{user} was added to %{group}." 60 | user_already_in: "%{user} was already in %{group}." 61 | user_removed: "%{user} was removed from %{group}." 62 | user_not_in: "%{user} was not in %{group}." 63 | empty_state: There are no authorization groups yet. 64 | empty_state_group: "There is no authorization group named %{group}." 65 | format: "Format" 66 | admin_management: Administrators can only be managed via Lita config. 67 | no_user_found: 'No user was found with the identifier "%{identifier}".' 68 | help: 69 | handler_contains: Handler "%{handler}" defines the following commands 70 | help: 71 | help_value: Lists help information for terms and command the robot will respond to. 72 | help_query_key: "help QUERY" 73 | help_query_value: Lists help information for commands matching QUERY. 74 | info: >- 75 | Send the message "%{address}help QUERY" to see matching help messages. QUERY may be the 76 | name of a handler, text matching a message pattern a handler responds to, or text 77 | matching the description of a message pattern a handler responds to. The following 78 | handlers are installed: 79 | no_help_found: "No matching handlers, message patterns, or descriptions found." 80 | pattern_or_description_contains: Message patterns or descriptions that match "%{query}" 81 | unauthorized: " [Unauthorized]" 82 | info: 83 | help: 84 | info_value: Replies with the current version of Lita. 85 | room: 86 | help: 87 | join_key: join ROOM_ID 88 | join_value: Makes the robot join the room with room ID ROOM_ID. 89 | part_key: part ROOM_ID 90 | part_value: Makes the robot part from the room with room ID ROOM_ID. 91 | users: 92 | find_empty_state: No matching users found. 93 | help: 94 | find_key: users find SEARCH_TERM 95 | find_value: Find a Lita user by ID, name, or mention name. 96 | http: 97 | exception: | 98 | Lita's built-in web server could not be started. The exception was: 99 | %{message} 100 | Full backtrace: 101 | %{backtrace} 102 | plugin: 103 | name_required: Plugins that are anonymous classes must set a namespace or define self.name. 104 | redis: 105 | exception: | 106 | Lita could not connect to Redis. The exception was: 107 | %{message} 108 | Full backtrace: 109 | %{backtrace} 110 | test_mode_exception: | 111 | Lita could not connect to Redis. The exception was: 112 | %{message} 113 | robot: 114 | unknown_adapter: "Unknown adapter: :%{adapter}." 115 | rspec: 116 | full_suite_required: Lita::RSpec requires both RSpec::Mocks and RSpec::Expectations. 117 | version_3_required: RSpec::Core 3 or greater is required to use Lita::RSpec. 118 | rack_test_required: Rack::Test is required to use the `http` method of Lita::RSpec. 119 | lita_3_compatibility_mode: >- 120 | WARNING: Lita 3 compatibility mode is deprecated in Lita 5 and will be removed in Lita 6. 121 | It no longer has any effect. Please remove any calls to `Lita.version_3_compatibility_mode`. 122 | route_failure: |- 123 | Expected message "%{message}" to route to :%{route}, but didn't. 124 | negative_route_failure: |- 125 | Expected message "%{message}" not to route to :%{route}, but did. 126 | http_route_failure: |- 127 | Expected request "%{method} %{path}" to route to :%{route}, but didn't. 128 | negative_http_route_failure: |- 129 | Expected request "%{method} %{path}" not to route to :%{route}, but did. 130 | event_subscription_failure: |- 131 | Expected triggering event "%{event}" to invoke :%{route}, but didn't. 132 | negative_event_subscription_failure: |- 133 | Expected triggering event "%{event}" not to invoke :%{route}, but did. 134 | source: 135 | user_or_room_required: Either a user or a room is required. 136 | template: 137 | missing_template: Missing template file at %{path}. 138 | -------------------------------------------------------------------------------- /templates/plugin/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /templates/plugin/README.tt: -------------------------------------------------------------------------------- 1 | # <%= config[:gem_name] %> 2 | 3 | TODO: Add a description of the plugin. 4 | 5 | ## Installation 6 | 7 | <%- if config[:plugin_type] == "extension" -%> 8 | Add <%= config[:gem_name] %> to your Lita plugin's gemspec: 9 | 10 | ``` ruby 11 | spec.add_runtime_dependency "<%= config[:gem_name] %>" 12 | ``` 13 | <%- else -%> 14 | Add <%= config[:gem_name] %> to your Lita instance's Gemfile: 15 | 16 | ``` ruby 17 | gem "<%= config[:gem_name] %>" 18 | ``` 19 | <%- end -%> 20 | 21 | <%- unless config[:plugin_type] == "extension" -%> 22 | ## Configuration 23 | 24 | TODO: Describe any configuration attributes the plugin exposes. 25 | 26 | <%- end -%> 27 | ## Usage 28 | 29 | TODO: Describe the plugin's features and how to use them. 30 | -------------------------------------------------------------------------------- /templates/plugin/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /templates/plugin/gemspec.tt: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |spec| 2 | spec.name = "<%= config[:gem_name] %>" 3 | spec.version = "0.1.0" 4 | spec.authors = ["<%= config[:author] %>"] 5 | spec.email = ["<%= config[:email] %>"] 6 | spec.description = "TODO: Add a description" 7 | spec.summary = "TODO: Add a summary" 8 | spec.homepage = "TODO: Add a homepage" 9 | spec.license = "TODO: Add a license" 10 | spec.metadata = { "lita_plugin_type" => "<%= config[:plugin_type] %>" } 11 | 12 | spec.files = `git ls-files`.split($/) 13 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 14 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 15 | spec.require_paths = ["lib"] 16 | 17 | spec.add_runtime_dependency "lita", ">= <%= config[:required_lita_version] %>" 18 | 19 | spec.add_development_dependency "bundler", "~> 2.0" 20 | if RUBY_PLATFORM != 'java' 21 | spec.add_development_dependency "pry-byebug" 22 | end 23 | spec.add_development_dependency "rake" 24 | spec.add_development_dependency "rack-test" 25 | spec.add_development_dependency "rspec" 26 | spec.add_development_dependency "simplecov" 27 | end 28 | -------------------------------------------------------------------------------- /templates/plugin/gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | lita_config.rb 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | -------------------------------------------------------------------------------- /templates/plugin/lib/lita/plugin_type/plugin.tt: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Lita 4 | module <%= config[:constant_namespace] %> 5 | class <%= config[:constant_name] %><% unless config[:plugin_type] == "extension" %> < <%= config[:plugin_type].capitalize %><% end %> 6 | # insert <%= config[:plugin_type] %> code here 7 | 8 | <%- if config[:plugin_type] == "adapter" -%> 9 | Lita.register_adapter(:<%= config[:name] %>, self) 10 | <%- elsif config[:plugin_type] == "handler" -%> 11 | Lita.register_handler(self) 12 | <%- else -%> 13 | # If your extension needs to register with a Lita hook, uncomment the 14 | # following line and change the hook name to the appropriate value: 15 | # Lita.register_hook(:hook_name, self) 16 | <%- end -%> 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /templates/plugin/lib/plugin.tt: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "lita" 4 | 5 | Lita.load_locales Dir[File.expand_path( 6 | File.join("..", "..", "locales", "*.yml"), __FILE__ 7 | )] 8 | 9 | require "lita/<%= config[:namespace] %>/<%= config[:name] %>" 10 | <%- if config[:plugin_type] == "handler" -%> 11 | 12 | Lita::<%= config[:constant_namespace] %>::<%= config[:constant_name] %>.template_root File.expand_path( 13 | File.join("..", "..", "templates"), 14 | __FILE__ 15 | ) 16 | <%- end -%> 17 | -------------------------------------------------------------------------------- /templates/plugin/locales/en.yml.tt: -------------------------------------------------------------------------------- 1 | en: 2 | lita: 3 | <%= config[:namespace] %>: 4 | <%= config[:name] %>: 5 | -------------------------------------------------------------------------------- /templates/plugin/spec/lita/plugin_type/plugin_spec.tt: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Lita::<%= config[:constant_namespace] %>::<%= config[:constant_name] %>, <%= config[:spec_type] %>: true do 6 | end 7 | -------------------------------------------------------------------------------- /templates/plugin/spec/spec_helper.tt: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | require "coveralls" 5 | SimpleCov.start { add_filter "/spec/" } 6 | 7 | require "<%= config[:gem_name] %>" 8 | require "lita/rspec" 9 | -------------------------------------------------------------------------------- /templates/plugin/templates/gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litaio/lita/4ae72372bc31495511abfbcda361ccda415bc145/templates/plugin/templates/gitkeep -------------------------------------------------------------------------------- /templates/robot/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "lita" 6 | -------------------------------------------------------------------------------- /templates/robot/lita_config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Lita.configure do |config| 4 | # The name your robot will use. 5 | config.robot.name = "Lita" 6 | 7 | ## An array identifiers for users who are considered administrators. These 8 | ## users have the ability to add and remove other users from authorization 9 | ## groups. What is considered a user ID will change depending on which adapter 10 | ## you use. 11 | # config.robot.admins = ["1", "2"] 12 | 13 | # The adapter you want to connect with. Make sure you've added the 14 | # appropriate gem to the Gemfile. 15 | config.robot.adapter = :shell 16 | 17 | ## Example: Set options for the chosen adapter. 18 | # config.adapter.username = "myname" 19 | # config.adapter.password = "secret" 20 | 21 | ## Example: Set options for the Redis connection. 22 | # config.redis[:host] = "127.0.0.1" 23 | # config.redis[:port] = 1234 24 | 25 | ## Example: Set configuration for any loaded handlers. See the handler's 26 | ## documentation for options. 27 | # config.handlers.example_handler.example_attribute = "example value" 28 | end 29 | --------------------------------------------------------------------------------