kybus-bot 0.10.0 → 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5133b5a89dc9947680ecdbd8ec25befed895f57e017d19121278617b6bc0ff4d
4
- data.tar.gz: '08e309bdf344e7066a5354c516487b12764b85cca7ea38165054471e5280cba7'
3
+ metadata.gz: 07b049513ef4f14a6c9d7896822a8d62a1f7eccee3df45325d7e5f6eff105abc
4
+ data.tar.gz: 259700235d2b8a923fa6dfd706b8e0e30b0546f16575ffb960b9e4f45d0a218c
5
5
  SHA512:
6
- metadata.gz: da4828437e972ecd01dab962800ae67f011a7ed8ceb88b9f1484d7c4caef740811dfd816f578620a5679a86768b21a00a976750d206c25cdcd7df66e4e3f4a6c
7
- data.tar.gz: c7bc12f54fb1352f774cbf607c12bbcae4802edc0d0058e99a38a7bb1554a8083b721a4e715e2185fd92fab668ece278e83ce4a2a15d43f28ddd23f5175cccfa
6
+ metadata.gz: a66ec612123afc67d7296e405f902beac5bae5849b3de7d1cc538ceadc6cbd62c993a3fd373c7a672592196706d2bd71c5e57feb15256299e1c2d55de2bae38d
7
+ data.tar.gz: 887ea27d9d9974ba1621fe9430c483ed072e041019b1d66de17f137f32ddc362033382b914dc0ba7ddb8b18df124efec02634d779c5da2a223ac0f5a21a8391c
@@ -35,14 +35,17 @@ module Kybus
35
35
  end
36
36
  end
37
37
 
38
+ def self.make_message_id
39
+ @message_id ||= 0
40
+ @message_id += 1
41
+ end
42
+
38
43
  def initialize(text, channel, attachment = nil)
39
44
  super()
40
45
  @text = text
41
46
  @channel = channel
42
47
  @attachment = attachment
43
- @@message_id ||= 0
44
- @@message_id += 1
45
- @message_id = @@message_id + 1
48
+ @message_id = DebugMessage.make_message_id
46
49
  end
47
50
 
48
51
  # Returns the channel id
@@ -125,7 +128,7 @@ module Kybus
125
128
  # This adapter is intended to be used on unit tests and development.
126
129
  class Debug
127
130
  # Exception for stoping the loop of messages
128
- class NoMoreMessageException < Kybus::Exceptions::AntError
131
+ class NoMoreMessageException < Kybus::Exceptions::KybusError
129
132
  def initialize
130
133
  super('There are no messages left')
131
134
  end
@@ -8,118 +8,70 @@ require_relative 'telegram_message'
8
8
  module Kybus
9
9
  module Bot
10
10
  module Adapter
11
- ##
12
- # This adapter is intended to be used on unit tests and development.
13
11
  class Telegram
14
12
  include ::Kybus::Logger
15
13
 
16
14
  attr_reader :last_message
17
15
 
18
- # It receives a hash with the configurations:
19
- # - name: the name of the channel
20
- # - channels a key value, where the key is a name and the value the
21
- # list of the messages in the channel.
22
- # - echo: a flag to enable debug messages.
23
16
  def initialize(configs)
24
17
  @config = configs
25
18
  @client = ::Telegram::Bot::Client.new(@config['token'])
26
19
  TelegramFile.register(:cli, @client)
27
20
  end
28
21
 
29
- # Interface for receiving message
30
22
  def read_message
31
- # take the first message from the first open message,
32
23
  loop do
33
24
  @client.listen do |message|
34
- log_info('Received message', message: message.to_h,
35
- from: message.from.to_h)
25
+ log_info('Received message', message: message.to_h, from: message.from.to_h)
36
26
  return @last_message = TelegramMessage.new(message)
37
27
  end
38
28
  rescue ::Telegram::Bot::Exceptions::ResponseError => e
39
- # :nocov:
40
- log_error('An error ocurred while calling to Telegram API', e)
41
- # :nocov:
29
+ log_error('An error occurred while calling to Telegram API', e)
42
30
  end
43
31
  end
44
32
 
45
33
  def handle_message(body)
46
34
  chat_id = body.dig('message', 'chat', 'id')
