kybus-bot 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kybus
4
+ module Bot
5
+ class ParameterSaver
6
+ def initialize(executor)
7
+ @executor = executor
8
+ end
9
+
10
+ def save_token!(message)
11
+ @executor.execution_context.last_message = message.serialize
12
+ if @executor.execution_context.expecting_command?
13
+ command = @executor.channel_factory.command(message.command)
14
+ set_command(command, message)
15
+ else
16
+ save_param!(message)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def save_param!(message)
23
+ @executor.execution_context.add_param(message.raw_message)
24
+ save_attachment!(message) if message.has_attachment?
25
+ end
26
+
27
+ def save_attachment!(message)
28
+ file = @executor.bot.provider.file_builder(message.attachment)
29
+ @executor.execution_context.add_file(file)
30
+ end
31
+
32
+ def set_command(command, message)
33
+ if @executor.inline_args && !command
34
+ search_command_with_inline_arg(message)
35
+ elsif !@executor.inline_args && !command
36
+ set_default_command
37
+ else
38
+ @executor.execution_context.command = command
39
+ end
40
+ end
41
+
42
+ def search_command_with_inline_arg(message)
43
+ command, values = @executor.channel_factory.command_with_inline_arg(message.raw_message || '')
44
+ if command
45
+ set_command_with_values(command, values)
46
+ else
47
+ set_default_command
48
+ end
49
+ end
50
+
51
+ def set_command_with_values(command, values)
52
+ @executor.execution_context.command = command
53
+ values.each do |value|
54
+ @executor.execution_context.next_param = @executor.execution_context.next_missing_param
55
+ @executor.execution_context.add_param(value)
56
+ end
57
+ end
58
+
59
+ def set_default_command
60
+ @executor.execution_context.command = @executor.channel_factory.default_command
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kybus
4
+ module Bot
5
+ class RegularCommandMatcher
6
+ def initialize(definitions)
7
+ @definitions = definitions
8
+ @matchers = build_matchers
9
+ end
10
+
11
+ def find_command(search)
12
+ @definitions.each do |name, command|
13
+ matcher = @matchers[name.class]
14
+ result = matcher&.call(name, command, search)
15
+ return result if result
16
+ end
17
+ nil
18
+ end
19
+
20
+ private
21
+
22
+ def build_matchers
23
+ {
24
+ String => method(:match_string),
25
+ Class => method(:match_class),
26
+ Regexp => method(:match_regexp)
27
+ }
28
+ end
29
+
30
+ def match_string(name, command, search)
31
+ command if name == search
32
+ end
33
+
34
+ def match_class(name, command, search)
35
+ command if search.is_a?(name)
36
+ end
37
+
38
+ def match_regexp(name, command, search)
39
+ return unless search.is_a?(String) && name.match?(search)
40
+
41
+ storable_command = command.clone
42
+ storable_command.name = search
43
+ storable_command
44
+ end
45
+ end
46
+ end
47
+ end
@@ -4,6 +4,9 @@ require 'forwardable'
4
4
  require_relative 'command/command'
5
5
  require_relative 'command/command_definition'
6
6
  require_relative 'command/execution_context'
7
+ require_relative 'dsl_methods'
8
+ require_relative 'command/command_handler'
9
+ require_relative 'command/parameter_saver'
7
10
 
8
11
  module Kybus
9
12
  module Bot
@@ -11,7 +14,7 @@ module Kybus
11
14
  extend Forwardable
12
15
 
13
16
  include Kybus::Logger
14
- attr_reader :dsl, :bot, :execution_context
17
+ attr_reader :dsl, :bot, :execution_context, :channel_factory, :inline_args, :error
15
18
 
16
19
  def_delegator :execution_context, :save!, :save_execution_context!
17
20
 
@@ -29,79 +32,32 @@ module Kybus
29
32
  @dsl = DSLMethods.new(bot.provider, state, bot)
30
33
  @inline_args = inline_args
31
34
  @precommand_hook = proc {}
35
+ @parameter_saver = ParameterSaver.new(self)
36
+ @command_handler = CommandHandler.new(self)
32
37
  end
33
38
 
