watchmonkey_cli 1.10.0 → 1.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: 2130727c6200a2fffef5220a52052df322b126e610dfdf18431f4c05841bd286
4
- data.tar.gz: e8130e7bd3267c92a3352d102a577d7bd047c35905eae7291e9ddfe49da822e9
3
+ metadata.gz: 47f837c69b183c6b2f6f1670358d1bd4eb6745afd4db2335e2f1c95749c17022
4
+ data.tar.gz: bdc02ab27bbb69a4c15eb67c4bc310fbfc25493abb124684353ccddcf7a320e8
5
5
  SHA512:
6
- metadata.gz: '053523431494e83e9c6e72f4b1d1313cf3be03fe3f5a566deabcc2e2d22ec0db17d7d04f82a0fddbc91cd88024267d36d7a8bd1ffe7b5bc6c1990b6cfd1d41ba'
7
- data.tar.gz: d4072ceafae6848acef9e34591cf1e33ddbf6df248eafa8f6018f0c1b7feb1c66e2238b388724a15cd5fa82669b9a6cd21b1f56f0518adc4c0ce040c730074b3
6
+ metadata.gz: def716ff43830383dd7f095b5c502172ce9ac71f008a9cbee00cd18448aa8d893fdf306e717181e569e70a36facac814433b520674fc0957b27430e48857c6cf
7
+ data.tar.gz: 1fc92b89105fd547344474405c276661f1383486c5cf0443bc8ee989c991b0fe1a46c003bc88bede5ca3d7e3c91cd0e651e97d82e51d6f984291a5015fe7a63d
data/Gemfile CHANGED
@@ -2,3 +2,6 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in dle.gemspec
4
4
  gemspec
5
+
6
+ # optional dependencies
7
+ gem 'telegram-bot-ruby'
data/README.md CHANGED
@@ -4,10 +4,11 @@ Watchmonkey is a very simple tool to monitor resources with Ruby without the nee
4
4
 
5
5
  Before looking any further you might want to know:
6
6
 