47
35
  message_id = body.dig('message', 'message_id')
48
- user = body.dig('message', 'from', 'username') || body.dig('message', 'from', 'first_name')
36
+ user = extract_user(body.dig('message', 'from'))
49
37
  raw_message = body.dig('message', 'text')
50
-
51
- replied_message = body.dig('message', 'reply_to_message')
52
38
  is_private = body.dig('message', 'chat', 'type') == 'private'
39
+ attachment = extract_attachment(body['message'])
40
+ serialized_replied_message = serialize_replied_message(body.dig('message', 'reply_to_message'))
53
41
 
54
- # Check if the message has an attachment
55
- has_attachment = body.dig('message',
56
- 'photo') || body.dig('message', 'document') || body.dig('message', 'video')
57
- attachment = if has_attachment
58
- body.dig('message',
59
- 'photo')&.last || body.dig('message', 'document') || body.dig('message', 'video')
60
- end
61
-
62
- # Serialize replied_message if it exists
63
- serialized_replied_message = if replied_message
64
- SerializedMessage.new(
65
- provider: 'telegram',
66
- channel_id: replied_message.dig('chat', 'id'),
67
- message_id: replied_message['message_id'],
68
- user: replied_message.dig('from',
69
- 'username') || replied_message.dig('from',
70
- 'first_name'),
71
- raw_message: replied_message['text'],
72
- is_private?: replied_message.dig('chat', 'type') == 'private'
73
- ).serialize
74
- end
75
-
76
- SerializedMessage.new(
77
- provider: 'telegram',
78
- channel_id: chat_id,
79
- message_id:,
80
- user:,
81
- replied_message: serialized_replied_message,
82
- raw_message:,
83
- is_private?: is_private,
84
- attachment:
85
- )
42
+ SerializedMessage.new(provider: 'telegram', channel_id: chat_id, message_id:, user:,
43
+ replied_message: serialized_replied_message, raw_message:, is_private?: is_private,
44
+ attachment:)
86
45
  end
87
46
 
88
47
  def mention(id)
89
48
  "[user](tg://user?id=#{id})"
90
49
  end
91
50
 
92
- # interface for sending messages
93
51
  def send_message(contents, channel_name)
94
52
  log_debug('Sending message', channel_name:, message: contents)
95
53
  @client.api.send_message(chat_id: channel_name.to_i, text: contents, parse_mode: @config['parse_mode'])
96
- # :nocov:
97
54
  rescue ::Telegram::Bot::Exceptions::ResponseError => e
98
55
  nil if e.error_code == '403'
99
56
  end
100
- # :nocov:
101
57
 
102
- # interface for sending video
103
58
  def send_video(channel_name, video_url, comment = nil)
104
59
  file = Faraday::FilePart.new(video_url, 'video/mp4')
105
60
  @client.api.send_video(chat_id: channel_name, video: file, caption: comment)
106
61
  end
107
62
 
108
- # interface for sending uadio
109
63
  def send_audio(channel_name, audio_url)
110
64
  file = Faraday::FilePart.new(audio_url, 'audio/mp3')
111
65
  @client.api.send_audio(chat_id: channel_name, audio: file)
112
66
  end
113
67
 
114
- # interface for sending image
115
68
  def send_image(channel_name, image_url, comment = nil)
116
69
  file = Faraday::FilePart.new(image_url, 'image/jpeg')
117
70
  @client.api.send_photo(chat_id: channel_name, photo: file, caption: comment)
118
71
  end
119
72
 
120
- # interface for sending document
121
73
  def send_document(channel_name, image_url)
122
- file = Faraday::FilePart.new(image_url, 'application/octect-stream')
74
+ file = Faraday::FilePart.new(image_url, 'application/octet-stream')
123
75
  @client.api.send_document(chat_id: channel_name, document: file)
124
76
  end
125
77
 
@@ -130,6 +82,35 @@ module Kybus
130
82
  def file_builder(file)
131
83
  TelegramFile.new(file)
132
84
  end