34
- # Process a single message, this method can be overwriten to enable
35
- # more complex implementations of commands. It receives a message object.
36
- def process_message(message)
37
- @execution_context = ExecutionContest.new(message.channel_id, @channel_factory)
38
- save_token!(message)
39
- msg = run_command_or_prepare!
40
- save_execution_context!
41
- msg
42
- end
43
-
44
- def save_param!(message)
45
- execution_context.add_param(message.raw_message)
46
- return unless message.has_attachment?
47
-
48
- file = bot.provider.file_builder(message.attachment)
49
- execution_context.add_file(file)
50
- end
51
-
52
- def search_command_with_inline_arg(message)
53
- command, values = @channel_factory.command_with_inline_arg(message.raw_message || '')
54
- if command
55
- execution_context.command = command
56
- values.each do |value|
57
- execution_context.next_param = execution_context.next_missing_param
58
- execution_context.add_param(value)
59
- end
60
- else
61
- execution_context.command = @channel_factory.default_command
62
- end
63
- end
64
-
65
- def save_token!(message)
66
- execution_context.set_last_message(message.serialize)
67
- if execution_context.expecting_command?
68
- command = @channel_factory.command(message.command)
69
- if @inline_args && !command
70
- search_command_with_inline_arg(message)
71
- elsif !@inline_args && !command
72
- execution_context.command = @channel_factory.default_command
73
- else
74
- execution_context.command = command
75
- end
76
- else
77
- save_param!(message)
78
- end
79
- end
80
-
81
- def run_command_or_prepare!
82
- if execution_context.ready?
83
- @dsl.state = execution_context.state
84
- @dsl.instance_eval(&@precommand_hook)
85
- msg = run_command!
86
- execution_context.clear_command
87
- msg
39
+ def precommand_hook(&)
40
+ if block_given?
41
+ @precommand_hook = proc(&)
88
42
  else
89
- param = execution_context.next_missing_param
90
- ask_param(param, execution_context.state.command.params_ask_label(param))
43
+ @precommand_hook
91
44
  end
92
45
  end
93
46
 
94
- def precommand_hook(&)
95
- @precommand_hook = proc(&)
47
+ def process_message(message)
48
+ setup_execution_context(message)
49
+ @parameter_saver.save_token!(message)
50
+ msg = @command_handler.run_command_or_prepare!
51
+ save_execution_context!
52
+ msg
96
53
  end
97
54
 
98
55
  def fallback(error)
99
- catch = @channel_factory.command(error)
56
+ catch_command = @channel_factory.command(error)
100
57
  log_error('Unexpected error', error)
101
- execution_context.command = catch if catch
58
+ execution_context.command = catch_command if catch_command
102
59
  end
103
60
 
104
- # Method for triggering command
105
61
  def run_command!
106
62
  execution_context.call!(@dsl)
107
63
  rescue StandardError => e
@@ -112,28 +68,40 @@ module Kybus
112
68
  end
113
69
 
114
70
  def invoke(command, args)
115
- state.command = command
116
- command.params.zip(args).each do |param, value|
117
- state.store_param(param, value)
118
- end
119
- run_command_or_prepare!
71
+ set_state_command(command, args)
72
+ @command_handler.run_command_or_prepare!
120
73
  end
121
74
 
122
75
  def redirect(command_name, args)
123
76
  command = @channel_factory.command(command_name)
124
- if command.nil? || command.params_size != args.size
125
- raise "Wrong redirect #{command_name}, #{bot.registered_commands}"
126
- end
127
-
77
+ validate_redirect(command, command_name, args)
128
78
  invoke(command, args)
129
79
  end
130
80
 
131
- # Sends a message to get the next parameter from the user
132
81
  def ask_param(param, label = nil)
133
82
  msg = label || "I need you to tell me #{param}"
134
- bot.send_message(msg, last_message.channel_id)
83
+ bot.dsl.send_message(msg, last_message.channel_id)
135
84
  execution_context.next_param = param
136
85
  end
