rodbot 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.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +1 -0
  3. data/CHANGELOG.md +14 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +653 -0
  6. data/exe/rodbot +7 -0
  7. data/lib/roda/plugins/rodbot.rb +36 -0
  8. data/lib/rodbot/async.rb +45 -0
  9. data/lib/rodbot/cli/command.rb +25 -0
  10. data/lib/rodbot/cli/commands/console.rb +23 -0
  11. data/lib/rodbot/cli/commands/credentials.rb +19 -0
  12. data/lib/rodbot/cli/commands/deploy.rb +20 -0
  13. data/lib/rodbot/cli/commands/new.rb +21 -0
  14. data/lib/rodbot/cli/commands/simulator.rb +17 -0
  15. data/lib/rodbot/cli/commands/start.rb +26 -0
  16. data/lib/rodbot/cli/commands/stop.rb +15 -0
  17. data/lib/rodbot/cli/commands/version.rb +15 -0
  18. data/lib/rodbot/cli/commands.rb +18 -0
  19. data/lib/rodbot/cli.rb +9 -0
  20. data/lib/rodbot/config.rb +157 -0
  21. data/lib/rodbot/constants.rb +13 -0
  22. data/lib/rodbot/db/hash.rb +71 -0
  23. data/lib/rodbot/db/redis.rb +61 -0
  24. data/lib/rodbot/db.rb +91 -0
  25. data/lib/rodbot/dispatcher.rb +125 -0
  26. data/lib/rodbot/env.rb +48 -0
  27. data/lib/rodbot/error.rb +19 -0
  28. data/lib/rodbot/generator.rb +108 -0
  29. data/lib/rodbot/log.rb +67 -0
  30. data/lib/rodbot/memoize.rb +86 -0
  31. data/lib/rodbot/plugins/github_webhook/README.github_webhook.md +42 -0
  32. data/lib/rodbot/plugins/github_webhook/app.rb +46 -0
  33. data/lib/rodbot/plugins/gitlab_webhook/README.gitlab_webhook.md +40 -0
  34. data/lib/rodbot/plugins/gitlab_webhook/app.rb +40 -0
  35. data/lib/rodbot/plugins/hal/README.hal.md +15 -0
  36. data/lib/rodbot/plugins/hal/app.rb +22 -0
  37. data/lib/rodbot/plugins/matrix/README.matrix.md +42 -0
  38. data/lib/rodbot/plugins/matrix/relay.rb +113 -0
  39. data/lib/rodbot/plugins/otp/README.otp.md +82 -0
  40. data/lib/rodbot/plugins/otp/app.rb +47 -0
  41. data/lib/rodbot/plugins/word_of_the_day/README.word_of_the_day.md +13 -0
  42. data/lib/rodbot/plugins/word_of_the_day/schedule.rb +51 -0
  43. data/lib/rodbot/plugins.rb +81 -0
  44. data/lib/rodbot/rack.rb +50 -0
  45. data/lib/rodbot/refinements.rb +118 -0
  46. data/lib/rodbot/relay.rb +104 -0
  47. data/lib/rodbot/services/app.rb +72 -0
  48. data/lib/rodbot/services/relay.rb +37 -0
  49. data/lib/rodbot/services/schedule.rb +29 -0
  50. data/lib/rodbot/services.rb +32 -0
  51. data/lib/rodbot/simulator.rb +60 -0
  52. data/lib/rodbot/version.rb +5 -0
  53. data/lib/rodbot.rb +60 -0
  54. data/lib/templates/deploy/docker/compose.yaml.gerb +37 -0
  55. data/lib/templates/deploy/docker-split/compose.yaml.gerb +52 -0
  56. data/lib/templates/deploy/procfile/Procfile.gerb +1 -0
  57. data/lib/templates/deploy/procfile-split/Procfile.gerb +5 -0
  58. data/lib/templates/deploy/render/render.yaml.gerb +0 -0
  59. data/lib/templates/deploy/render-split/render.yaml.gerb +0 -0
  60. data/lib/templates/new/LICENSE.txt +22 -0
  61. data/lib/templates/new/README.md +4 -0
  62. data/lib/templates/new/app/app.rb +11 -0
  63. data/lib/templates/new/app/routes/help.rb +19 -0
  64. data/lib/templates/new/app/views/layout.erb +12 -0
  65. data/lib/templates/new/app/views/root.erb +5 -0
  66. data/lib/templates/new/config/rodbot.rb +8 -0
  67. data/lib/templates/new/config/schedule.rb +5 -0
  68. data/lib/templates/new/config.ru +3 -0
  69. data/lib/templates/new/gems.locked +104 -0
  70. data/lib/templates/new/gems.rb +15 -0
  71. data/lib/templates/new/guardfile.rb +9 -0
  72. data/lib/templates/new/public/assets/images/rodbot.avif +0 -0
  73. data/lib/templates/new/public/assets/stylesheets/base.css +18 -0
  74. data/lib/templates/new/rakefile.rb +28 -0
  75. data.tar.gz.sig +0 -0
  76. metadata +510 -0
  77. metadata.gz.sig +3 -0