85
+
86
+ private
87
+
88
+ def extract_user(from)
89
+ from['username'] || from['first_name']
90
+ end
91
+
92
+ def extract_attachment(message)
93
+ return unless message
94
+
95
+ %w[photo document video].each do |type|
96
+ attachment = message[type]
97
+ return type == 'photo' ? attachment.last : attachment if attachment
98
+ end
99
+ nil
100
+ end
101
+
102
+ def serialize_replied_message(replied_message)
103
+ return unless replied_message
104
+
105
+ SerializedMessage.new(
106
+ provider: 'telegram',
107
+ channel_id: replied_message.dig('chat', 'id'),
108
+ message_id: replied_message['message_id'],
109
+ user: extract_user(replied_message['from']),
110
+ raw_message: replied_message['text'],
111
+ is_private?: replied_message.dig('chat', 'type') == 'private'
112
+ ).serialize
113
+ end
133
114
  end
134
115
 
135
116
  register('telegram', Telegram)
@@ -40,7 +40,7 @@ module Kybus
40
40
  end
41
41
 
42
42
  def original_name
43
- meta.dig('result', 'file_name')
43
+ meta.file_path
44
44
  end
45
45
 
46
46
  def file_name
@@ -49,7 +49,7 @@ module Kybus
49
49
 
50
50
  def download
51
51
  token = cli.api.token
52
- file_path = meta.dig('result', 'file_path')
52
+ file_path = meta.file_path
53
53
  path = "https://api.telegram.org/file/bot#{token}/#{file_path}"
54
54
  Faraday.get(path).body
55
55
  end
@@ -47,9 +47,13 @@ module Kybus
47
47
  end
48
48
 
49
49
  def attachment
50
- (@message.respond_to?(:document) && @message&.document) ||
51
- (@message.respond_to?(:photo) && @message.photo&.last) ||
52
- (@message.respond_to?(:audio) && @message&.audio)
50
+ %i[document photo audio].each do |method|
51
+ next unless @message.respond_to?(method)
52
+
53
+ attachment = @message.public_send(method)
54
+ return method == :photo ? attachment.last : attachment if attachment
55
+ end
56
+ nil
53
57
  end
54
58
 
55
59
  def user
@@ -6,25 +6,19 @@ require 'kybus/storage'
6
6
  require_relative 'command/command_state'
7
7
  require_relative 'dsl_methods'
8
8
  require_relative 'command_executor'
9
+ require_relative 'command/command_definition'
10
+ require_relative 'command/execution_context'
9
11
  require_relative 'command/command_state_factory'
10
-
12
+ require_relative 'exceptions'
13
+ require_relative 'forkers/base'
14
+ require_relative 'forkers/thread_forker'
15
+ require_relative 'forkers/lambda_sqs_forker'
11
16
  require 'kybus/logger'
12
17
  require 'forwardable'
13
18
 
14
19
  module Kybus
15
20
  module Bot
16
- # Base class for bot implementation. It wraps the threads execution, the
17
- # provider and the state storage inside an object.
18
- class Base
19
- class BotError < StandardError; end
20
- class AbortError < BotError; end
21
-
22
- class EmptyMessageError < BotError
23
- def initialize
24
- super('Message is empty')
25
- end
26
- end
27
-
21
+ class Base # rubocop: disable Metrics/ClassLength
28
22
  extend Forwardable
29
23
  include Kybus::Logger
30
24
 
@@ -44,41 +38,17 @@ module Kybus
44
38
  def_delegators :executor, :state, :precommand_hook
45
39
  def_delegators :definitions, :registered_commands
46
40
 
47
- # Configurations needed:
48
- # - pool_size: number of threads created in execution
49
- # - provider: a configuration for a thread provider.
50
- # See supported adapters
51
- # - name: The bot name
52
- # - repository: Configurations about the state storage
53
-
54
41
  def initialize(configs)
55
- build_pool(configs['pool_size'])
42
+ @pool_size = configs['pool_size']
56
43
  @provider = Kybus::Bot::Adapter.from_config(configs['provider'])
57
- # TODO: move this to config
58
- repository = make_repository(configs)
59
44
  @definitions = Kybus::Bot::CommandDefinition.new
45
+ repository = create_repository(configs)
60
46
  command_factory = CommandStateFactory.new(repository, @definitions)