86
+
87
+ private
88
+
89
+ def setup_execution_context(message)
90
+ @execution_context = ExecutionContest.new(message.channel_id, @channel_factory)
91
+ end
92
+
93
+ def set_state_command(command, args)
94
+ state.command = command
95
+ command.params.zip(args).each do |param, value|
96
+ state.store_param(param, value)
97
+ end
98
+ end
99
+
100
+ def validate_redirect(command, command_name, args)
101
+ return unless command.nil? || command.params_size != args.size
102
+
103
+ raise ::Kybus::Bot::Base::BotError, "Wrong redirect #{command_name}, #{bot.registered_commands}"
104
+ end
137
105
  end
138
106
  end
139
107
  end
@@ -6,7 +6,7 @@ module Kybus
6
6
  include Kybus::Logger
7
7
 
8
8
  attr_accessor :state
9
- attr_reader :provider
9
+ attr_reader :provider, :args
10
10
 
11
11
  def initialize(provider, state, bot)
12
12
  @provider = provider
@@ -77,13 +77,17 @@ module Kybus
77
77
  state&.command&.name
78
78
  end
79
79
 
80
- def redirect(*args)
81
- @bot.redirect(*args)
80
+ def redirect(*)
81
+ @bot.redirect(*)
82
82
  end
83
83
 
84
84
  def abort(msg = nil)
85
85
  raise ::Kybus::Bot::Base::AbortError, msg
86
86
  end
87
+
88
+ def fork(command, arguments = {})
89
+ @bot.invoke_job(command, arguments)
90
+ end
87
91
  end
88
92
  end
89
93
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kybus
4
+ module Bot
5
+ class Base
6
+ class BotError < StandardError; end
7
+ class AbortError < BotError; end
8
+
9
+ class EmptyMessageError < BotError
10
+ def initialize
11
+ super('Message is empty')
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kybus
4
+ module Bot
5
+ module Forkers
6
+ class JobNotFound < ::Kybus::Bot::Base::BotError; end
7
+ class JobNotReady < ::Kybus::Bot::Base::BotError; end
8
+
9
+ extend Kybus::DRY::ResourceInjector
10
+ register(:forkers, {})
11
+
12
+ def self.register_forker(name, provider)
13
+ forkers = resource(:forkers)
14
+ forkers[name] = provider
15
+ end
16
+
17
+ def self.forker(name)
18
+ forkers = resource(:forkers)
19
+ forkers[name]
20
+ end
21
+
22
+ def self.from_config(bot, configs)
23
+ provider_name = configs&.dig('provider') || 'thread'
24
+ provider = forker(provider_name)
25
+ provider.new(bot, configs)
26
+ end
27
+
28
+ class Base
29
+ include Kybus::Logger
30
+
31
+ def initialize(bot, configs)
32
+ @configs = configs
33
+ @bot = bot
34
+ @command_definition = CommandDefinition.new
35
+ end
36
+
37
+ def register_command(command, arguments, &)
38
+ @command_definition.register_command(command, arguments, &)
39
+ end
40
+
41
+ def fork(command, arguments, dsl)
42
+ job_definition = @command_definition[command]
43
+ raise JobNotFound if job_definition.nil?
44
+
45
+ raise JobNotReady unless job_definition.ready?(arguments)
46
+
47
+ invoke(command, arguments, job_definition, dsl)
48
+ end
49
+
50
+ def handle_job(command, args)
51
+ job_definition = @command_definition[command]
52
+ @bot.dsl.instance_eval do
53
+ @args = args
54
+ instance_eval(&job_definition.block)
55
+ end
56
+ log_info('Job Executed', command:, args:)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kybus
4
+ module Bot
5
+ module Forkers
6
+ class LambdaSQSForker < Base
7
+ extend Kybus::DRY::ResourceInjector
8
+
9
+ def self.register_queue_client(client)
10
+ register(:sqs, client)
11
+ end
12
+
13
+ def initialize(bot, configs)
14
+ super
15
+ @client = LambdaSQSForker.resource(:sqs)
16
+ @queue = configs['queue']
17
+ @queue_url = @client.get_queue_url(queue_name: @queue).queue_url
18
+ end
19
+
20
+ def invoke(command, args, _job_definition, dsl)
21
+ @client.send_message(queue_url: @queue_url, message_body: make_message(command, args, dsl).to_json)
22
+ end
23
+
24
+ def make_message(command, args, dsl)
25
+ {
26
+ job: command,
27
+ args: args.to_h,
28
+ state: dsl.state.to_h
29
+ }
30
+ end
31
+
32
+ def handle_job(command, args)
33
+ log_info('Got job from SQS', command:)
34
+ super
35
+ end
36
+ end
37
+
38
+ register_forker('sqs', LambdaSQSForker)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kybus
4
+ module Bot
5
+ module Forkers
6
+ class ThreadForker < Base
7
+ def invoke(command, args, _job_definition, _dsl)
8
+ Thread.new do
9
+ log_info('Forking job', command:)
10
+ handle_job(command, args)
11
+ end
12
+ end
13
+ end
14
+
15
+ register_forker('thread', ThreadForker)
16
+ end
17
+ end
18
+ end
@@ -5,55 +5,73 @@ module Kybus
5
5
  module Migrator