7
- * There is no escalation or notification system but you may add it yourself
8
- * I created this for being used with [Platypus](http://sveinbjorn.org/platypus) hence the [Platypus Hook](https://github.com/2called-chaos/watchmonkey_cli/blob/master/lib/watchmonkey_cli/hooks/platypus.rb)
7
+ * There is no escalation or notification system (except experimental telegram bot) but you may add it yourself
8
+ * I originally created this for being used with [Platypus](http://sveinbjorn.org/platypus) hence the [Platypus Hook](https://github.com/2called-chaos/watchmonkey_cli/blob/master/lib/watchmonkey_cli/hooks/platypus.rb)
9
9
  * This is how the text output looks like: [Screenshot](http://imgur.com/8yLYnKb)
10
10
  * This is how the Platypus support looks like: [ProgressBar](http://imgur.com/Vd8ZD7A) [HTML/WebView](http://imgur.com/5FwmWFZ)
11
+ * This is how Telegram Bot looks for now: [Telegram Screenshot](http://imgur.com/HBONi51)
11
12
 
12
13
  ---
13
14
 
@@ -43,6 +44,8 @@ To get a list of available options invoke Watchmonkey with the `--help` or `-h`
43
44
  --generate-config [myconfig] Generates a example config in ~/.watchmonkey
44
45
  -l, --log [file] Log to file, defaults to ~/.watchmonkey/logs/watchmonkey.log
45
46
  -t, --threads [NUM] Amount of threads to be used for checking (default: 10)
47
+ -e, --except tag1,tag2 Don't run tasks tagged with given tags
48
+ -o, --only tag1,tag2 Only run tasks tagged with given tags
46
49
  -s, --silent Only print errors and infos
47
50
  -q, --quiet Only print errors
48
51
 
@@ -81,6 +84,15 @@ If you want to monitor something that is not covered by the buildin handlers you
81
84
  By default Watchmonkey will run all tests once and then exit. This addon will enable Watchmonkey to run in a loop and run tests on a periodic interval.
82
85
  Since this seems like a core feature it might get included directly into Watchmonkey but for now take a look at the [application configuration file](https://github.com/2called-chaos/watchmonkey_cli/blob/master/doc/config_example.rb) and [ReQueue source code](https://github.com/2called-chaos/watchmonkey_cli/blob/master/lib/watchmonkey_cli/hooks/requeue.rb) for integration examples.
83
86
 
87
+ ### Telegram Bot
88
+ Notify via Telegram. Experimental. Refer to [application configuration file](https://github.com/2called-chaos/watchmonkey_cli/blob/master/doc/config_example.rb) and [TelegramBot source code](https://github.com/2called-chaos/watchmonkey_cli/blob/master/lib/watchmonkey_cli/hooks/telegram_bot.rb) for further information.
89
+
90
+ * works with ReQueue (wouldn't make much sense otherwise huh?)
91
+ * optional per-user message throttling via checker uniqid (checker name + host + arguments)
92
+ * optional per-user only/except filters based on tags
93
+ * planned: robust telegram connection failure handling
94
+ * planned: per-user regex exclusion filters
95
+
84
96
  ### Platypus support
85
97
  [Platypus](http://sveinbjorn.org/platypus) is a MacOS software to create dead simple GUI wrappers for scripts. There is buildin support for the interface types ProgressBar and WebView. For integration examples take a look at the [application configuration file](https://github.com/2called-chaos/watchmonkey_cli/blob/master/doc/config_example.rb) and [Platypus hook source code](https://github.com/2called-chaos/watchmonkey_cli/blob/master/lib/watchmonkey_cli/hooks/platypus.rb).
86
98
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.10.0
1
+ 1.11.0
@@ -23,6 +23,7 @@ module MyWatchmonkeyCheckers
23
23
  # Called by configuration defining a check with all the arguments.
24
24
  # e.g. my_checker "http://google.com", some_option: true
25
25
  # Should invoke `app.enqueue` which will by default call `#check!` method with given arguments.
26
+ # Must have options as last argument!
26
27
  def enqueue host, opts = {}
27
28
  opts = { some_option: false }.merge(opts)
28
29
 
@@ -36,6 +37,7 @@ module MyWatchmonkeyCheckers
36
37
  end
37
38
 
38
39
  # First argument is the result object, all other arguments came from `app.enqueue` call.
40
+ # Must have options as last argument!
39
41
  def check! result, host, opts = {}
40
42
  # Do your checks and modify the result object.
41
43
  # Debug messages will not show if -s/--silent or -q/--quiet argument is passed.
@@ -43,3 +43,59 @@ if @argv.delete("--platypus")
43
43
 
44
44
  @opts[:colorize] = false # doesn't render in platypus
45
45
  end
46
+
47
+
48
+
49
+ # Integrate Telegram notifications
50
+ # For options refer to the source code:
51
+ # https://github.com/2called-chaos/watchmonkey_cli/blob/master/lib/watchmonkey_cli/hooks/telegram_bot.rb
52
+ if @argv.delete("--telegram")
53
+ require "watchmonkey_cli/hooks/telegram_bot"
54
+ WatchmonkeyCli::TelegramBot.hook!(self, {
55
+ # to create a bot refer to https://core.telegram.org/bots#6-botfather
56
+ api_key: "123456789:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
57
+
58
+ # poll timeout, the longer this is (in seconds) the longer it will take to gracefully shut down
59
+ timeout: 5,
60
+
61
+ # optionally log incoming messages
62
+ #logger: Logger.new(STDOUT),
63
+
64
+ # purge old throttle data, default: 30.days
65
+ #throttle_retention: 30.days,
66
+
67
+ # retry sending messages that failed, default: false
68
+ # Not recommended since on connection failure a HUGE amount of messages will accumulate
69
+ # and spam you (and reach rate limits) upon connection restore.
70
+ #retry_on_egress_failure: false,
71
+
72
+ # configure your notification targets, if not listed you can't interact with the bot
73
+ notify: [
74
+ [
75
+ # your telegram ID, if you try talking to the bot it will tell you your ID
76
+ 987654321,
77
+
78
+ # flags
79
+ # - :all -- same as :debug, :info, :error (not recommended)
80
+ # - :debug -- send all debug messages (not recommended)
81
+ # - :info -- send all info messages (not recommended)
82
+ # - :error -- send all error messages (RECOMMENDED)
83
+ # - :admin_flag -- allows access to some commands (/wm_shutdown /stats)
84
+ [:error, :admin_flag],
85
+
86
+ # options (all optional, you can comment them out but leave the {})
87
+ {
88
+ # throttle: seconds(int) -- throttle messages by checker uniqid for this long (0/false = no throttle, default)
89
+ throttle: 15*60,
90
+
91
+ # only: Array(string, symbol) -- only notify when tagged with given tags
92
+ only: %w[production critical],
93
+
94
+ # except: Array(string, symbol) -- don't notify when tagged with given tags (runs after only-check)
95
+ except: %w[database],
96
+ }
97
+ ],
98
+ [123456789, [:error], { throttle: 30.minutes }]
99
+ ],
100
+ })
101
+ end
@@ -21,9 +21,22 @@ module WatchmonkeyCli
21
21
  app.haltpoint
22
22
  rescue Interrupt
23
23
  app.abort("Interrupted", 1)
24
+ rescue SystemExit
25
+ # silence
24
26
  ensure
27
+ $wm_runtime_exiting = true
25
28
  app.fire(:wm_shutdown)
26
- app.debug "#{Thread.list.length} threads remain..."
29
+ if app.filtered_threads.length > 1
30
+ app.error "[WARN] #{app.filtered_threads.length} threads remain (should be 1)..."
31
+ app.filtered_threads.each do |thr|
32
+ app.debug "[THR] #{Thread.main == thr ? "MAIN" : "THREAD"}\t#{thr.alive? ? "ALIVE" : "DEAD"}\t#{thr.inspect}", 10
33
+ thr.backtrace.each do |l|
34
+ app.debug "[THR]\t#{l}", 20
35
+ end
36
+ end
37
+ else
38
+ app.debug "1 thread remains..."
39
+ end
27
40
  end
28
41
  end
29
42
  end
@@ -52,6 +65,8 @@ module WatchmonkeyCli
52
65
  silent: false, # -s flag
53
66
  quiet: false, # -q flag
54
67
  stdout: STDOUT, # (internal) STDOUT redirect
68
+ tag_only: [], # -o flag
69
+ tag_except: [], # -e flag
55
70
  }
56
71
  init_params
57
72
  yield(self)
@@ -65,6 +80,8 @@ module WatchmonkeyCli
65
80
  opts.on("--generate-config [myconfig]", "Generates a example config in ~/.watchmonkey") {|s| @opts[:dispatch] = :generate_config; @opts[:config_name] = s }
66
81
  opts.on("-l", "--log [file]", "Log to file, defaults to ~/.watchmonkey/logs/watchmonkey.log") {|s| @opts[:logfile] = s || logger_filename }
67
82
  opts.on("-t", "--threads [NUM]", Integer, "Amount of threads to be used for checking (default: 10)") {|s| @opts[:threads] = s }
83
+ opts.on("-e", "--except tag1,tag2", Array, "Don't run tasks tagged with given tags") {|s| @opts[:tag_except] = s.map(&:to_sym) }
84
+ opts.on("-o", "--only tag1,tag2", Array, "Only run tasks tagged with given tags") {|s| @opts[:tag_only] = s.map(&:to_sym) }
68
85
  opts.on("-s", "--silent", "Only print errors and infos") { @opts[:silent] = true }
69
86
  opts.on("-q", "--quiet", "Only print errors") { @opts[:quiet] = true }
70
87
 
@@ -76,10 +93,15 @@ module WatchmonkeyCli
76
93
  opts.on("-z", "Do not check for updates on GitHub (with -v/--version)") { @opts[:check_for_updates] = false }
77
94
  opts.on("--dump-core", "for developers") { @opts[:dump] = true }
78
95
  end
96
+ fire(:optparse_init, @optparse)
79
97
  end
80
98
 
81
99
  def parse_params
82
- @optparse.parse!(@argv)
100
+ fire(:optparse_parse_before, @optparse)
101
+ fire(:optparse_parse_around, @optparse) do
102
+ @optparse.parse!(@argv)
103
+ end
104
+ fire(:optparse_parse_after, @optparse)
83
105
  rescue OptionParser::ParseError => e
84
106
  abort(e.message)
85
107
  dispatch(:help)
@@ -62,6 +62,7 @@ module WatchmonkeyCli
62
62
  def initialize app, file
63
63
  @app = app
64
64
  @file = file
65
+ @tags = []
65
66
  begin
66
67
  eval File.read(file, encoding: "utf-8"), binding, file
67
68
  rescue
@@ -74,9 +75,28 @@ module WatchmonkeyCli
74
75
  @app.fetch_connection(:ssh, name, opts, &b)
75
76
  end
76
77
 
78
+ def tag_all! *tags
79
+ @tags = tags.map(&:to_sym)
80
+ end
81
+
77
82
  def method_missing meth, *args, &block
78
83
  if c = @app.checkers[meth.to_s]
79
- c.enqueue(*args)
84
+ opts = args.extract_options!
85
+ only = @app.opts[:tag_only]
86
+ except = @app.opts[:tag_except]
87
+ tags = (@tags + (opts[:tags] || []).map(&:to_sym)).uniq
88
+ if only.any?
89
+ if tags.any?{|t| only.include?(t) }
90
+ if tags.any?{|t| except.include?(t) }
91
+ return @app.debug "Skipping #{meth} with #{args} and #{opts} due to tag_except filter..."
92
+ end
93
+ else
94
+ return @app.debug "Skipping #{meth} with #{args} and #{opts} due to tag_only filter..."
95
+ end
96
+ elsif tags.any?{|t| except.include?(t) }
97
+ return @app.debug "Skipping #{meth} with #{args} and #{opts} due to tag_except filter..."
98
+ end
99
+ c.enqueue(*args, opts.merge(tags: tags))
80
100
  else
81
101
  super
82
102
  end
@@ -1,5 +1,26 @@
1
1
  # This is a Ruby file!
2
2
 
3
+ # =========================================
4
+ # = Step 0: Tag all checkers in this file =
5
+ # =========================================
6
+
7
+ # All checkers you define after this will get these
8
+ # base tags (you can pass additional ones).
9
+ # Do note that you can call this method multiple
10
+ # times and it will replace your base tags for
11
+ # all checkers to follow, base tags will be cleared
12
+ # upon reaching the end of the file.
13
+
14
+ tag_all! :production, :critical
15
+
16
+ # You can use additional tags on any checker by passing
17
+ # a `tags` option consisting of an Array of strings or symbols.
18
+ # e.g.:
19
+ # some_checker :some_target, tags: %w[critical foo bar]
20
+
21
+
22
+
23
+
3
24
  # =================================
4
25
  # = Step 1: Setup SSH connections =
5
26
  # =================================
@@ -1,6 +1,12 @@
1
1
  module WatchmonkeyCli
2
2
  class Application
3
3
  module Core
4
+ def filtered_threads
5
+ Thread.list.reject do |thr|
6
+ thr.backtrace[0]["gems/concurrent-ruby"] rescue false
7
+ end
8
+ end
9
+
4
10
  # ===================
5
11
  # = Signal trapping =
6
12
  # ===================
@@ -37,10 +43,31 @@ module WatchmonkeyCli
37
43
  end
38
44
  end
39
45
 
40
- def fire which, *args
46
+ def fire which, *args, &block
41
47
  return if @disable_event_firing
42
- sync { debug "[Event] Firing #{which} (#{@hooks[which].try(:length) || 0} handlers) #{args.map(&:class)}", 99 }
43
- @hooks[which] && @hooks[which].each{|h| h.call(*args) }
48
+ sync { debug "[Event] Firing #{which} (#{@hooks[which].try(:length) || 0} handlers) #{args.map(&:class)}#{" HAS_BLOCK" if block}", 99 }
49
+ if block && (!@hooks[which] || @hooks[which].empty?)
50
+ block.call
51
+ else
52
+ if @hooks[which] && @hooks[which].any?
53
+ if block
54
+ catch :abort do
55
+ _fire_around(@hooks[which], args, 0, &block)
56
+ end
57
+ else
58
+ @hooks[which].all?{|h| h.call(*args) }
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ def _fire_around hooks, args, index = 0, &block
65
+ return block.call unless hook = hooks[index]
66
+ skip = catch(:skip) {
67
+ hook.call(*args) { _fire_around(hooks, args, index + 1, &block) }
68
+ nil
69
+ }
70
+ _fire_around(hooks, args, index + 1, &block) if skip
44
71
  end
45
72
 
46
73
 
@@ -82,8 +109,10 @@ module WatchmonkeyCli
82
109
  def close_connections!
83
110
  @connections.each do |type, clist|
84
111
  clist.each do |id, con|
85
- debug "[SHUTDOWN] closing #{type} connection #{id} #{con}"
86
- con.close!
112
+ if con.established?
113
+ debug "[SHUTDOWN] closing #{type} connection #{id} #{con}"
114
+ con.close!
115
+ end
87
116
  end
88
117
  end
89
118
  end
@@ -100,8 +129,14 @@ module WatchmonkeyCli
100
129
  @queue << [checker, a, ->(*a) {
101
130
  begin
102
131
  result = Checker::Result.new(checker, *a)
132
+
133
+ # assign tags
134
+ taskopts = a.extract_options!
135
+ result.tags = taskopts[:tags] || []
136
+ a << taskopts
137
+
103
138
  checker.debug(result.str_running)
104
- checker.safe(result.str_safe) {
139
+ checker.rsafe(result) {
105
140
  timeout = checker.class.maxrt.nil? ? @opts[:maxrt] : checker.class.maxrt
106
141
  timeout = timeout.call(self, checker, a) if timeout.respond_to?(:call)
107
142
  begin
@@ -3,7 +3,11 @@ module WatchmonkeyCli
3
3
  module Dispatch
4
4
  def dispatch action = (@opts[:dispatch] || :help)
5
5
  if respond_to?("dispatch_#{action}")
6
- send("dispatch_#{action}")
6
+ fire(:dispatch_before, action)
7
+ fire(:dispatch_around, action) do
8
+ send("dispatch_#{action}")
9
+ end
10
+ fire(:dispatch_after, action)
7
11
  else
8
12
  abort("unknown action #{action}", 1)
9
13
  end
@@ -13,6 +13,10 @@ module WatchmonkeyCli
13
13
  sync { @opts[:stdout].send(:warn, *a) }
14
14
  end
15
15
 
16
+ def info msg
17
+ puts c("[INFO] #{msg}", :blue)
18
+ end
19
+
16
20
  def debug msg, lvl = 1
17
21
  puts c("[DEBUG] #{msg}", :black) if @opts[:debug] && @opts[:debug] >= lvl
18
22
  end
@@ -52,16 +52,24 @@ module WatchmonkeyCli
52
52
 
53
53
  class Result
54
54
  attr_reader :checker, :type, :args
55
- attr_accessor :result, :command, :data
55
+ attr_accessor :result, :command, :data, :tags
56
56
 
57
57
  def initialize checker, *args
58
58
  @checker = checker
59
59
  @args = args
60
60
  @mutex = Monitor.new
61
61
  @type = :info
62
+ @tags = []
62
63
  @spool = { error: [], info: [], debug: []}
63
64
  end
64
65
 
66
+ def uniqid additional = []
67
+ ([
68
+ self.class.name,
69
+ @args.map(&:to_s).to_s,
70
+ ] + additional).join("/")
71
+ end
72
+
65
73
  def sync &block
66
74
  @mutex.synchronize(&block)
67
75
  end
@@ -197,10 +205,12 @@ module WatchmonkeyCli
197
205
  e.backtrace.each{|l| resultobj.debug "\t\t#{l}" }
198
206
  resultobj.dump!
199
207
  end
200
- sleep 1
201
- retry
208
+ unless $wm_runtime_exiting
209
+ sleep 1
210
+ retry
211
+ end
202
212
  end
203
- resultobj.error! "#{descriptor}retries exceeded"
213
+ resultobj.error! "retries exceeded"
204
214
  resultobj.dump!
205
215
  end
206
216
  end
@@ -5,7 +5,7 @@ module WatchmonkeyCli
5
5
  self.maxrt = false
6
6
 
7
7
  def enqueue host, opts = {}
8
- host = app.fetch_connection(:loopback, :local) if !host || host == :local
8
+ host = app.fetch_connection(:loopback, :local) if !host || host == :local
9
9
  host = app.fetch_connection(:ssh, host) if host.is_a?(Symbol)
10
10
  app.enqueue(self, host, opts)
11
11
  end
@@ -71,7 +71,7 @@ module WatchmonkeyCli
71
71
  <dt>Items in Queue</dt><dd class="qlength">#{@queue.length}</dd>
72
72
  <dt>Items in ReQ</dt><dd class="rqlength">#{@requeue.length}</dd>
73
73
  <dt>Workers</dt><dd class="workers">#{@threads.select{|t| t[:working] }.length}/#{@threads.length} working (#{@threads.select(&:alive?).length} alive)</dd>
74
- <dt>Threads</dt><dd class="tlength">#{Thread.list.length}</dd>
74
+ <dt>Threads</dt><dd class="tlength">#{filtered_threads.length}</dd>
75
75
  <dt>Processed entries</dt><dd class="processed">#{@processed}</dd>
76
76
  <dt>Watching since</dt><dd>#{@boot}</dd>
77
77
  <dt>Last draw</dt><dd class="lastdraw">#{Time.current}</dd>
@@ -93,7 +93,7 @@ module WatchmonkeyCli
93
93
  $("dd.qlength").html("#{@queue.length}");
94
94
  $("dd.rqlength").html("#{@requeue.length}");
95
95
  $("dd.workers").html("#{@threads.select{|t| t[:working] }.length}/#{@threads.length} working#{ti}");
96
- $("dd.tlength").html("#{Thread.list.length}");
96
+ $("dd.tlength").html("#{filtered_threads.length}");
97
97
  $("dd.processed").html("#{@processed}");
98
98
  $("dd.lastdraw").html("#{Time.current}");
99
99
  $("pre.lasterrors").html("#{escape_javascript @platypus_status_cache[:errors].map{|t,e| "#{t}: #{e}" }.join("\n")}");
@@ -37,11 +37,12 @@ module WatchmonkeyCli
37
37
  puts " Queue: #{@queue.length}"
38
38
  puts " Requeue: #{@requeue.length}"
39
39
  puts " Workers: #{@threads.select{|t| t[:working] }.length}/#{@threads.length} working (#{@threads.select(&:alive?).length} alive)"
40
- puts " Threads: #{Thread.list.length}"
40
+ puts " Threads: #{filtered_threads.length}"
41
41
  # puts " #{@threads.select(&:alive?).length} alive"
42
42
  # puts " #{@threads.select{|t| t.status == "run" }.length} running"
43
43
  # puts " #{@threads.select{|t| t.status == "sleep" }.length} sleeping"
44
44
  puts " Processed: #{@processed}"
45
+ puts " Promises: #{@telegram_bot_egress_promises.length}" if @telegram_bot_egress_promises
45
46
  puts "========== //STATUS =========="
46
47
  end
47
48
  end
@@ -101,6 +102,16 @@ module WatchmonkeyCli
101
102
  }]
102
103
  end
103
104
  end
105
+
106
+ def requeue_runall
107
+ return if $wm_runtime_exiting
108
+ sync do
109
+ debug "Running all queued tasks immediately!"
110
+ @requeue.each_with_index do |(run_at, callback), index|
111
+ @requeue[index][0] = Time.now
112
+ end
113
+ end
114
+ end
104
115
  end
105
116
  end
106
117
  end
@@ -0,0 +1,566 @@
1
+ module WatchmonkeyCli
2
+ class TelegramBot
3
+ BotProbeError = Class.new(::RuntimeError)
4
+ UnknownEgressType = Class.new(::RuntimeError)
5
+
6
+ class Event
7
+ attr_reader :app, :bot
8
+
9
+ def initialize app, bot, event
10
+ @app, @bot, @event = app, bot, event
11
+ end
12
+
13
+ def raw
14
+ @event
15
+ end
16
+
17
+ def method_missing method, *args, &block
18
+ @event.__send__(method, *args, &block)
19
+ end
20
+
21
+ def user_data
22
+ app.telegram_bot_get_udata(raw.from.id)
23
+ end
24
+
25
+ def user_admin?
26
+ user_data && user_data[1].include?(:admin_flag)
27
+ end
28
+
29
+ def from_descriptive
30
+ "".tap do |name|
31
+ name << "[BOT] " if from.is_bot
32
+ name << "#{from.first_name} " if from.first_name.present?
33
+ name << "#{from.last_name} " if from.last_name.present?
34
+ name << "(#{from.username}) " if from.username.present?
35
+ name << "[##{from.id}]"
36
+ end.strip
37
+ end
38
+
39
+ def message?
40
+ raw.is_a?(Telegram::Bot::Types::Message)
41
+ end
42
+
43
+ def message
44
+ return false unless message?
45
+ raw.text.to_s
46
+ end
47
+
48
+ def chunks
49
+ @_chunks ||= message.split(" ")
50
+ end
51
+
52
+ def command?
53
+ raw.is_a?(Telegram::Bot::Types::Message) && message.start_with?("/")
54
+ end
55
+
56
+ def command
57
+ chunks.first if command?
58
+ end
59
+
60
+ def args
61
+ chunks[1..-1]
62
+ end
63
+
64
+ def reply msg, msgopts = {}
65
+ return unless msg.present?
66
+ msgopts = msgopts.merge(text: msg, chat_id: raw.chat.id)
67
+ msgopts = msgopts.merge(reply_to_message_id: raw.message_id) unless msgopts[:quote] == false
68
+ app.telegram_bot_send(msgopts.except(:quote))
69
+ end
70
+ end
71
+
72
+ class BetterQueue
73
+ def initialize
74
+ @stor = []
75
+ @monitor = Monitor.new
76
+ end
77
+
78
+ def sync &block
79
+ @monitor.synchronize(&block)
80
+ end
81
+
82
+ [:length, :empty?, :push, :pop, :unshift, :shift, :<<].each do |meth|
83
+ define_method(meth) do |*args, &block|
84
+ sync { @stor.send(meth, *args, &block) }
85
+ end
86
+ end
87
+ end
88
+
89
+ class PromiseHandler < BetterQueue
90
+ def scrub!
91
+ @stor.dup.each_with_index do |p, i|
92
+ @stor.delete(p) if [:fulfilled, :rejected].include?(p.state)
93
+ end
94
+ end
95
+
96
+ def await_all!
97
+ while p = shift
98
+ p.wait
99
+ end
100
+ end
101
+ end
102
+
103
+ def self.hook!(app, bot_opts = {})
104
+ app.opts[:telegram_bot] = bot_opts.reverse_merge({
105
+ debug: false,
106
+ info: true,
107
+ error: true,
108
+ throttle_retention: 30.days,
109
+ retry_on_egress_failure: false,
110
+ notify: []
111
+ })
112
+
113
+ app.instance_eval do
114
+ hook :dispatch_around do |action, &act|
115
+ throw :skip, true unless action == :index
116
+ begin
117
+ require "telegram/bot"
118
+ rescue LoadError
119
+ abort "[TelegramBot] cannot load telegram/bot, run\n\t# gem install telegram-bot-ruby"
120
+ end
121
+ telegram_bot_dispatch(&act)
122
+ end
123
+
124
+ [:debug, :info, :error].each do |level|
125
+ hook :"on_#{level}" do |msg, robj|
126
+ telegram_bot_notify(msg, robj)
127
+ end if app.opts[:telegram_bot][level]
128
+ end
129
+
130
+ hook :wm_shutdown do
131
+ debug "[TelegramBot] shutting down telegram bot ingress thread"
132
+ if @telegram_bot_ingress&.alive?
133
+ @telegram_bot_ingress[:stop] = true
134
+ @telegram_bot_ingress.join
135
+ end
136
+
137
+ debug "[TelegramBot] shutting down telegram bot egress thread"
138
+ if @telegram_bot_egress&.alive?
139
+ telegram_bot_msg_admins "<b>ALERT: Watchmonkey is shutting down!</b>", parse_mode: "HTML" rescue false
140
+ @telegram_bot_egress_promises.await_all!
141
+ @telegram_bot_egress[:stop] = true
142
+ @telegram_bot_egress.join
143
+ end
144
+ end
145
+
146
+ # --------------------
147
+
148
+ def telegram_bot_state
149
+ "#{wm_cfg_path}/telegram_bot.wmstate"
150
+ end
151
+
152
+ def telegram_bot_dispatch &act
153
+ parent_thread = Thread.current
154
+ @telegram_bot_throttle_locks = {}
155
+ @telegram_bot_egress_promises = PromiseHandler.new
156
+ @telegram_bot_egress_queue = BetterQueue.new
157
+ telegram_bot_throttle # eager load
158
+ telegram_bot_normalize_notify_udata
159
+
160
+ # ==================
161
+ # = ingress thread =
162
+ # ==================
163
+ debug "[TelegramBot] Starting telegram bot ingress thread..."
164
+ @telegram_bot_ingress = Thread.new do
165
+ Thread.current.abort_on_exception = true
166
+ begin
167
+ until Thread.current[:stop]
168
+ begin
169
+ telegram_bot.fetch_updates {|ev| telegram_bot_handle_event(ev) }
170
+ rescue SocketError, Faraday::ConnectionFailed => ex
171
+ error "[TelegramBot] Poll failure, retrying in 3 seconds (#{ex.class}: #{ex.message})"
172
+ sleep 3 unless Thread.current[:stop]
173
+ retry unless Thread.current[:stop]
174
+ end
175
+ end
176
+ rescue StandardError => ex
177
+ parent_thread.raise(ex)
178
+ end
179
+ end
180
+
181
+ # wait for telegram bot to be ready
182
+ begin
183
+ Timeout::timeout(10) do
184
+ loop do
185
+ break if @telegram_bot_info
186
+ sleep 0.5
187
+ end
188
+ end
189
+ rescue Timeout::Error
190
+ abort "[TelegramBot] Failed to start telegram bot within 10 seconds, aborting...", 2
191
+ end
192
+
193
+ # ==================
194
+ # = egress thread =
195
+ # ==================
196
+ debug "[TelegramBot] Starting telegram bot egress thread..."
197
+ @telegram_bot_egress = Thread.new do
198
+ Thread.current.abort_on_exception = true
199
+ begin
200
+ until Thread.current[:stop] && @telegram_bot_egress_queue.empty?
201
+ begin
202
+ promise, item = @telegram_bot_egress_queue.shift
203
+ if item
204
+ mode = item.delete(:__mode)
205
+ case mode
206
+ when :send then promise.set telegram_bot.api.send_message(item)
207
+ when :edit then promise.set telegram_bot.api.edit_message_text(item)
208
+ else raise(UnknownEgressType, "Unknown egress mode `#{mode}'!")
209
+ end
210
+ else
211
+ @telegram_bot_egress_promises.scrub!
212
+ sleep 0.5
213
+ end
214
+ rescue SocketError, Faraday::ConnectionFailed => ex
215
+ if opts[:telegram_bot][:retry_on_egress_failure]
216
+ error "[TelegramBot] Push failure, retrying in 3 seconds (#{ex.class}: #{ex.message})"
217
+ @telegram_bot_egress_queue.unshift([promise, item.merge(__mode: mode)])
218
+ sleep 3 unless Thread.current[:stop]
219
+ else
220
+ error "[TelegramBot] Push failure, discarding message (#{ex.class}: #{ex.message})"
221
+ sleep 0.5
222
+ end
223
+ end
224
+ end
225
+ rescue StandardError => ex
226
+ parent_thread.raise(ex)
227
+ end
228
+ end
229
+
230
+ telegram_bot_msg_admins "<b>INFO: Watchmonkey is watching#{" ONCE" unless opts[:loop_forever]}!</b>", disable_notification: true, parse_mode: "HTML"
231
+ act.call
232
+ rescue BotProbeError => ex
233
+ abort ex.message
234
+ ensure
235
+ @telegram_bot_egress_promises.await_all!
236
+ File.open(telegram_bot_state, "w+") do |f|
237
+ f << telegram_bot_throttle.to_json
238
+ end
239
+ end
240
+
241
+ def telegram_bot
242
+ @telegram_bot ||= begin
243
+ Telegram::Bot::Client.new(opts[:telegram_bot][:api_key], opts[:telegram_bot].except(:api_key)).tap do |bot|
244
+ begin
245
+ me = bot.api.get_me
246
+ if me["ok"]
247
+ @telegram_bot_info = me["result"]
248
+ info "[TelegramBot] Established client [#{@telegram_bot_info["id"]}] #{@telegram_bot_info["first_name"]} (#{@telegram_bot_info["username"]})"
249
+ else
250
+ raise BotProbeError, "Failed to get telegram client information, got NOK response (#{me.inspect})"
251
+ end
252
+ rescue HTTParty::RedirectionTooDeep, Telegram::Bot::Exceptions::ResponseError => ex
253
+ raise BotProbeError, "Failed to get telegram client information, API key correct? (#{ex.class})"
254
+ rescue SocketError, Faraday::ConnectionFailed => ex
255
+ raise BotProbeError, "Failed to get telegram client information, connection failure? (#{ex.class}: #{ex.message})"
256
+ end
257
+ end
258
+ end
259
+ end
260
+
261
+ def telegram_bot_send payload
262
+ Concurrent::Promise.new.tap do |promise|
263
+ @telegram_bot_egress_queue.push([promise, payload.merge(__mode: :send)])
264
+ end
265
+ end
266
+
267
+ def telegram_bot_edit payload
268
+ Concurrent::Promise.new.tap do |promise|
269
+ @telegram_bot_egress_queue.push([promise, payload.merge(__mode: :edit)])
270
+ end
271
+ end
272
+
273
+ def telegram_bot_tid_exclusive tid, &block
274
+ sync do
275
+ @telegram_bot_throttle_locks[tid] ||= Monitor.new
276
+ end
277
+ @telegram_bot_throttle_locks[tid].synchronize(&block)
278
+ end
279
+
280
+ def telegram_bot_throttle
281
+ @telegram_bot_throttle ||= begin
282
+ Hash.new {|h,k| h[k] = Hash.new }.tap do |h|
283
+ if File.exist?(telegram_bot_state)
284
+ json = File.read(telegram_bot_state)
285
+ if json.present?
286
+ JSON.parse(json).each do |k, v|
287
+ case k
288
+ when "__mute_until"
289
+ h[k] = Time.parse(v)
290
+ else
291
+ v.each do |k, data|
292
+ data[0] = Time.parse(data[0])
293
+ data[1] = Time.parse(data[1])
294
+ end
295
+ v.delete_if {|k, data| data[0] < opts[:telegram_bot][:throttle_retention].ago }
296
+ h[k.to_i] = v
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end
303
+ end
304
+
305
+ def telegram_bot_normalize_notify_udata
306
+ opts[:telegram_bot][:notify].each do |a|
307
+ a[1] = (a[1] ? [*a[1]] : [:error]).flat_map(&:to_sym).uniq
308
+ a[2] ||= {}
309
+ end
310
+ end
311
+
312
+ def telegram_bot_get_udata lookup_tid
313
+ opts[:telegram_bot][:notify].detect do |tid, level, topts|
314
+ tid == lookup_tid
315
+ end
316
+ end
317
+
318
+ def telegram_bot_user_muted? tid, notify = true
319
+ telegram_bot_tid_exclusive(tid) do
320
+ return false unless mute_until = telegram_bot_throttle[tid]["__mute_until"]
321
+ if mute_until > Time.current
322
+ return true
323
+ else
324
+ telegram_bot_throttle[tid].delete("__mute_until")
325
+ telegram_bot_send(text: "You have been unmuted (expired #{mute_until})", chat_id: tid, disable_notification: true) if notify
326
+ return false
327
+ end
328
+ end
329
+ end
330
+
331
+ def telegram_bot_notify msg, robj
332
+ to_notify = opts[:telegram_bot][:notify].select do |tid, level, topts|
333
+ level.include?(:all) || level.include?(robj.type)
334
+ end
335
+
336
+ to_notify.each do |tid, level, topts|
337
+ telegram_bot_tid_exclusive(tid) do
338
+ next if telegram_bot_user_muted?(tid)
339
+ if robj
340
+ # gate only tags
341
+ next if topts[:only] && topts[:only].any? && !robj.tags.any?{|t| topts[:only].include?(t) }
342
+
343
+ # gate except tags
344
+ next if topts[:except] && topts[:except].any? && robj.tags.any?{|t| topts[:except].include?(t) }
345
+
346
+ # send message
347
+ throttle = telegram_bot_throttle[tid][robj.uniqid] ||= [Time.current, Time.current, 0, nil]
348
+ throttle[2] += 1
349
+ if (Time.current - throttle[0]) <= (topts[:throttle] || 0) && throttle[3]
350
+ throttle[1] = Time.current
351
+ _telegram_bot_sendmsg(tid, msg, throttle[2], throttle, throttle[3])
352
+ else
353
+ throttle[0] = Time.current
354
+ throttle[2] = 1
355
+ throttle[3] = nil
356
+ _telegram_bot_sendmsg(tid, msg, throttle[2], throttle)
357
+ end
358
+ else
359
+ _telegram_bot_sendmsg(tid, msg, 0, [])
360
+ end
361
+ end
362
+ end
363
+ end
364
+
365
+ def _telegram_bot_sendmsg tid, msg, repeat, result_to, msgid = nil
366
+ msg = "<pre>#{ERB::Util.h msg}</pre>"
367
+ msg = "#{msg}\n<b>(message repeated #{repeat} times)</b> #{" <i>last occurance: #{result_to[1]}</i>" if result_to[1]}" if repeat > 1 && msgid
368
+ (msgid ?
369
+ telegram_bot_edit(chat_id: tid, message_id: msgid, text: msg, disable_web_page_preview: true, parse_mode: "html")
370
+ :
371
+ telegram_bot_send(chat_id: tid, text: msg, disable_web_page_preview: true, parse_mode: "html")
372
+ ).tap do |promise|
373
+ await = Concurrent::Promise.new
374
+ promise.on_success do |m|
375
+ if msgid = m.dig("result", "message_id")
376
+ result_to[3] = msgid
377
+ await.set :success
378
+ else
379
+ puts m.inspect
380
+ await.set :failed
381
+ end
382
+ end
383
+ promise.on_error do |ex|
384
+ error "[TelegramBot] MessagePromiseError - #{ex.class}:#{ex.message}"
385
+ await.set :failed
386
+ end
387
+ @telegram_bot_egress_promises << await
388
+ end
389
+ rescue
390
+ end
391
+
392
+ def _telegram_bot_timestr_parse *chunks
393
+ time = Time.current
394
+ chunks.flatten.each do |chunk|
395
+ if chunk.end_with?("d")
396
+ time += chunk[0..-2].to_i.days
397
+ elsif chunk.end_with?("h")
398
+ time += chunk[0..-2].to_i.hours
399
+ elsif chunk.end_with?("m")
400
+ time += chunk[0..-2].to_i.minutes
401
+ elsif chunk.end_with?("s")
402
+ time += chunk[0..-2].to_i.seconds
403
+ else
404
+ time += chunk.to_i.seconds
405
+ end
406
+ end
407
+ time
408
+ end
409
+
410
+ def telegram_bot_handle_event ev
411
+ Event.new(self, telegram_bot, ev).tap do |event|
412
+ begin
413
+ ctrl = catch :event_control do
414
+ case event.command
415
+ when "/ping"
416
+ event.reply "Pong!"
417
+ throw :event_control, :done
418
+ when "/start"
419
+ if event.user_data
420
+ event.reply [].tap{|m|
421
+ m << "<b>Welcome!</b> I will tell you if something is wrong with bmonkeys infrastructure."
422
+ m << "Your current tags are: #{event.user_data[1].join(", ")}"
423
+ m << "<b>You have admin permissions!</b>" if event.user_admin?
424
+ m << "\nInstead of muting me in Telegram you can silence me for a while with <code>/mute 6h 30m</code>."
425
+ m << "Use <code>/help</code> for more information."
426
+ }.join("\n"), parse_mode: "HTML"
427
+ else
428
+ event.reply %{
429
+ Hello there, unfortunately I don't recognize your user id (#{event.from.id}).
430
+ Please ask your admin to add you to the configuration file.
431
+ }
432
+ end
433
+ throw :event_control, :done
434
+ when "/mute"
435
+ if event.user_data
436
+ telegram_bot_tid_exclusive(event.from.id) do
437
+ telegram_bot_user_muted?(event.from.id) # clears if expired
438
+ if event.args.any?
439
+ mute_until = _telegram_bot_timestr_parse(event.args)
440
+ telegram_bot_throttle[event.from.id]["__mute_until"] = mute_until
441
+ event.reply "You are muted until #{mute_until}\nUse /unmute to prematurely cancel the mute.", disable_notification: true
442
+ else
443
+ msg = "Usage: <code>/mute &lt;1d 2h 3m 4s&gt;</code>"
444
+ if mute_until = telegram_bot_throttle[event.from.id]["__mute_until"]
445
+ msg << "\n<b>You are currently muted until #{mute_until}</b> /unmute"
446
+ end
447
+ event.reply msg, parse_mode: "HTML", disable_notification: true
448
+ end
449
+ end
450
+ throw :event_control, :done
451
+ else
452
+ throw :event_control, :access_denied
453
+ end
454
+ when "/unmute"
455
+ if event.user_data
456
+ telegram_bot_tid_exclusive(event.from.id) do
457
+ if mute_until = telegram_bot_throttle[event.from.id]["__mute_until"]
458
+ event.reply "You have been unmuted (prematurely canceled #{mute_until})", disable_notification: true
459
+ else
460
+ event.reply "You are not currently muted.", disable_notification: true
461
+ end
462
+ end
463
+ throw :event_control, :done
464
+ else
465
+ throw :event_control, :access_denied
466
+ end
467
+ when "/scan", "/rescan"
468
+ if event.user_data
469
+ if respond_to?(:requeue_runall)
470
+ event.reply "Triggering all tasks in ReQueue…", disable_notification: true
471
+ requeue_runall
472
+ else
473
+ event.reply "Watchmonkey is not running with ReQueue!", disable_notification: true
474
+ end
475
+ throw :event_control, :done
476
+ else
477
+ throw :event_control, :access_denied
478
+ end
479
+ when "/clearthrottle", "/clearthrottles"
480
+ if event.user_data
481
+ telegram_bot_tid_exclusive(event.from.id) do
482
+ was_muted = telegram_bot_throttle[event.from.id].delete("__mute_until")
483
+ telegram_bot_throttle.delete(event.from.id)
484
+ event.reply "Cleared all your throttles!", disable_notification: true
485
+ if was_muted
486
+ telegram_bot_throttle[event.from.id]["__mute_until"] = was_muted
487
+ end
488
+ end
489
+ throw :event_control, :done
490
+ else
491
+ throw :event_control, :access_denied
492
+ end
493
+ when "/status"
494
+ if event.user_admin?
495
+ msg = []
496
+ msg << "<pre>"
497
+ msg << "========== STATUS =========="
498
+ msg << " Queue: #{@queue.length}"
499
+ msg << " Requeue: #{@requeue.length}" if @requeue
500
+ msg << " Workers: #{@threads.select{|t| t[:working] }.length}/#{@threads.length} working (#{@threads.select(&:alive?).length} alive)"
501
+ msg << " Threads: #{filtered_threads.length}"
502
+ msg << " Processed: #{@processed}"
503
+ msg << " Promises: #{@telegram_bot_egress_promises.length}"
504
+ msg << "========== //STATUS =========="
505
+ msg << "</pre>"
506
+ event.reply msg.join("\n"), parse_mode: "HTML", disable_notification: true
507
+ throw :event_control, :done
508
+ else
509
+ throw :event_control, :access_denied
510
+ end
511
+ when "/help"
512
+ event.reply [].tap{|m|
513
+ m << "<b>/start</b> Bot API"
514
+ if event.user_data
515
+ m << "<b>/mute &lt;time&gt;</b> Don't send messages for this long (e.g. <code>/mute 1d 2h 3m 4s</code>)"
516
+ m << "<b>/unmute</b> Cancel mute"
517
+ m << "<b>/scan</b> Trigger all queued scans in ReQueue"
518
+ m << "<b>/clearthrottle</b> Clears your throttled messages"
519
+ end
520
+ if event.user_admin?
521
+ m << "<b>/status</b> Some status information"
522
+ m << "<b>/wm_shutdown</b> Shutdown watchmonkey process, may respawn if deamonized"
523
+ end
524
+ }.join("\n"), parse_mode: "HTML", disable_notification: true
525
+ throw :event_control, :done
526
+ when "/wm_shutdown"
527
+ if event.user_admin?
528
+ $wm_runtime_exiting = true
529
+ telegram_bot_msg_admins "ALERT: `#{event.from_descriptive}` invoked shutdown!"
530
+ throw :event_control, :done
531
+ else
532
+ throw :event_control, :access_denied
533
+ end
534
+ end
535
+ end
536
+
537
+ if ctrl == :access_denied
538
+ event.reply("You don't have sufficient permissions to execute this command!")
539
+ elsif ctrl == :bad_request
540
+ event.reply("Bad Request: Your input is invalid!")
541
+ end
542
+ rescue StandardError => ex
543
+ event.reply("Sorry, encountered an error while processing your request!")
544
+
545
+ # notify admins
546
+ msg = "ERROR: Encountered error while processing a request!\n"
547
+ msg << "Request: #{ERB::Util.h event.message}\n"
548
+ msg << "Origin: #{ERB::Util.h event.from.inspect}\n"
549
+ msg << "<pre>#{ERB::Util.h ex.class}: #{ERB::Util.h ex.message}\n#{ERB::Util.h ex.backtrace.join("\n")}</pre>"
550
+ telegram_bot_msg_admins(msg, parse_mode: "HTML")
551
+ end
552
+ end
553
+ end
554
+
555
+ def telegram_bot_msg_admins msg, msgopts = {}
556
+ return unless msg.present?
557
+ opts[:telegram_bot][:notify].each do |tid, level, topts|
558
+ next unless level.include?(:admin_flag)
559
+ next if telegram_bot_user_muted?(tid)
560
+ telegram_bot_send(msgopts.merge(text: msg, chat_id: tid))
561
+ end
562
+ end
563
+ end
564
+ end
565
+ end
566
+ end
@@ -1,7 +1,10 @@
1
1
  module WatchmonkeyCli
2
2
  class LoopbackConnection
3
+ attr_reader :opts, :established
4
+
3
5
  def initialize(id, opts = {}, &initializer)
4
6
  @id = id
7
+ @established = false
5
8
  @opts = {}.merge(opts)
6
9
  # @mutex = Monitor.new
7
10
  initializer.try(:call, @opts)
@@ -15,6 +18,10 @@ module WatchmonkeyCli
15
18
  "lo:#{@id}"
16
19
  end
17
20
 
21
+ def established?
22
+ @established
23
+ end
24
+
18
25
  def sync &block
19
26
  # @mutex.synchronize(&block)
20
27
  block.try(:call)
@@ -4,6 +4,7 @@ module WatchmonkeyCli
4
4
 
5
5
  def initialize(id, opts = {}, &initializer)
6
6
  @id = id
7
+ @established = false
7
8
 
8
9
  if opts.is_a?(String)
9
10
  u, h = opts.split("@", 2)
@@ -30,6 +31,10 @@ module WatchmonkeyCli
30
31
  "ssh:#{@id}"
31
32
  end
32
33
 
34
+ def established?
35
+ @established
36
+ end
37
+
33
38
  def sync &block
34
39
  @mutex.synchronize(&block)
35
40
  end
@@ -42,7 +47,12 @@ module WatchmonkeyCli
42
47
  end
43
48
 
44
49
  def connection
45
- sync { @ssh ||= Net::SSH.start(nil, nil, @opts) }
50
+ sync {
51
+ @ssh ||= begin
52
+ @established = true
53
+ Net::SSH.start(nil, nil, @opts)
54
+ end
55
+ }
46
56
  end
47
57
 
48
58
  def close!
@@ -1,4 +1,4 @@
1
1
  module WatchmonkeyCli
2
- VERSION = "1.10.0"
2
+ VERSION = "1.11.0"
3
3
  UPDATE_URL = "https://raw.githubusercontent.com/2called-chaos/watchmonkey_cli/master/VERSION"
4
4
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: watchmonkey_cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.10.0
4
+ version: 1.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sven Pachnit
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-01-21 00:00:00.000000000 Z
11
+ date: 2020-04-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -141,6 +141,7 @@ files:
141
141
  - lib/watchmonkey_cli/helper.rb
142
142
  - lib/watchmonkey_cli/hooks/platypus.rb
143
143
  - lib/watchmonkey_cli/hooks/requeue.rb
144
+ - lib/watchmonkey_cli/hooks/telegram_bot.rb
144
145
  - lib/watchmonkey_cli/loopback_connection.rb
145
146
  - lib/watchmonkey_cli/ssh_connection.rb
146
147
  - lib/watchmonkey_cli/version.rb