61
- @executor = if configs['sidekiq']
62
- require_relative 'sidekiq_command_executor'
63
- Kybus::Bot::SidekiqCommandExecutor.new(self, command_factory, configs)
64
- else
65
- Kybus::Bot::CommandExecutor.new(self, command_factory, configs['inline_args'])
66
- end
67
- register_command('default') { nil }
68
- rescue_from(::Kybus::Bot::Base::AbortError) do
69
- msg = params[:_last_exception]&.message
70
- send_message(msg) if msg && msg != 'Kybus::Bot::Base::AbortError'
71
- end
72
- end
73
-
74
- def make_repository(configs)
75
- repository_config = configs['state_repository'].merge('primary_key' => 'channel_id', 'table' => 'bot_sessions')
76
- repository_config.merge!('fields' => DYNAMOID_FIELDS) if repository_config['name'] == 'dynamoid'
77
- Kybus::Storage::Repository.from_config(nil, repository_config, {})
78
- end
79
-
80
- def extend(*args)
81
- DSLMethods.include(*args)
47
+ @executor = create_executor(configs, command_factory)
48
+ register_default_command
49
+ register_abort_handler
50
+ build_forker(configs)
51
+ build_pool
82
52
  end
83
53
 
84
54
  def self.helpers(mod = nil, &)
@@ -86,14 +56,8 @@ module Kybus
86
56
  DSLMethods.class_eval(&) if block_given?
87
57
  end
88
58
 
89
- def build_pool(pool_size)
90
- @pool = Array.new(pool_size) do
91
- # TODO: Create a subclass with the context execution
92
- Kybus::DRY::Daemon.new(pool_size, true) do
93
- message = provider.read_message
94
- executor.process_message(message)
95
- end
96
- end
59
+ def extend(*)
60
+ DSLMethods.include(*)
97
61
  end
98
62
 
99
63
  def dsl
@@ -105,13 +69,9 @@ module Kybus
105
69
  @executor.process_message(parsed)
106
70
  end
107
71
 
108
- # Starts the bot execution, this is a blocking call.
109
72
  def run
110
- # TODO: Implement an interface for killing the process
111
73
  pool.each(&:run)
112
- # :nocov: #
113
74
  pool.each(&:await)
114
- # :nocov: #
115
75
  end
116
76
 
117
77
  def redirect(command, *params)
@@ -127,14 +87,63 @@ module Kybus
127
87
  definitions.register_command(klass, params, &)
128
88
  end
129
89
 
90
+ def register_job(name, args, &)
91
+ @forker.register_command(name, args, &)
92
+ end
93
+
94
+ def invoke_job(name, args)
95
+ @forker.fork(name, args, dsl)
96
+ end
97
+
130
98
  def rescue_from(klass, &)
131
99
  definitions.register_command(klass, [], &)
132
100
  end
133
101
 
134
- def method_missing(method, ...)
135
- raise unless dsl.respond_to?(method)
102
+ def method_missing(method, ...) # rubocop: disable Style/MissingRespondToMissing
103
+ return dsl.send(method, ...) if dsl.respond_to?(method)
136
104
 
137
- dsl.send(method, ...)
105
+ super
106
+ end
107
+
108
+ private
109
+
110
+ def create_repository(configs)
111
+ repository_config = configs['state_repository'].merge('primary_key' => 'channel_id', 'table' => 'bot_sessions')
112
+ repository_config.merge!('fields' => DYNAMOID_FIELDS) if repository_config['name'] == 'dynamoid'
113
+ Kybus::Storage::Repository.from_config(nil, repository_config, {})
114
+ end
115
+
116
+ def create_executor(configs, command_factory)
117
+ if configs['sidekiq']
118
+ require_relative 'sidekiq_command_executor'
119
+ Kybus::Bot::SidekiqCommandExecutor.new(self, command_factory, configs)
120
+ else
121
+ Kybus::Bot::CommandExecutor.new(self, command_factory, configs['inline_args'])
122
+ end
123
+ end
124
+
125
+ def register_default_command
126
+ register_command('default') { nil }
127
+ end
128
+
129
+ def register_abort_handler
130
+ rescue_from(Kybus::Bot::Base::AbortError) do
131
+ msg = params[:_last_exception]&.message
132
+ send_message(msg) if msg && msg != 'Kybus::Bot::Base::AbortError'
133
+ end
134
+ end
135
+
136
+ def build_forker(configs)
137
+ @forker = Forkers.from_config(self, configs['forker'])
138
+ end
139
+
140
+ def build_pool
141
+ @pool = Array.new(pool_size) do
142
+ Kybus::DRY::Daemon.new(pool_size, true) do
143
+ message = provider.read_message
144
+ executor.process_message(message)
145
+ end
146
+ end
138
147
  end