6
6
  class << self
7
7
  def run_migrations!(config)
8
- case config['name']
8
+ migrator = migrator_for(config['name'])
9
+ migrator.run_migrations!(config)
10
+ end
11
+
12
+ private
13
+
14
+ def migrator_for(name)
15
+ case name
9
16
  when 'sequel'
10
- run_sequel_migrations(config)
17
+ SequelMigrator
11
18
  when 'dynamoid'
12
- run_dynamoid_migrations(config)
19
+ DynamoidMigrator
13
20
  else
14
- raise "Provider not supported #{config['name']}"
21
+ raise "Provider not supported #{name}"
15
22
  end
16
23
  end
24
+ end
17
25
 
18
- private
26
+ class SequelMigrator
27
+ class << self
28
+ def run_migrations!(config)
29
+ require 'sequel'
30
+ require 'sequel/extensions/migration'
19
31
 
20
- def run_sequel_migrations(config)
21
- require 'sequel'
22
- require 'sequel/extensions/migration'
23
-
24
- conn = Sequel.connect(config['endpoint'])
25
- conn.create_table?(:bot_sessions) do
26
- String :channel_id
27
- String :user
28
- String :params, text: true
29
- String :metadata, text: true
30
- String :files, text: true
31
- String :cmd
32
- String :requested_param
33
- String :last_message, text: true
32
+ conn = Sequel.connect(config['endpoint'])
33
+ conn.create_table?(:bot_sessions) do
34
+ String :channel_id
35
+ String :user
36
+ String :params, text: true
37
+ String :metadata, text: true
38
+ String :files, text: true
39
+ String :cmd
40
+ String :requested_param
41
+ String :last_message, text: true
42
+ end
34
43
  end
35
44
  end
45
+ end
36
46
 
37
- def run_dynamoid_migrations(config)
38
- repository = Kybus::Storage::Datasource::DynamoidRepository.from_config(
39
- 'name' => 'dynamoid',
40
- 'dynamoid_config' => true,
41
- 'access_key' => config['access_key'],
42
- 'secret_key' => config['secret_key'],
43
- 'region' => config['region'],
44
- 'endpoint' => config['endpoint'],
45
- 'namespace' => config['namespace'],
46
- 'table' => 'bot_sessions',
47
- 'primary_key' => 'channel_id',
48
- 'fields' => Base::DYNAMOID_FIELDS,
49
- 'read_capacity' => config['read_capacity'] || 1,
50
- 'write_capacity' => config['write_capacity'] || 1
51
- )
52
-
53
- # Ensure the table is created
54
- return if Dynamoid.adapter.list_tables.include?('bot_sessions')
55
-
56
- repository.model_class.create_table(sync: true)
47
+ class DynamoidMigrator
48
+ class << self
49
+ def run_migrations!(config)
50
+ repository = Kybus::Storage::Datasource::DynamoidRepository.from_config(
51
+ 'name' => 'dynamoid',
52
+ 'dynamoid_config' => true,
53
+ 'access_key' => config['access_key'],
54
+ 'secret_key' => config['secret_key'],
55
+ 'region' => config['region'],
56
+ 'endpoint' => config['endpoint'],
57
+ 'namespace' => config['namespace'],
58
+ 'table' => 'bot_sessions',
59
+ 'primary_key' => 'channel_id',
60
+ 'fields' => Base::DYNAMOID_FIELDS,
61
+ 'read_capacity' => config['read_capacity'] || 1,
62
+ 'write_capacity' => config['write_capacity'] || 1
63
+ )
64
+
65
+ create_table_if_not_exists(repository)
66
+ end
67
+
68
+ private
69
+
70
+ def create_table_if_not_exists(repository)
71
+ return if Dynamoid.adapter.list_tables.include?('bot_sessions')
72
+
73
+ repository.model_class.create_table(sync: true)
74
+ end
57
75
  end
