rodbot 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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