139
148
  end
140
149
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'command'
4
+
3
5
  module Kybus
4
6
  module Bot
5
7
  class CommandDefinition
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kybus
4
+ module Bot
5
+ class CommandHandler
6
+ def initialize(executor)
7
+ @executor = executor
8
+ end
9
+
10
+ def run_command_or_prepare!
11
+ if @executor.execution_context.ready?
12
+ run_ready_command
13
+ else
14
+ ask_for_next_param
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def run_ready_command
21
+ @executor.dsl.state = @executor.execution_context.state
22
+ @executor.dsl.instance_eval(&@executor.precommand_hook)
23
+ msg = @executor.run_command!
24
+ @executor.execution_context.clear_command
25
+ msg
26
+ end
27
+
28
+ def ask_for_next_param
29
+ param = @executor.execution_context.next_missing_param
30
+ @executor.ask_param(param, @executor.execution_context.state.command.params_ask_label(param))
31
+ end
32
+ end
33
+ end
34
+ end
@@ -7,12 +7,7 @@ module Kybus
7
7
 
8
8
  def initialize(data, command)
9
9
  @command = command
10
- data = JSON.parse(data, symbolize_names: true) if data.is_a?(String)
11
- (data[:params] = JSON.parse(data[:params] || '{}', symbolize_names: true)) if data[:params].is_a?(String)
12
- (data[:metadata] = JSON.parse(data[:metadata] || '{}', symbolize_names: true)) if data[:metadata].is_a?(String)
13
- (data[:files] = JSON.parse(data[:files] || '{}', symbolize_names: true)) if data[:files].is_a?(String)
14
- (data[:last_message] = data[:last_message] && SerializedMessage.from_json(data[:last_message]))
15
- @data = data
10
+ @data = parse_data(data)
16
11
  end
17
12
 
18
13
  def self.from_json(str, commands_provider)
@@ -36,12 +31,11 @@ module Kybus
36
31
  command&.ready?(params)
37
32
  end
38
33
 
39
- # validates which is the following parameter required
40
34
  def next_missing_param
41
35
  command.next_missing_param(params)
42
36
  end
43
37
 
44
- def set_last_message(msg)
38
+ def last_message=(msg)
45
39
  @data[:last_message] = msg
46
40
  end
47
41
 
@@ -91,12 +85,29 @@ module Kybus
91
85
 
92
86
  def save!
93
87
  backup = @data.clone
94
- %i[params files last_message metadata].each do |param|
95
- @data[param] = @data[param].to_json
96
- end
88
+ serialize_data!
97
89
  @data.store
90
+ @data = backup
91
+ end
92
+
93
+ private
94
+
95
+ def parse_data(data)
96
+ data = JSON.parse(data, symbolize_names: true) if data.is_a?(String)
97
+ %i[params metadata files].each do |key|
98
+ data[key] = parse_json(data[key]) if data[key].is_a?(String)
99
+ end
100
+ data[:last_message] = SerializedMessage.from_json(data[:last_message]) if data[:last_message]
101
+ data
102
+ end
103
+
104
+ def parse_json(value)
105
+ JSON.parse(value || '{}', symbolize_names: true)
106
+ end
107
+
108
+ def serialize_data!
98
109
  %i[params files last_message metadata].each do |param|
99
- @data[param] = backup[param]
110
+ @data[param] = @data[param].to_json
100
111
  end
101
112
  end
102
113
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'regular_command_matcher'
4
+ require_relative 'inline_command_matcher'
5
+
3
6
  module Kybus
4
7
  module Bot
5
8
  class CommandStateFactory
@@ -7,29 +10,14 @@ module Kybus
7
10
  attr_reader :factory
8
11
 
9
12
  def initialize(repository, definitions)