58
76
  end
59
77
  end
@@ -2,21 +2,15 @@
2
2
 
3
3
  module Kybus
4
4
  module Bot
5
- # Base iplementation for messages from distinct providers
5
+ # Base implementation for messages from distinct providers
6
6
  class SerializedMessage < Message
7
7
  MANDATORY_FIELDS = %i[channel_id provider message_id user raw_message].freeze
8
8
 
9
9
  def initialize(data)
10
- data = data.to_h if data.is_a?(SerializedMessage)
11
- raise 'BadSerializedMessage: nil message' if data.nil?
12
-
13
- data = data[:data] if data.is_a?(Hash) && data.key?(:data)
14
-
15
- missing_keys = MANDATORY_FIELDS.reject { |k| data.keys.include?(k) }
16
- raise "BadSerializedMessage: Missing keys `#{missing_keys}', got: #{data}" unless missing_keys.empty?
17
-
18
- @data = data.is_a?(String) ? JSON.parse(data, symbolize_names: true) : data
19
- @data[:replied_message] = SerializedMessage.new(@data[:replied_message]) if @data[:replied_message]
10
+ super()
11
+ @data = parse_data(data)
12
+ validate_data!
13
+ parse_replied_message
20
14
  end
21
15
 
22
16
  def self.from_json(json)
@@ -32,7 +26,7 @@ module Kybus
32
26
  !@data[attachment].nil?
33
27
  end
34
28
 
35
- def method_missing(method, *_args)
29
+ def method_missing(method, *_args) # rubocop:disable Style/MissingRespondToMissing
36
30
  @data[method]
37
31
  end
38
32
 
@@ -40,8 +34,32 @@ module Kybus
40
34
  @data.dup
41
35
  end
42
36
 
43
- def to_json(*args)
44
- @data.to_json(*args)
37
+ def to_json(*)
38
+ @data.to_json(*)
39
+ end
40
+
41
+ private
42
+
43
+ def parse_data(data)
44
+ data = data.to_h if data.is_a?(SerializedMessage)
45
+ raise Base::BotError, 'BadSerializedMessage: nil message' if data.nil?
46
+
47
+ data = data[:data] if data.is_a?(Hash) && data.key?(:data)
48
+ data.is_a?(String) ? JSON.parse(data, symbolize_names: true) : data
49
+ end
50
+
51
+ def validate_data!
52
+ missing_keys = MANDATORY_FIELDS.reject { |k| @data.key?(k) }
53
+ return if missing_keys.empty?
54
+
55
+ raise Base::BotError,
56
+ "BadSerializedMessage: Missing keys `#{missing_keys}', got: #{@data}"
57
+ end
58
+
59
+ def parse_replied_message
60
+ return unless @data[:replied_message]
61
+
62
+ @data[:replied_message] = SerializedMessage.new(@data[:replied_message])
45
63
  end
46
64
  end
47
65
  end
@@ -32,7 +32,9 @@ module Kybus
32
32
  dsl, state = build_context(details_json)
33
33
  dsl.instance_eval(&state.command.block)
34
34
  rescue StandardError => e
35
+ # :nocov:
35
36
  log_error('Error in worker', error: e.class, msg: e.message, trace: e.backtrace)
37
+ # :nocov:
36
38
  end
37
39
  end
38
40
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kybus
4
4
  module Bot
5
- VERSION = '0.10.0'
5
+ VERSION = '0.11.1'
6
6
  end
7
7
  end