pug-bot 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 130e63accf84439db61edd3da1f0745878a35bf5
4
+ data.tar.gz: e655cfbcd864bc561b128b0248798a1c8371b62c
5
+ SHA512:
6
+ metadata.gz: 360bc1589c908f872b249c7e0388b87d50dcbdc0df9e791d84b431f7e3136e0589b37f4632212e3fecdfed8b3f1b8b51ae562729028f0b776f844aa191a5d26d
7
+ data.tar.gz: d6bfc6fef76beabd1e7ee82ac1df9e9368910138e6cd81e4d81ca415fa69be583050d56ad8ce305efa9027b8f2fc319bd2345392142e5d2beca8804a254b7741
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2018 Alex Figueroa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # pug-bot
2
+
3
+ An automation framework for repetitive dev tasks
4
+
5
+ # Installation
6
+
7
+ Add the following to your `Gemfile` and then run `bundle install`
8
+
9
+ ```
10
+ gem 'pug-bot'
11
+ ```
12
+
13
+ # Usage
14
+
15
+ Before you can interact with pug-bot, you need to give it something to do via `Action`s
16
+
17
+ ## Action
18
+
19
+ An `Action` is an abstract representation of a task. This could be anything from
20
+ transforming RGB colors in the form: `66, 134, 244` to `4286f4` or pulling reports from
21
+ an API to display stock prices.
22
+
23
+ To define your own action, you need to create a new class which subclasses `Pug::Interfaces::Action` as follows
24
+
25
+ ```ruby
26
+ # actions/hello_world_action.rb
27
+ require 'pug'
28
+
29
+ class HelloWorldAction < Pug::Interfaces::Action
30
+ end
31
+ ```
32
+
33
+ There are a few methods you **must** override specifically `name`, `requires_input?`, and `execute`.
34
+ - `name` helps to identify your `Action`
35
+ - `requires_input?` determines if input is required for your `Action`
36
+ - `execute` is what happens when the `Action` is ran
37
+
38
+ ```ruby
39
+ # actions/hello_world_action.rb
40
+ require 'pug'
41
+
42
+ class HelloWorldAction < Pug::Interfaces::Action
43
+ def name
44
+ 'Hello, World'
45
+ end
46
+
47
+ def requires_input?
48
+ true
49
+ end
50
+
51
+ def execute(input)
52
+ "Hello, #{input}"
53
+ end
54
+ end
55
+ ```
56
+
57
+ Once you've defined your action you just need a way to interact with it.
58
+ There are two ways to interact with pug-bot: Telegram and Terminal.
59
+
60
+ ### Telegram Bot Setup
61
+
62
+ To use Telegram we need to first setup a Telegram Bot. Thankfully, Telegram makes this a nice experience. Skip this and the next section if you want to just use Terminal.
63
+
64
+ Assuming you already have Telegram installed, you'll need to perform the following steps:
65
+ 1. Start a new conversation with `@botfather` on Telegram
66
+ 2. Type `/newbot` and follow its steps to create a new Bot
67
+ 3. Grab the token for the HTTP API, this is your Telegram API token
68
+ 4. Start a new conversation with your newly created Bot
69
+ 5. Visit `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates` with `YOUR_BOT_TOKEN` replaced with the token from Step 3.
70
+ 6. Look for the `id` field in the `chat` body, this is your Telegram Chat ID
71
+
72
+ ## Telegram
73
+
74
+ To setup Telegram, ensure you have an API token and Chat ID.
75
+ Replace `YOUR_TELEGRAM_TOKEN` and `YOUR_TELEGRAM_CHAT_ID` with the token and chat_id for your Bot.
76
+
77
+ ```ruby
78
+ require 'pug'
79
+ require_relative './actions/hello_world_action'
80
+
81
+ Pug.configure do |config|
82
+ config.type = Pug::Configuration::TELEGRAM
83
+ config.token = 'YOUR_TELEGRAM_TOKEN'
84
+ config.chat_id = 'YOUR_TELEGRAM_CHAT_ID'
85
+ config.actions = [HelloWorldAction.new]
86
+ end
87
+
88
+ Pug::Bot.run
89
+ ```
90
+
91
+ ## Terminal
92
+
93
+ To setup Terminal, you just need to specify the type as Terminal
94
+
95
+ ```ruby
96
+ require 'pug'
97
+ require_relative './actions/hello_world_action'
98
+
99
+ Pug.configure do |config|
100
+ config.type = Pug::Configuration::TERMINAL
101
+ config.actions = [HelloWorldAction.new]
102
+ end
103
+
104
+ Pug::Bot.run
105
+ ```
106
+
107
+ # Interactions
108
+
109
+ Every `Action` that pug-bot handles is enumerated. To call your command, you just need to type in the number that it corresponds to.
110
+ For the `HelloWorldAction`, that would be `0` as shown below:
111
+
112
+ ## Telegram
113
+
114
+ <img src="assets/telegram_example.png" width=60% height=60%>
115
+
116
+ ## Terminal
117
+
118
+ <img src="assets/terminal_example.png" width=60% height=60%>
119
+
120
+ # Hints
121
+
122
+ Hints can be provided via entering: `help` or `list`.
123
+
124
+ <img src="assets/terminal_hints.png" width=60% height=60%>
125
+
126
+
127
+ # Motivation
128
+
129
+ There were a lot of tasks that I found myself repeating during my dev work.
130
+ I wanted to use something that was device/editor agnostic and could handle multiline arguments with no hassle.
131
+ After playing around with the Telegram API, I hacked together a quick Bot to start this automation.
132
+ This is the result of a few weeks of tinkering in hopes of making something more extensible.
133
+
134
+ If this helps at least one other person work faster then I'll be content.
135
+ Also, I come from an iOS background so forgive my not-so idomatic Ruby.
data/Rakefile ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true.
2
+
3
+ require 'pug/version'
4
+ require 'yard'
5
+
6
+ desc 'Prepares and installs gem'
7
+ task :prepare do
8
+ sh %{ gem build pug-bot.gemspec }
9
+ sh %{ gem install pug-bot-#{Pug::VERSION}.gem }
10
+ end
11
+
12
+ desc 'Run tests for the gem'
13
+ task :test do
14
+ sh %{ rspec spec }
15
+ end
16
+
17
+ desc 'Run Rubocop check for the gem'
18
+ task :cop do
19
+ sh %{ rubocop }
20
+ end
21
+
22
+ desc 'Verify everything is good before merge'
23
+ task :flightcheck => [:cop, :test] do
24
+ end
25
+
26
+ desc 'Generate documentation'
27
+ task :document do
28
+ sh %{ yardoc 'lib/**/*.rb' }
29
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pug
4
+ module Action
5
+ # Controls the execution of an Action serially
6
+ class Controller
7
+ # @param actions [Array<Pug::Interfaces::Action>] The actions to manage
8
+ def initialize(actions)
9
+ @current_action = nil
10
+ @actions = actions || []
11
+ end
12
+
13
+ # Indicates if there are any actions to manage
14
+ # @return [Boolean] If there are any actions
15
+ def actions?
16
+ !@actions.empty?
17
+ end
18
+
19
+ # Indicates if there is a currently running Action
20
+ # @return [Boolean] if there is an action currently running
21
+ def running_action?
22
+ !@current_action.nil?
23
+ end
24
+
25
+ # Provides information about the Action's input requirements
26
+ # @return [Input] describing the requirements
27
+ def action_input
28
+ raise Strings.no_action_running unless running_action?
29
+ input_required = @current_action.requires_input?
30
+ Input.new(@current_action.name, input_required)
31
+ end
32
+
33
+ # Determines if an Action can start for a given index
34
+ # @param index [Integer] the index of the Action in actions
35
+ # @return [Boolean] indicating if an Action can be started at this index
36
+ # @note This will return false if there is a currently running action
37
+ def can_start_action?(index)
38
+ return false if index.negative? || index >= @actions.length
39
+ !running_action?
40
+ end
41
+
42
+ # Starts up the Action at the given index if possible
43
+ # @param index [Integer] the index of the Action in actions
44
+ def start_action(index)
45
+ return unless can_start_action?(index)
46
+
47
+ action = @actions[index]
48
+ @current_action = action
49
+ end
50
+
51
+ # Runs the Action prepared by #start_action with provided input
52
+ # @param input [String] the input to pass to the Action
53
+ # @return [Pug::Types::Result] indicating the execution result
54
+ def run_action(input)
55
+ return Results.no_action_running unless running_action?
56
+
57
+ requires_input = @current_action.requires_input?
58
+ result = @current_action.execute(requires_input ? input : nil)
59
+ output = Output.new(@current_action.name, result || '')
60
+ @current_action = nil
61
+ Types::Result.success(output)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pug
4
+ module Action
5
+ # Formats actions to an enumerated text representation
6
+ class Enumerator
7
+ # Enumerates Action names with an optional description
8
+ # @param actions [Array<Interfaces::Action>] Action names to enumerate
9
+ # @param show_description [Boolean] optional flag that adds descriptions
10
+ # @return [Array<String>] enumerated names with optional description
11
+ def names(actions, show_description = false)
12
+ return [] if actions.nil?
13
+ actions.each_with_index.map do |action, index|
14
+ if show_description && !action.description.to_s.empty?
15
+ "#{index}: #{action.name} # #{action.description}"
16
+ else
17
+ "#{index}: #{action.name}"
18
+ end
19
+ end
20
+ end
21
+
22
+ # Enumerates and groups Action names into a 2D array
23
+ # @param actions [Array<Interfaces::Action>] Action names to enumerate
24
+ # @param group_size [Integer] optional count indicating subarray size
25
+ # @return [Array<Array<String>>] enumerated and grouped names
26
+ # @note This does not support descriptions at the moment
27
+ def grouped_names(actions, group_size = 2)
28
+ return [] if group_size <= 0
29
+ names(actions).each_slice(group_size).to_a
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pug
4
+ module Action
5
+ # Defines input requirements for an Action
6
+ # @!attribute action_name
7
+ # @return [String] the name of the Action
8
+ # @!attribute required
9
+ # @return [Boolean] if Action requires input
10
+ class Input
11
+ attr_reader :action_name, :required
12
+
13
+ # @param action_name [String] The name of the Action
14
+ # @param required [Boolean] if Action requires input
15
+ def initialize(action_name, required)
16
+ @action_name = action_name
17
+ @required = required
18
+ end
19
+
20
+ # @return [Boolean] if Action requires input
21
+ def required?
22
+ @required
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pug
4
+ module Action
5
+ # Defines output of running an Action
6
+ # @!attribute action_name
7
+ # @return [String] the name of the Action
8
+ # @!attribute value
9
+ # @return [String] the output of the Action
10
+ class Output
11
+ attr_reader :action_name, :value
12
+
13
+ # @param action_name [String] the name of the Action
14
+ # @param value [String] the output of the Action
15
+ def initialize(action_name, value)
16
+ @action_name = action_name
17
+ @value = value
18
+ end
19
+ end
20
+ end
21
+ end
data/lib/pug/bot.rb ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pug
4
+ # Coordinates the Client with the provided Actions
5
+ class Bot
6
+ # Convenience method used to setup the Bot
7
+ # with the user defined Configuration
8
+ # @return [Bot] A new instance of Bot.
9
+ # @see Configuration
10
+ def self.run
11
+ config = Pug.configuration
12
+ config.validate
13
+ client = Clients::Factory.client_for_config(config)
14
+ Bot.new(client, config.actions).start
15
+ end
16
+
17
+ # @param client [Interfaces::Client] used to interact with User
18
+ # @param actions [Array<Interfaces::Action>] available actions
19
+ def initialize(client, actions)
20
+ @client = client
21
+ @handler = MessageHandler.default(actions)
22
+ end
23
+
24
+ # Starts the handling all messages received via the Client
25
+ # @return [void]
26
+ def start
27
+ @client.listen do |message|
28
+ handle(message)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def handle(message)
35
+ result = @handler.handle(message)
36
+ output = output_from_result(result)
37
+ @client.send_message(output) unless output.to_s.empty?
38
+ return if result.type != Types::Result::SUCCESS
39
+ action_name = result.value.action_name
40
+ @client.send_message(Strings.finished_running(action_name))
41
+ end
42
+
43
+ def output_from_result(result)
44
+ if result.type == Types::Result::SUCCESS
45
+ result.value.value
46
+ elsif result.type == Types::Result::INFO
47
+ result.value
48
+ else
49
+ result.error
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pug
4
+ module Clients
5
+ # Factory for building Clients based on a configuration
6
+ class Factory
7
+ # Builds a Client for the given config
8
+ # @param config [Configuration] parameters to build Client from
9
+ # @return [Interfaces::Client] The client from the config parameters
10
+ def self.client_for_config(config)
11
+ if !config.nil? && config.type == Configuration::TELEGRAM
12
+ enumerator = Action::Enumerator.new
13
+ markup = enumerator.grouped_names(config.actions)
14
+ client = TelegramClient.new(config.token, config.chat_id)
15
+ client.configure_keyboard(markup)
16
+ client
17
+ else
18
+ TerminalClient.new
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pug
4
+ # Defines parameters used to setup Pug
5
+ # @!attribute type
6
+ # @return [Integer] the type of client to setup
7
+ # @see Configuration::TERMINAL
8
+ # @see Configuration::TELEGRAM
9
+ # @!attribute token
10
+ # @return [String] the API token for Telegram
11
+ # @note This is optional if type is TERMINAL
12
+ # @!attribute chat_id
13
+ # @return [String] the chat_id for Telegram
14
+ # @note This is optional if type is TERMINAL
15
+ # @!attribute actions
16
+ # @return [Array<Interfaces::Action>] user defined actions
17
+ class Configuration
18
+ attr_accessor :type, :token, :chat_id, :actions
19
+
20
+ # @!group Client Types
21
+ TERMINAL = 0
22
+ TELEGRAM = 1
23
+ # @!endgroup
24
+
25
+ # @param type [Integer] type of client to setup
26
+ def initialize(type = TERMINAL)
27
+ @type = type
28
+ end
29
+
30
+ # Validates if attributes are correctly setup for Pug
31
+ # @raise RuntimeError if type is not a valid Client type
32
+ # @raise RuntimeError if Telegram client and no token & chat_id
33
+ # @raise RuntimeError if provided actions are nil
34
+ # @return [void]
35
+ def validate
36
+ valid_type = [TERMINAL, TELEGRAM].include?(type)
37
+ raise 'Invalid client type' unless valid_type
38
+ bad_config = @token.nil? || @chat_id.nil?
39
+ raise 'Invalid Telegram config' if @type == TELEGRAM && bad_config
40
+ raise 'No actions provided' if @actions.nil?
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pug
4
+ module Interfaces
5
+ # Abstract interface representing an Action
6
+ class Action
7
+ # The human readable name for the Action
8
+ # @return [String] Action name
9
+ def name
10
+ raise NoMethodError
11
+ end
12
+
13
+ # Optional description for the action
14
+ # @return [String] Action description
15
+ def description
16
+ ''
17
+ end
18
+
19
+ # Indicates if the action requires an input
20
+ # @return [Boolean] if input is required
21
+ # @note Defaults to false
22
+ def requires_input?
23
+ false
24
+ end
25
+
26
+ # Entry point for Action with provided input if any
27
+ # @param input [String] The optional input for the Action
28
+ # @return [String] The output of running the Action
29
+ # @note This can return nil if there is no output
30
+ def execute(input) # rubocop:disable UnusedMethodArgument
31
+ raise NoMethodError
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pug
4
+ module Interfaces
5
+ # Abstract interface for a Client that Pug can talk to
6
+ # such as Telegram or Terminal
7
+ class Client
8
+ # Listens for and passes text via a block
9
+ # @yieldparam [String] text
10
+ def listen
11
+ raise NoMethodError
12
+ end
13
+
14
+ # Sends a message to the User via the Client
15
+ # @param message [String] the message to send
16
+ # @return [void]
17
+ def send_message(message) # rubocop:disable UnusedMethodArgument
18
+ raise NoMethodError
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Pug
6
+ # Responds to keywords that provide hints to the User
7
+ class KeywordHandler
8
+ # @!group Keywords
9
+ HELP = 'help'
10
+ LIST = 'list'
11
+ # @!endgroup
12
+
13
+ # @param actions [Array<Interfaces::Action>]
14
+ # user provided actions
15
+ def initialize(actions)
16
+ @actions = actions
17
+ @keywords = Set[HELP, LIST]
18
+ @enumerator = Action::Enumerator.new
19
+ end
20
+
21
+ # Determines if a given text is a keyword
22
+ # @param text [String] text to test
23
+ # @return [Boolean] if text is a keyword
24
+ def keyword?(text)
25
+ @keywords.include?(text)
26
+ end
27
+
28
+ # Runs the command corresponding to text
29
+ # if it is a keyword
30
+ # @param text [String] text to run command for
31
+ # @return [String, nil] output of command or nil
32
+ def run_command_for_keyword(text)
33
+ return nil unless keyword?(text)
34
+ map_keyword_to_command(text)
35
+ end
36
+
37
+ private
38
+
39
+ def keywords_excluding_help
40
+ @keywords.to_a.reject { |keyword| keyword == HELP }
41
+ end
42
+
43
+ def map_keyword_to_command(keyword)
44
+ return help_response if keyword == HELP
45
+ return list_response if keyword == LIST
46
+ nil
47
+ end
48
+
49
+ def help_response
50
+ commands = keywords_excluding_help.join("\n")
51
+ Strings.help(commands)
52
+ end
53
+
54
+ def list_response
55
+ return Strings.no_actions if @actions.empty?
56
+ @enumerator.names(@actions, true).join("\n")
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pug
4
+ # Coordinates between messages provided from Bot
5
+ # to actions via the Controller
6
+ class MessageHandler
7
+ # A convenice initializer for provided actions
8
+ # @param actions [Array<Interfaces::Action>] actions to coordinate
9
+ # @return [MessageHandler] A new instance of MessageHandler
10
+ def self.default(actions)
11
+ controller = Action::Controller.new(actions)
12
+ keyword_handler = KeywordHandler.new(actions)
13
+ MessageHandler.new(controller, NumberParser.new, keyword_handler)
14
+ end
15
+
16
+ # @param controller [Action::Controller] to control Action flow
17
+ # @param parser [NumberParser] to parse numeric text
18
+ # @param keyword_handler [KeywordHandler] to handle keywords
19
+ def initialize(controller, parser, keyword_handler)
20
+ @controller = controller
21
+ @parser = parser
22
+ @keyword_handler = keyword_handler
23
+ end
24
+
25
+ # Parses and coordinates the given message with the Controller
26
+ # @param message [String] message to handle
27
+ # @return [Types::Result] result of handling message
28
+ def handle(message)
29
+ return Results.missing_actions unless @controller.actions?
30
+ return Results.unknown_input if message.to_s.empty?
31
+ if @controller.running_action?
32
+ run_action_with_inputs(message)
33
+ elsif @keyword_handler.keyword?(message)
34
+ handle_keyword(message)
35
+ else
36
+ parse_message_for_index(message)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def can_start_action?(index)
43
+ @controller.can_start_action?(index)
44
+ end
45
+
46
+ def run_action_with_inputs(inputs = nil)
47
+ @controller.run_action(inputs || '')
48
+ end
49
+
50
+ def parse_message_for_index(message)
51
+ index = @parser.number_from_text(message)
52
+ return Results.unknown_input if index.nil?
53
+ return Results.invalid_index(index) unless can_start_action?(index)
54
+
55
+ @controller.start_action(index)
56
+ request_input_if_needed(message)
57
+ end
58
+
59
+ def request_input_if_needed(message)
60
+ input = @controller.action_input
61
+ if input.required?
62
+ Results.enter_inputs(input.action_name)
63
+ else
64
+ run_action_with_inputs(message)
65
+ end
66
+ rescue RuntimeError => ex
67
+ Types::Result.error(ex)
68
+ end
69
+
70
+ def handle_keyword(keyword)
71
+ response = @keyword_handler.run_command_for_keyword(keyword)
72
+ return Results.unknown_input if response.nil?
73
+ Types::Result.info(response)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pug
4
+ # Parses numeric values from text
5
+ class NumberParser
6
+ # Indicates if a text starts with a number
7
+ # @param text [String] text to test
8
+ # @return [Boolean] if text starts with numeric text
9
+ def starts_with_numeric_text?(text)
10
+ text.to_i.positive? || text.strip.start_with?('0')
11
+ end
12
+
13
+ # Extracts number from text if it starts with
14
+ # a number
15
+ # @param text [String] text to extract number from
16
+ # @return [Integer, nil] number from text or nil
17
+ def number_from_text(text)
18
+ return nil unless starts_with_numeric_text?(text)
19
+ text.to_i
20
+ end
21
+ end
22
+ end