10
- factory = Kybus::Storage::Factory.new(EmptyModel)
11
- factory.register(:default, :json)
12
- factory.register(:json, repository)
13
- @factory = factory
13
+ @factory = build_factory(repository)
14
14
  @definitions = definitions
15
+ @regular_command_matcher = RegularCommandMatcher.new(definitions)
16
+ @inline_command_matcher = InlineCommandMatcher.new(definitions)
15
17
  end
16
18
 
17
19
  def command(search)
18
- @definitions.each do |name, command|
19
- case name
20
- when String
21
- return command if name == search
22
- when Class
23
- return command if search.is_a?(name)
24
- when Regexp
25
- if search.is_a?(String) && name.match?(search)
26
- storable_command = command.clone
27
- storable_command.name = search
28
- return storable_command
29
- end
30
- end
31
- end
32
- nil
20
+ @regular_command_matcher.find_command(search)
33
21
  end
34
22
 
35
23
  def default_command
@@ -41,21 +29,7 @@ module Kybus
41
29
  end
42
30
 
43
31
  def command_with_inline_arg(name_with_arg)
44
- @definitions.each do |name, command|
45
- case name
46
- when Class
47
- return [command, []] if name_with_arg.is_a?(name)
48
- when String
49
- return [command, name_with_arg.gsub(name, '').split('__')] if name_with_arg.start_with?(name)
50
- when Regexp
51
- next unless name_with_arg.match?(name)
52
-
53
- storable_command = command.dup
54
- storable_command.name = name_with_arg
55
- return [storable_command, [name_with_arg]]
56
- end
57
- end
58
- nil
32
+ @inline_command_matcher.find_command_with_inline_arg(name_with_arg)
59
33
  end
60
34
 
61
35
  def load_state(channel)
@@ -64,6 +38,15 @@ module Kybus
64
38
  rescue Kybus::Storage::Exceptions::ObjectNotFound
65
39
  CommandState.new(factory.create(channel_id: channel.to_s, params: '{}', metadata: '{}', last_message: nil), nil)
66
40
  end
41
+
42
+ private
43
+
44
+ def build_factory(repository)
45
+ factory = Kybus::Storage::Factory.new(EmptyModel)
46
+ factory.register(:default, :json)
47
+ factory.register(:json, repository)
48
+ factory
49
+ end
67
50
  end
68
51
  end
69
52
  end
@@ -9,7 +9,7 @@ module Kybus
9
9
  attr_reader :state
10
10
 
11
11
  def_delegator :state, :requested_param=, :next_param=
12
- def_delegators :state, :clear_command, :save!, :ready?, :next_missing_param, :set_last_message
12
+ def_delegators :state, :clear_command, :save!, :ready?, :next_missing_param, :last_message=
13
13
 
14
14
  def block
15
15
  state.command.block
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kybus
4
+ module Bot
5
+ class InlineCommandMatcher
6
+ def initialize(definitions)
7
+ @definitions = definitions
8
+ @matchers = build_matchers
9
+ end
10
+
11
+ def find_command_with_inline_arg(name_with_arg)
12
+ @definitions.each do |name, command|
13
+ matcher = @matchers[name.class]
14
+ result = matcher&.call(name, command, name_with_arg)
15
+ return result if result
16
+ end
17
+ nil
18
+ end
19
+
20
+ private
21
+
22
+ def build_matchers
23
+ {
24
+ Class => method(:match_inline_class),
25
+ String => method(:match_inline_string),
26
+ Regexp => method(:match_inline_regexp)
27
+ }
28
+ end
29
+
30
+ def match_inline_class(name, command, name_with_arg)
31
+ [command, []] if name_with_arg.is_a?(name)
32
+ end
33
+
34
+ def match_inline_string(name, command, name_with_arg)
35
+ [command, name_with_arg.gsub(name, '').split('__')] if name_with_arg.start_with?(name)
36
+ end
37
+
38
+ def match_inline_regexp(name, command, name_with_arg)
39
+ return unless name_with_arg.match?(name)
40
+
41
+ storable_command = command.dup
42
+ storable_command.name = name_with_arg
43
+ [storable_command, [name_with_arg]]
44
+ end
45
+ end
46
+ end
47
+ end