@@ -0,0 +1,42 @@
1
+ # Rodbot Plugin – Matrix
2
+
3
+ Relay with the Matrix communication network
4
+
5
+ ## Preparation
6
+
7
+ To get an `access_token`, you have to create a new bot user. (Actually, Matrix does not know special bot users, just create a normal one instead.)
8
+
9
+ 1. Create a regular Matrix user account with [Element](https://app.element.io)
10
+ 2. In "All settings", set the display name, upload a user picture and disable all notifications. If the bot is supposed to join encrypted rooms as well, you should download the backup keys.
11
+ 3. You find the access token in "All settings -> Help & About".
12
+
13
+ Invite this new bot user to the room of your choice and figure out the corresponding `room_id`. You can use the public room ID (e.g. `#myroom:matrix.org`) but since it may change you're better off using the internal room ID (e.g. `!kg7FkT64kGUgfk8R7a:matrix.org`) instead:
14
+
15
+ 1. Right click on the room in question and select "Settings"
16
+ 2. Click on "Advanced"
17
+
18
+ ## Activation
19
+
20
+ Install the required gems via the corresponding Bundler group:
21
+
22
+ ```
23
+ bundle config set --local with matrix
24
+ bundle install
25
+ ```
26
+
27
+ Then activate and configure this plugin in `config/rodbot.rb`:
28
+
29
+ ```ruby
30
+ plugin :matrix do
31
+ access_token '<TOKEN>'
32
+ room_id '<ID>'
33
+ end
34
+ ```
35
+
36
+ You might want to use the credentials facilities of Rodbot to encrypt the token.
37
+
38
+ ## Usage
39
+
40
+ Once Rodbot is restarted, the Matrix relay will automatically accept the invitation and start listening. To check whether the relay works fine, just say +!ping+ in the room, you should receive a "pong" in reply.
41
+
42
+ Any room message beginning with "!" is considered a bot command.
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'matrix_sdk'
4
+
5
+ using Rodbot::Refinements
6
+
7
+ module Rodbot
8
+ class Plugins
9
+ class Matrix
10
+ class Relay < Rodbot::Relay
11
+ include Rodbot::Memoize
12
+
13
+ def loops
14
+ client.on_invite_event.add_handler { on_invite(_1) }
15
+ client.sync(filter: filter(empty: true))
16
+ client.on_event.add_handler('m.room.message') { on_message(_1) }
17
+ [method(:read_loop), method(:write_loop)]
18
+ end
19
+
20
+ private
21
+
22
+ def access_token
23
+ Rodbot.config(:plugin, :matrix, :access_token)
24
+ rescue => error
25
+ raise Rodbot::PluginError.new("invalid access_token", error.message)
26
+ end
27
+
28
+ def room_id
29
+ Rodbot.config(:plugin, :matrix, :room_id)
30
+ rescue => error
31
+ raise Rodbot::PluginError.new("invalid room_id", error.message)
32
+ end
33
+
34
+ def homeserver
35
+ room_id.split(':').last
36
+ rescue => error
37
+ raise Rodbot::PluginError.new("invalid room_id", error.message)
38
+ end
39
+
40
+ memoize def client
41
+ MatrixSdk::Client.new(homeserver, access_token: access_token, client_cache: :some)
42
+ end
43
+
44
+ memoize def room
45
+ client.ensure_room(room_id)
46
+ end
47
+
48
+ def read_loop
49
+ loop do
50
+ client.sync(filter: filter)
51
+ rescue StandardError
52
+ sleep 5
53
+ end
54
+ end
55
+
56
+ def write_loop
57
+ server = TCPServer.new(*bind)
58
+ loop do
59
+ Thread.start(server.accept) do |remote|
60
+ body = remote.gets("\x04")
61
+ remote.close
62
+ body.force_encoding('UTF-8')
63
+ room.send_html body.md_to_html
64
+ end
65
+ end
66
+ end
67
+
68
+ def on_invite(invite)
69
+ client.join_room(invite[:room_id]) if Rodbot.config(:plugin, :matrix, :room_id) == invite[:room_id]
70
+ end
71
+
72
+ def on_message(message)
73
+ if message.content[:msgtype] == 'm.text' && message.content[:body].start_with?('!')
74
+ html = 'pong' if message.content[:body] == '!ping'
75
+ html ||= reply_to(message)
76
+ room.send_html(html)
77
+ end
78
+ end
79
+
80
+ def reply_to(message)
81
+ command(*message.content[:body][1..].split(/\s+/, 2)).
82
+ md_to_html.
83
+ psub(placeholders(client.get_user(message.sender)))
84
+ end
85
+
86
+ def placeholders(sender)
87
+ {
88
+ sender: "https://matrix.to/#/#{sender.id}"
89
+ }
90
+ end
91
+
92
+ def filter(empty: false)
93
+ {
94
+ presence: { types: [] },
95
+ account_data: { types: [] },
96
+ room: {
97
+ ephemeral: { types: [] },
98
+ state: {
99
+ types: (empty ? [] : ['m.room.*']),
100
+ lazy_load_members: true
101
+ },
102
+ timeline: {
103
+ types: (empty ? [] : ['m.room.message'])
104
+ },
105
+ account_data: { types: [] }
106
+ }
107
+ }
108
+ end
109
+
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,82 @@
1
+ # Rodbot Plugin – OTP
2
+
3
+ Guard commands with one-time passwords
4
+
5
+ ## Preparation
6
+
7
+ Create a secret key and add it to your authenticator app:
8
+
9
+ ```
10
+ require 'rotp'
11
+ ROTP::Base32.random
12
+ ```
13
+
14
+ ## Activation
15
+
16
+ Activate and configure this plugin in `config/rodbot.rb` using the secret you've created in the previous section:
17
+
18
+ ```ruby
19
+ plugin :otp do
20
+ secret '<SECRET>'
21
+ drift 10
22
+ end
23
+ ```
24
+
25
+ The `drift` config is optional. In this example, one-time passwords are accepted up to 10 seconds beyond their expiration to compensate for slow networks. By default, no drift is granted.
26
+
27
+ ## Usage
28
+
29
+ To protect a command, just add a guard to the corresponding app route. Say, you have implemented a command `!reboot example.com` in `app/routes/reboot.rb`:
30
+
31
+ ```ruby
32
+ module Routes
33
+ class Hello < App
34
+
35
+ route do |r|
36
+
37
+ # GET /reboot
38
+ r.root do |r|
39
+ response['Content-Type'] = 'text/plain; charset=utf-8'
40
+ ServerService.new(r.arguments).reboot!
41
+ 'Done!'
42
+ end
43
+
44
+ end
45
+
46
+ end
47
+ end
48
+ ```
49
+
50
+ (As a reminder: Up until this point, `r.arguments` is a mere shortcut for `r.params['arguments']`.)
51
+
52
+ In order to protect this rather dangerous command with a one-time password, you have to guard the route:
53
+
54
+ ```ruby
55
+ r.root do |r|
56
+ r.halt [401, {}, ['Unauthorized']] unless r.valid_otp?
57
+ response['Content-Type'] = 'text/plain; charset=utf-8'
58
+ ServerService.new(r.arguments).reboot!
59
+ 'Done!'
60
+ end
61
+ ```
62
+
63
+ To execute the command now, you have to add the six digit one-time password to the end of it:
64
+
65
+ ```
66
+ !reboot example.com 123456
67
+ ```
68
+
69
+ The `r.valid_otp?` guard extracts the one-time password from the message and validates it. In this example, a validation result `true` causes the server to be rebooted. Please note that `r.argument` after the guard does not contain the one-time password anymore!
70
+
71
+ If halting with a 401 error is all you want, there's even a shorter alternative `r.require_valid_otp!`:
72
+
73
+ ```ruby
74
+ r.root do |r|
75
+ r.require_valid_otp!
76
+ response['Content-Type'] = 'text/plain; charset=utf-8'
77
+ ServerService.new(r.arguments).reboot!
78
+ 'Done!'
79
+ end
80
+ ```
81
+
82
+ This route does exactly the same as the more verbose one above.
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rotp'
4
+
5
+ module Rodbot
6
+ class Plugins
7
+ class Otp
8
+ module App
9
+
10
+ module RequestMethods
11
+ include Rodbot::Memoize
12
+
13
+ def valid_otp?
14
+ return false unless password
15
+ return false if Rodbot.db.get(:otp, password) # already used
16
+ valid = totp.verify(password, drift_behind: Rodbot.config(:otp, :drift).to_i)
17
+ !!if
18
+ Rodbot.db.set(:otp, password) { true }
19
+ true
20
+ end
21
+ end
22
+
23
+ def require_valid_otp!
24
+ halt [401, {}, ['Unauthorized']] unless valid_otp?
25
+ end
26
+
27
+ private
28
+
29
+ memoize def totp
30
+ secret = Rodbot.config(:plugin, :otp, :secret)
31
+ fail(Rodbot::PluginError, "OTP secret is not set") unless secret
32
+ ROTP::TOTP.new(secret, issuer: 'Rodbot')
33
+ end
34
+
35
+ # Extract (and remove) the password from arguments
36
+ #
37
+ # @return [String, nil] extracted password if any
38
+ memoize def password
39
+ params['arguments'] = params['arguments']&.sub(/\s*(\d{6})\s*\z/, '')
40
+ $1
41
+ end
42
+ end
43
+
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,13 @@
1
+ # Rodbot Plugin – Word of the Day
2
+
3
+ Word of the day announcements
4
+
5
+ ## Setup
6
+
7
+ Activate and configure this plugin in `config/rodbot.rb`:
8
+
9
+ ```ruby
10
+ plugin :word_of_the_day do
11
+ time '10:00'
12
+ end
13
+ ```
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+
5
+ module Rodbot
6
+ class Plugins
7
+ class WordOfTheDay
8
+ class Schedule
9
+ def initialize
10
+ Clockwork.every(1.day, -> { Rodbot.say message }, at: time)
11
+ end
12
+
13
+ private
14
+
15
+ def time
16
+ Rodbot.config(:plugin, :word_of_the_day, :time) || '12:00'
17
+ end
18
+
19
+ def message
20
+ Rodbot::Plugins::WordOfTheDay::Today.new.message
21
+ end
22
+
23
+ end
24
+
25
+ class Today
26
+ def initialize
27
+ @response = HTTParty.get('https://www.merriam-webster.com/word-of-the-day')
28
+ end
29
+
30
+ def message
31
+ if @response.success?
32
+ "Word of the day: [#{word}](#{url})"
33
+ else
34
+ "Sorry, there was a problem fetching the word of the day."
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def word
41
+ @response.body.match(/<h2 class="word-header-txt">(.+?)</).captures.first
42
+ end
43
+
44
+ def url
45
+ @response.body.match(/<meta property="og:url" content="(.+?)"/).captures.first
46
+ end
47
+ end
48
+
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,81 @@
1
+ # frozen-string-literal: true
2
+
3
+ using Rodbot::Refinements
4
+
5
+ module Rodbot
6
+
7
+ # Interface for bundled and gemified plugins
8
+ class Plugins
9
+
10
+ # Required service extensions from plugins
11
+ #
12
+ # @return [Hash] map from extension name (Symbol) to module or class
13
+ # path (String)
14
+ attr_reader :extensions
15
+
16
+ def initialize
17
+ @extensions = {}
18
+ end
19
+
20
+ # Extend app service with app components provided by all active plugins
21
+ def extend_app
22
+ require_extensions(:app) do |name, path|
23
+ begin
24
+ ::App.run(name, "#{path}/routes".constantize)
25
+ rescue NameError
26
+ end
27
+ begin
28
+ Roda::RodaPlugins.register_plugin(name, path.constantize)
29
+ rescue NameError
30
+ end
31
+ end
32
+ end
33
+
34
+ # Extend relay service with relay components provided by all active plugins
35
+ def extend_relay
36
+ return if extensions.key? :relay
37
+ require_extensions(:relay)
38
+ end
39
+
40
+ # Extend schedule service with schedule components provided by all active
41
+ # plugins
42
+ def extend_schedule
43
+ return if extensions.key? :schedule
44
+ require_extensions(:schedule) do |name, path|
45
+ path.constantize.new
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # Require (and log) the service extensions provided by all active plugins
52
+ #
53
+ # @param service [Symbol] any of {Rodbot::SERVICES}
54
+ # @yield additional code to execute after require
55
+ # @return [self]
56
+ def require_extensions(service)
57
+ Rodbot.config(:plugin).each_key do |name|
58
+ path = "rodbot/plugins/#{name}/#{service}"
59
+ if rescued_require(path)
60
+ Rodbot.log("#{path} required", level: Logger::DEBUG)
61
+ extensions[service] ||= {}
62
+ extensions[service][name] = path
63
+ yield(name, path) if block_given?
64
+ end
65
+ end
66
+ self
67
+ end
68
+
69
+ # Same as +require+ but never fail with +LoadError+
70
+ #
71
+ # @param path [String] path to require
72
+ # @return [Boolean] true if required (again) or false if load failed
73
+ def rescued_require(path)
74
+ require path
75
+ true
76
+ rescue LoadError
77
+ false
78
+ end
79
+
80
+ end
81
+ end
@@ -0,0 +1,50 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'httparty'
4
+
5
+ using Rodbot::Refinements
6
+
7
+ module Rodbot
8
+ module Rack
9
+
10
+ class << self
11
+
12
+ # Default +config.ru+
13
+ #
14
+ # In case you wish to do things differently, just copy the contents of
15
+ # this method into your +config.ru+ file and tweak it.
16
+ def boot(rack)
17
+ loader = Zeitwerk::Loader.new
18
+ loader.logger = Rodbot::Log.logger('loader')
19
+ loader.push_dir(Rodbot.env.root.join('lib'))
20
+ loader.push_dir(Rodbot.env.root.join('app'))
21
+
22
+ if Rodbot.env.development? || Rodbot.env.test?
23
+ loader.enable_reloading
24
+ loader.setup
25
+ rack.run ->(env) do
26
+ loader.reload
27
+ App.call(env)
28
+ end
29
+ else
30
+ loader.setup
31
+ Zeitwerk::Loader.eager_load_all
32
+ rack.run App.freeze.app
33
+ end
34
+ end
35
+
36
+ # Send request to the app service
37
+ #
38
+ # @param path [String] path e.g. +/help+
39
+ # @param query [Hash] query hash e.g. +{ search: 'foobar' }+
40
+ # @param method [Symbol, String] HTTP method
41
+ # @param timeout [Integer] max seconds to wait for response
42
+ # @return [HTTParty::Response]
43
+ def request(path, query: {}, method: :get, timeout: 10)
44
+ HTTParty.send(method, Rodbot::Services::App.url.uri_concat(path), query: query, timeout: timeout)
45
+ end
46
+
47
+ end
48
+
49
+ end
50
+ end
@@ -0,0 +1,118 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'uri'
4
+ require 'kramdown'
5
+
6
+ module Rodbot
7
+ module Refinements
8
+
9
+ # @!method camelize
10
+ # Convert from under_scores to CamelCase
11
+ #
12
+ # @example
13
+ # camelize('foo_bar') # => 'FooBar'
14
+ #
15
+ # @return [String] CamelCased string
16
+ refine String do
17
+ def camelize
18
+ Rodbot::Refinements.inflector.camelize(self, nil)
19
+ end
20
+ end
21
+
22
+ # @!method constantize
23
+ # Convert module or class path to module or class
24
+ #
25
+ # @example
26
+ # 'foo/bar_baz'.constantize # => Foo::BarBaz
27
+ #
28
+ # @return [Class, Module] class or module
29
+ refine String do
30
+ def constantize
31
+ Module.const_get(self.split('/').map(&:camelize).join('::'))
32
+ end
33
+ end
34
+
35
+ # @!method uri_concat
36
+ # Safely concat path segments to a URI string
37
+ #
38
+ # {URI#join} is ultimately used to add the given segments which has a
39
+ # maybe counter-intuitive API at first. Check out the docs of {URI#join}
40
+ # and the examples below.
41
+ #
42
+ # @example
43
+ # s = 'http://example.com'
44
+ # s.uri_concat('foo') # => "http://example.com/foo"
45
+ # s.uri_concat('foo/') # => "http://example.com/foo/"
46
+ # s.uri_concat('foo', 'bar') # => "http://example.com/bar" <- sic!
47
+ # s.uri_concat('foo/, 'bar') # => "http://example.com/foo/bar"
48
+ # s.uri_concat('foo/, 'bar.html') # => "http://example.com/foo/bar.html"
49
+ # s.uri_concat('föö') # => "http://example.com/f%C3%B6%C3%B6"
50
+ #
51
+ # @param segments [Array<String>] path segments
52
+ # @return [String] concatted URI
53
+ refine String do
54
+ def uri_concat(*segments)
55
+ parser = URI::Parser.new
56
+ segments.inject(URI(self)) do |uri, segment|
57
+ uri + parser.escape(segment)
58
+ end.to_s
59
+ end
60
+ end
61
+
62
+ # @!method md_to_html
63
+ # Converts Markdown in the string to HTML
64
+ #
65
+ # @example
66
+ # '**important**'.md_to_html # => '<strong>important</strong>'
67
+ #
68
+ # @return [String] HTML
69
+ refine String do
70
+ def md_to_html
71
+ Kramdown::Document.new(self, input: 'GFM').to_html.strip
72
+ end
73
+ end
74
+
75
+ # @!method html_to_text
76
+ # Converts HTML to plain text by removing all tags
77
+ #
78
+ # @example
79
+ # '<strong>important</strong>'.html_to_text # => 'important'
80
+ #
81
+ # @return [String] text
82
+ refine String do
83
+ def html_to_text
84
+ self.gsub(/<.*?>/, '')
85
+ end
86
+ end
87
+
88
+ # @!method psub
89
+ # Replace placeholders
90
+ #
91
+ # Placeholders are all UPCASE and wrapped in [[ and ]]. They must match
92
+ # keys in the placeholder hash, however, these keys are Symbols and all
93
+ # downcase.
94
+ #
95
+ # @example
96
+ # placeholders = { sender: 'Oggy' }
97
+ # 'Hi, [[SENDER]]!'.psub(placeholders) # => 'Hi, Oggy!'
98
+ #
99
+ # @return [String] string without placeholders
100
+ refine String do
101
+ def psub(placeholders)
102
+ self.gsub(/\[\[.*?\]\]/) { placeholders[_1[2..-3].downcase.to_sym] }
103
+ end
104
+ end
105
+
106
+ class << self
107
+ include Rodbot::Memoize
108
+
109
+ # Reusable inflector instance
110
+ #
111
+ # @return [Zeitwerk::Inflector]
112
+ memoize def inflector
113
+ Zeitwerk::Inflector.new
114
+ end
115
+ end
116
+
117
+ end
118
+ end
@@ -0,0 +1,104 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'digest'
4
+ require 'socket'
5
+ require 'httparty'
6
+
7
+ module Rodbot
8
+
9
+ # Base class for relay extensions
10
+ class Relay
11
+ include Rodbot::Memoize
12
+
13
+ class << self
14
+
15
+ # Post a message via one or more relay services
16
+ #
17
+ # By default, messages are posted via all relay services which have
18
+ # "say true" configured in their corresponding config blocks. To further
19
+ # narrow it to exactly one relay service, use the +on+ argument.
20
+ #
21
+ # @param message [String] message to post
22
+ # @param on [Symbol, nil] post via this relay service only
23
+ # @return [Boolean] +false+ if at least one relay refused the connection or
24
+ # +true+ otherwise
25
+ def say(message, on: nil)
26
+ Rodbot.config(:plugin).select do |extension, config|
27
+ config[:say] == true && (!on || extension == on)
28
+ end.keys.inject(true) do |success, extension|
29
+ write(message, extension) && success
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # Write a message to a relay service extension
36
+ #
37
+ # @param message [String] message to post
38
+ # @param extension [Symbol] post via this relay service
39
+ # @return [Boolean] +false+ if the connection was refused or +true+ otherwise
40
+ def write(message, extension)
41
+ uri = URI(Rodbot::Services::Relay.url(extension))
42
+ Socket.tcp(uri.host, uri.port, connect_timeout: 3) do |socket|
43
+ socket.write message
44
+ socket.write "\x04"
45
+ end
46
+ true
47
+ rescue Errno::ECONNREFUSED
48
+ warn "WARNING: say via relay #{extension} failed as connection was refused"
49
+ false
50
+ end
51
+ end
52
+
53
+ # Loops which will be called by the relay service
54
+ #
55
+ # @abstract
56
+ # @return [Array<Proc>]
57
+ def loops
58
+ fail(Rodbot::RelayError, "loops method is not implemented")
59
+ end
60
+
61
+ # # @abstract
62
+ # def login
63
+ # fail(Rodbot::RelayError, "login not necessary")
64
+ # end
65
+ #
66
+ # # @abstract
67
+ # def logout
68
+ # fail(Rodbot::RelayError, "logout not possible")
69
+ # end
70
+
71
+ private
72
+
73
+ # @return [Symbol] name of the relay extension
74
+ memoize def name
75
+ self.class.to_s.split('::')[-2].downcase.to_sym
76
+ end
77
+
78
+ # @see {Rodbot::Relay.bind}
79
+ # @return [String] designated "IP:port"
80
+ memoize def bind
81
+ [
82
+ (ENV["RODBOT_RELAY_HOST"] || 'localhost'),
83
+ Rodbot.config(:port) + 1 + Rodbot.config(:plugin).keys.index(name)
84
+ ]
85
+ end
86
+
87
+ # Perform the command on the app using a GET request
88
+ #
89
+ # @param command [String] command to perform
90
+ # @param argument [String, nil] optional arguments
91
+ # @return [String] response as Markdown
92
+ def command(command, argument=nil)
93
+ response = Rodbot.request(command, query: { argument: argument })
94
+ case response.code
95
+ when 200 then response.body
96
+ when 404 then "[[SENDER]] I don't know what do do with `!#{command}`. 🤔"
97
+ else fail
98
+ end
99
+ rescue
100
+ "[[SENDER]] I'm having trouble talking to the app. 💣"
101
+ end
102
+
103
+ end
104
+ end