pechkin 1.2.2 → 1.5.1

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: c8edc5ca6908ec5bbe30dc46cac568f7461a7cd4a7b8f27edfec4bc9f03c406e
4
- data.tar.gz: 181cfaae800da3746168e124faeac2a794d8392c2291cff1ed44f1b2903ed35d
3
+ metadata.gz: c18e97f32b4fe42a3bd649c43563abc81a13fe49d5c2332f54129b1babc61767
4
+ data.tar.gz: bcc9dcdbccc1041ed9377b1e4bbce4113a6820ca798efceeefc9476b30eb8499
5
5
  SHA512:
6
- metadata.gz: a779e1584430ceb11261abfa41c4a3f5a01e688867c43b87824a046c73e1398b1ae12a9aaaa2a565d864241d23effebb429dace47d41d77493e5ebd6f907fbd2
7
- data.tar.gz: 1533b9c729bc2b6c3b2498066bbbc027c44e03a106d4260538fb58399fa42565278661e32a426ffb07449ae33dfac6d89b43f96086983f534d33e7ce90240a03
6
+ metadata.gz: 7d567c46d62df87a39e701f086c51a753616cb3e1acc104829675a1d091a9907110ed1e807f0253782b125a55017918c2ddedf3716ad58af77b0a96296c26a4e
7
+ data.tar.gz: b7add41faa2da882200c5a47fe4a847a97ea3df7a8c9cb69796e200405e7a3a19fd49cf0e21776484a46dc713ca8312bfce86ab2230c003c9325c4d86ddab526
@@ -0,0 +1,44 @@
1
+ module Pechkin
2
+ # Rack application to handle requests
3
+ class App
4
+ DEFAULT_CONTENT_TYPE = { 'Content-Type' => 'application/json' }.freeze
5
+ DEFAULT_HEADERS = {}.merge(DEFAULT_CONTENT_TYPE).freeze
6
+
7
+ attr_accessor :handler, :logger
8
+
9
+ def initialize(logger)
10
+ @logger = logger
11
+ end
12
+
13
+ def call(env)
14
+ req = Rack::Request.new(env)
15
+ result = RequestHandler.new(handler, req, logger).handle
16
+ response(200, result)
17
+ rescue AppError => e
18
+ proces_app_error(req, e)
19
+ rescue StandardError => e
20
+ process_unhandled_error(req, e)
21
+ end
22
+
23
+ private
24
+
25
+ def response(code, body)
26
+ [code.to_s, DEFAULT_HEADERS, [body.to_json]]
27
+ end
28
+
29
+ def proces_app_error(req, err)
30
+ data = { status: 'error', message: err.message }
31
+ req.body.rewind
32
+ body = req.body.read
33
+ logger.error "Can't process message: #{err.message}. Body: '#{body}'"
34
+ response(err.code, data)
35
+ end
36
+
37
+ def process_unhandled_error(req, err)
38
+ data = { status: 'error', message: err.message }
39
+ logger.error("#{err.message}\n\t" + err.backtrace.join("\n\t"))
40
+ logger.error(req.body.read)
41
+ response(503, data)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,39 @@
1
+ module Pechkin
2
+ # Application configurator and builder. This creates all needed middleware
3
+ # and stuff
4
+ class AppBuilder
5
+ def build(handler, options)
6
+ logger = create_logger(options.log_dir)
7
+ handler.logger = logger
8
+ app = App.new(logger)
9
+ app.handler = handler
10
+ prometheus = Pechkin::PrometheusUtils.registry
11
+
12
+ Rack::Builder.app do
13
+ use Rack::CommonLogger, logger
14
+ use Rack::Deflater
15
+ use Prometheus::Middleware::Collector, registry: prometheus
16
+ # Add Auth check if found htpasswd file or it was excplicitly provided
17
+ # See CLI class for configuration details
18
+ use Pechkin::Auth::Middleware, auth_file: options.htpasswd if options.htpasswd
19
+ use Prometheus::Middleware::Exporter, registry: prometheus
20
+
21
+ run app
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def create_logger(log_dir)
28
+ if log_dir
29
+ raise "Directory #{log_dir} does not exist" unless File.exist?(log_dir)
30
+
31
+ log_file = File.join(log_dir, 'pechkin.log')
32
+ file = File.open(log_file, File::WRONLY | File::APPEND)
33
+ Logger.new(file)
34
+ else
35
+ Logger.new($stdout)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,27 @@
1
+ module Pechkin
2
+ # Generic application error class.
3
+ #
4
+ # Allows us return meaningful error messages
5
+ class AppError < StandardError
6
+ attr_reader :code
7
+
8
+ def initialize(code, msg)
9
+ super(msg)
10
+ @code = code
11
+ end
12
+
13
+ class << self
14
+ def bad_request(message)
15
+ AppError.new(503, message)
16
+ end
17
+
18
+ def message_not_found
19
+ AppError.new(404, 'message not found')
20
+ end
21
+
22
+ def http_method_not_allowed
23
+ AppError.new(405, 'method not allowed')
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,49 @@
1
+ module Pechkin
2
+ # Http requests handler. We need fresh instance per each request. To keep
3
+ # internal state isolated
4
+ class RequestHandler
5
+ REQ_PATH_PATTERN = %r{^/(.+)/([^/]+)/?$}.freeze
6
+
7
+ attr_reader :req, :handler,
8
+ :channel_id, :message_id,
9
+ :logger
10
+
11
+ def initialize(handler, req, logger)
12
+ @handler = handler
13
+ @req = req
14
+ @logger = logger
15
+
16
+ @channel_id, @message_id = req.path_info.match(REQ_PATH_PATTERN) do |m|
17
+ [m[1], m[2]]
18
+ end
19
+ end
20
+
21
+ def handle
22
+ raise AppError.http_method_not_allowed unless post?
23
+ raise AppError.message_not_found unless message?
24
+
25
+ data = parse_data(req.body.read)
26
+ handler.handle(channel_id, message_id, data).each do |i|
27
+ logger.info "Sent #{channel_id}/#{message_id}: #{i.to_json}"
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def parse_data(data)
34
+ JSON.parse(data)
35
+ rescue JSON::JSONError => e
36
+ raise AppError.bad_request(e.message)
37
+ end
38
+
39
+ def message?
40
+ return false unless @channel_id && @message_id
41
+
42
+ handler.message?(@channel_id, @message_id)
43
+ end
44
+
45
+ def post?
46
+ req.post?
47
+ end
48
+ end
49
+ end
data/lib/pechkin/app.rb CHANGED
@@ -1,114 +1,4 @@
1
- module Pechkin
2
- # Application configurator and builder. This creates all needed middleware
3
- # and stuff
4
- class AppBuilder
5
- def build(handler, options)
6
- app = App.new
7
- app.handler = handler
8
- prometheus = Pechkin::PrometheusUtils.registry
9
- logger = create_logger(options.log_dir)
10
-
11
- Rack::Builder.app do
12
- use Rack::CommonLogger, logger
13
- use Rack::Deflater
14
- use Prometheus::Middleware::Collector, registry: prometheus
15
- # Add Auth check if found htpasswd file or it was excplicitly provided
16
- # See CLI class for configuration details
17
- if options.htpasswd
18
- use Pechkin::Auth::Middleware, auth_file: options.htpasswd
19
- end
20
- use Prometheus::Middleware::Exporter, registry: prometheus
21
-
22
- run app
23
- end
24
- end
25
-
26
- private
27
-
28
- def create_logger(log_dir)
29
- if log_dir
30
- raise "Directory #{log_dir} does not exist" unless File.exist?(log_dir)
31
-
32
- log_file = File.join(log_dir, 'pechkin.log')
33
- file = File.open(log_file, File::WRONLY | File::APPEND)
34
- Logger.new(file)
35
- else
36
- Logger.new(STDOUT)
37
- end
38
- end
39
- end
40
-
41
- # Rack application to handle requests
42
- class App
43
- attr_accessor :handler
44
-
45
- def call(env)
46
- RequestHandler.new(handler, env).handle
47
- rescue StandardError => e
48
- body = { status: 'error', reason: e.message }.to_json
49
- ['503', { 'Content-Type' => 'application/json' }, [body]]
50
- end
51
- end
52
-
53
- # Http requests handler. We need fresh instance per each request. To keep
54
- # internal state isolated
55
- class RequestHandler
56
- REQ_PATH_PATTERN = %r{^/(.+)/([^/]+)/?$}
57
- DEFAULT_CONTENT_TYPE = { 'Content-Type' => 'application/json' }.freeze
58
- DEFAULT_HEADERS = {}.merge(DEFAULT_CONTENT_TYPE).freeze
59
-
60
- attr_reader :req, :env, :handler,
61
- :channel_id, :message_id
62
-
63
- def initialize(handler, env)
64
- @handler = handler
65
- @env = env
66
- @req = Rack::Request.new(env)
67
-
68
- @channel_id, @message_id = req.path_info.match(REQ_PATH_PATTERN) do |m|
69
- [m[1], m[2]]
70
- end
71
- end
72
-
73
- def handle
74
- return not_allowed unless post?
75
- return not_found unless message?
76
-
77
- begin
78
- data = JSON.parse(req.body.read)
79
- rescue JSON::JSONError => e
80
- return bad_request(e.message)
81
- end
82
-
83
- response(200, handler.handle(channel_id, message_id, data).to_json)
84
- end
85
-
86
- private
87
-
88
- def message?
89
- return false unless @channel_id && @message_id
90
-
91
- handler.message?(@channel_id, @message_id)
92
- end
93
-
94
- def not_allowed
95
- response(405, '{"status":"error", "reason":"method not allowed"}')
96
- end
97
-
98
- def not_found
99
- response(404, '{"status":"error", "reason":"message not found"}')
100
- end
101
-
102
- def bad_request(body)
103
- response(503, body)
104
- end
105
-
106
- def response(code, body)
107
- [code.to_s, DEFAULT_HEADERS, [body]]
108
- end
109
-
110
- def post?
111
- req.post?
112
- end
113
- end
114
- end
1
+ require_relative 'app/app_builder'
2
+ require_relative 'app/app_error'
3
+ require_relative 'app/app'
4
+ require_relative 'app/request_handler'
data/lib/pechkin/auth.rb CHANGED
@@ -5,6 +5,7 @@ module Pechkin
5
5
  # Utility class for altering htpasswd files
6
6
  class Manager
7
7
  attr_reader :htpasswd
8
+
8
9
  def initialize(htpasswd)
9
10
  @htpasswd = htpasswd
10
11
  end
@@ -1,4 +1,3 @@
1
1
  module Pechkin
2
2
  Bot = Struct.new(:token, :connector, :name, keyword_init: true)
3
- Channel = Struct.new(:chat_ids, :connector, :messages, keyword_init: true)
4
3
  end
@@ -1,59 +1,3 @@
1
1
  module Pechkin
2
- # Creates object which can send messages to assigned chanels
3
- class Chanel
4
- attr_accessor :logger
5
-
6
- def initialize(connector, channel_list, logger = ::Logger.new(STDOUT))
7
- @connector = connector
8
- @channel_list = channel_list
9
- @channel_list = [channel_list] unless channel_list.is_a?(Array)
10
- @logger = logger
11
- end
12
-
13
- def send_message(message, data, message_desc)
14
- text = message.nil? ? '' : Message.new(data).render(message)
15
-
16
- message_desc = substitute(data, message_desc)
17
-
18
- logger.warn 'Resulting text is empty' if text.empty?
19
- results = @channel_list.map do |id|
20
- @connector.send_message(id, text, message_desc)
21
- end
22
-
23
- process_results(message, results)
24
- end
25
-
26
- private
27
-
28
- def substitute(data, message_desc)
29
- substitute_recursive(Substitute.new(data), message_desc)
30
- end
31
-
32
- def substitute_recursive(substitutions, object)
33
- case object
34
- when String
35
- substitutions.process(object)
36
- when Array
37
- object.map { |o| substitute_recursive(substitutions, o) }
38
- when Hash
39
- r = {}
40
- object.each { |k, v| r[k] = substitute_recursive(substitutions, v) }
41
- r
42
- else
43
- object
44
- end
45
- end
46
-
47
- def process_results(message, results)
48
- success, error = results.partition { |_chat, code, _body| code < 400 }
49
- error.each do |chat, code, body|
50
- logger.error "#{message} => #{chat}[HTTP #{code}]: #{body}"
51
- end
52
-
53
- {
54
- successful: success.map(&:first),
55
- errors: error
56
- }
57
- end
58
- end
2
+ Channel = Struct.new(:chat_ids, :connector, :messages, keyword_init: true)
59
3
  end
data/lib/pechkin/cli.rb CHANGED
@@ -13,7 +13,7 @@ module Pechkin
13
13
  # @opt names [Array<String>] list of command line keys
14
14
  # @opt desc [String] option description
15
15
  # @opt type [Class] argument type to parse from command line, e.g. Integer
16
- def opt(name, default: nil, names:, desc: '', type: nil)
16
+ def opt(name, names:, default: nil, desc: '', type: nil)
17
17
  @cli_options ||= []
18
18
 
19
19
  # raise ':names is nil or empty' if names.nil? || names.empty?
@@ -9,7 +9,7 @@ module Pechkin
9
9
  # command behaviour
10
10
  # @opt stdout [IO] IO object which represents STDOUT
11
11
  # @opt stderr [IO] IO object which represents STDERR
12
- def initialize(options, stdout: STDOUT, stderr: STDERR)
12
+ def initialize(options, stdout: $stdout, stderr: $stderr)
13
13
  @options = options
14
14
  @stdout = stdout
15
15
  @stderr = stderr
@@ -29,6 +29,7 @@ module Pechkin
29
29
  d = if data.start_with?('@')
30
30
  file = data[1..-1]
31
31
  raise "File not found #{file}" unless File.exist?(file)
32
+
32
33
  IO.read(file)
33
34
  else
34
35
  data
@@ -13,11 +13,11 @@ module Pechkin
13
13
  def create_connector(bot)
14
14
  case bot.connector
15
15
  when 'tg', 'telegram'
16
- TelegramConnector.new(bot.token, bot.name)
16
+ Connector::Telegram.new(bot.token, bot.name)
17
17
  when 'slack'
18
- SlackConnector.new(bot.token, bot.name)
18
+ Connector::Slack.new(bot.token, bot.name)
19
19
  else
20
- raise 'Unknown connector ' + bot.connector + ' for ' + bot.name
20
+ raise "Unknown connector #{bot.connector} for #{bot.name}"
21
21
  end
22
22
  end
23
23
 
@@ -15,9 +15,7 @@ module Pechkin
15
15
  def load_bots_configuration(working_dir, bots)
16
16
  bots_dir = File.join(working_dir, 'bots')
17
17
 
18
- unless File.directory?(bots_dir)
19
- raise ConfigurationError, "'#{bots_dir}' is not a directory"
20
- end
18
+ raise ConfigurationError, "'#{bots_dir}' is not a directory" unless File.directory?(bots_dir)
21
19
 
22
20
  Dir["#{bots_dir}/*.yml"].each do |bot_file|
23
21
  name = File.basename(bot_file, '.yml')
@@ -22,9 +22,7 @@ module Pechkin
22
22
  def load_channels_configuration(working_dir, channels)
23
23
  channels_dir = File.join(working_dir, 'channels')
24
24
 
25
- unless File.directory?(channels_dir)
26
- raise ConfigurationError, "'#{channels_dir}' is not a directory"
27
- end
25
+ raise ConfigurationError, "'#{channels_dir}' is not a directory" unless File.directory?(channels_dir)
28
26
 
29
27
  Dir["#{channels_dir}/*"].each do |channel_dir|
30
28
  next unless File.directory?(channel_dir)
@@ -63,16 +61,53 @@ module Pechkin
63
61
  message_config = YAML.safe_load(IO.read(file))
64
62
  name = File.basename(file, '.yml')
65
63
 
64
+ # Dirty workaround. I need to recursively load templates. When doing it
65
+ # we looking for {'template': '...path to template..' } objects. But we
66
+ # don't want to force user write something like:
67
+ # text:
68
+ # template: '... path to main template...'
69
+ # because it's too mouthful for such common case.
70
+ #
71
+ # So now we pull main template out, then load everyting else. Then put
72
+ # it back.
73
+ template = nil
66
74
  if message_config.key?('template')
67
- message_config['template'] = get_template(message_config['template'])
75
+ template = get_template(message_config['template'])
76
+ message_config.delete('template')
68
77
  end
69
78
 
70
- messages[name] = message_config
79
+ message_config = load_templates(message_config)
80
+ message_config['template'] = template unless template.nil?
81
+
82
+ messages[name] = Message.new(message_config)
71
83
  end
72
84
 
73
85
  messages
74
86
  end
75
87
 
88
+ def load_templates(object)
89
+ case object
90
+ when String
91
+ object
92
+ when Array
93
+ object.map { |o| load_templates(o) }
94
+ when Hash
95
+ if object.key?('template')
96
+ msg = 'When using template only 1 KV pair allowed'
97
+ raise ConfigurationError, msg unless object.size == 1
98
+
99
+ # Replace whole object with created template.
100
+ get_template(object['template'])
101
+ else
102
+ r = {}
103
+ object.each { |k, v| r[k] = load_templates(v) }
104
+ r
105
+ end
106
+ else
107
+ object
108
+ end
109
+ end
110
+
76
111
  def get_template(path)
77
112
  msg = "Can't find template: #{path}"
78
113
  raise ConfigurationError, msg unless @views.key?(path)
@@ -15,9 +15,7 @@ module Pechkin
15
15
  def load_views_configuration(working_dir, views)
16
16
  views_dir = File.join(working_dir, 'views')
17
17
 
18
- unless File.directory?(views_dir)
19
- raise ConfigurationError, "'#{views_dir}' is not a directory"
20
- end
18
+ raise ConfigurationError, "'#{views_dir}' is not a directory" unless File.directory?(views_dir)
21
19
 
22
20
  Dir["#{views_dir}/**/*.erb"].each do |f|
23
21
  relative_path = f["#{views_dir}/".length..-1]
@@ -1,6 +1,5 @@
1
1
  require 'yaml'
2
2
 
3
- require_relative 'configuration/model'
4
3
  require_relative 'configuration/configuration_loader'
5
4
  require_relative 'configuration/configuration_loader_bots'
6
5
  require_relative 'configuration/configuration_loader_channels'
@@ -10,16 +9,16 @@ module Pechkin
10
9
  # Pechkin reads its configuration from provided directory structure. Basic
11
10
  # layout expected to be as follows:
12
11
  # .
13
- # | - bots/ <= Bots configuration
14
- # | | - marvin.yml <= Each bot described by yaml file
12
+ # | - bots/ <- Bots configuration
13
+ # | | - marvin.yml <- Each bot described by yaml file
15
14
  # | | - bender.yml
16
15
  # |
17
- # | - channels/ <= Channels description
16
+ # | - channels/ <- Channels description
18
17
  # | | - slack-repository-feed
19
18
  # | | - commit-hg.yml
20
19
  # | | - commit-svn.yml
21
20
  # |
22
- # | - views/ <= Template storage
21
+ # | - views/ <- Template storage
23
22
  # | - commit-hg.erb
24
23
  # | - commit-svn.erb
25
24
  #
@@ -0,0 +1,29 @@
1
+ module Pechkin
2
+ module Connector
3
+ # Base connector
4
+ class Base
5
+ DEFAULT_HEADERS = {
6
+ 'Content-Type' => 'application/json; charset=UTF-8'
7
+ }.freeze
8
+
9
+ def send_message(chat, message, message_desc); end
10
+
11
+ def preview(chats, message, _message_desc)
12
+ "Connector: #{self.class.name}; Chats: #{chats.join(', ')}\n" \
13
+ "Message:\n#{message}"
14
+ end
15
+
16
+ def post_data(url, data, headers: {})
17
+ uri = URI.parse(url)
18
+ headers = DEFAULT_HEADERS.merge(headers)
19
+ http = Net::HTTP.new(uri.host, uri.port)
20
+ http.use_ssl = url.start_with?('https://')
21
+
22
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
23
+ request.body = data.to_json
24
+
25
+ http.request(request)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,32 @@
1
+ module Pechkin
2
+ module Connector
3
+ class Slack < Connector::Base # :nodoc:
4
+ attr_reader :name
5
+
6
+ def initialize(bot_token, name)
7
+ super()
8
+
9
+ @headers = { 'Authorization' => "Bearer #{bot_token}" }
10
+ @name = name
11
+ end
12
+
13
+ def send_message(channel, message, message_desc)
14
+ text = CGI.unescape_html(message)
15
+
16
+ attachments = message_desc['slack_attachments'] || {}
17
+
18
+ if text.strip.empty? && attachments.empty?
19
+ return { channel: channel, code: 400,
20
+ response: 'Internal error: message is empty' }
21
+ end
22
+
23
+ params = { channel: channel, text: text, attachments: attachments }
24
+
25
+ url = 'https://slack.com/api/chat.postMessage'
26
+ response = post_data(url, params, headers: @headers)
27
+
28
+ { channel: channel, code: response.code.to_i, response: response.body }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,28 @@
1
+ module Pechkin
2
+ module Connector
3
+ class Telegram < Connector::Base # :nodoc:
4
+ attr_reader :name
5
+
6
+ def initialize(bot_token, name)
7
+ super()
8
+
9
+ @bot_token = bot_token
10
+ @name = name
11
+ end
12
+
13
+ def send_message(chat_id, message, message_desc)
14
+ options = { parse_mode: message_desc['telegram_parse_mode'] || 'HTML' }
15
+ params = options.update(chat_id: chat_id, text: message)
16
+
17
+ response = post_data(method_url('sendMessage'), params)
18
+ { chat_id: chat_id, code: response.code.to_i, response: response.body }
19
+ end
20
+
21
+ private
22
+
23
+ def method_url(method)
24
+ "https://api.telegram.org/bot#{@bot_token}/#{method}"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -4,26 +4,6 @@ require 'uri'
4
4
  require 'json'
5
5
  require 'cgi'
6
6
 
7
- module Pechkin
8
- # Base connector
9
- class Connector
10
- def send_message(chat, message, message_desc); end
11
-
12
- def preview(chats, message, _message_desc)
13
- "Connector: #{self.class.name}; Chats: #{chats.join(', ')}\n" \
14
- "Message:\n#{message}"
15
- end
16
-
17
- def post_data(url, data, headers: {})
18
- uri = URI.parse(url)
19
- headers = { 'Content-Type' => 'application/json' }.merge(headers)
20
- http = Net::HTTP.new(uri.host, uri.port)
21
- http.use_ssl = url.start_with?('https://')
22
-
23
- request = Net::HTTP::Post.new(uri.request_uri, headers)
24
- request.body = data.to_json
25
-
26
- http.request(request)
27
- end
28
- end
29
- end
7
+ require_relative 'connector/base'
8
+ require_relative 'connector/slack'
9
+ require_relative 'connector/telegram'
@@ -4,7 +4,10 @@ module Pechkin
4
4
  super("No such channel #{channel_name}")
5
5
  end
6
6
  end
7
+
7
8
  class MessageNotFoundError < StandardError; end
9
+
8
10
  class MessageContentIsEmptyError < StandardError; end
11
+
9
12
  class ConfigurationError < StandardError; end
10
13
  end
@@ -2,10 +2,16 @@ module Pechkin
2
2
  # Processes feeded data chunks and sends them via connectors to needed IM
3
3
  # services. Can skip some requests acording to filters.
4
4
  class Handler
5
- attr_reader :channels
5
+ attr_reader :channels, :message_matcher
6
+ attr_accessor :logger
6
7
 
7
- def initialize(channels)
8
+ def initialize(channels, stdout = $stdout, stderr = $stderr)
8
9
  @channels = channels
10
+ # Create empty logger by default
11
+ @logger = Logger.new(IO::NULL)
12
+ @stdout = stdout
13
+ @stderr = stderr
14
+ @message_matcher = MessageMatcher.new(@logger)
9
15
  end
10
16
 
11
17
  # Handles message request. Each request has three parameters: channel id,
@@ -21,20 +27,18 @@ module Pechkin
21
27
  # deserialized json.
22
28
  # @see Configuration
23
29
  def handle(channel_id, msg_id, data)
24
- channel_config = fetch_channel(channel_id)
25
- # Find message and try substitute values to message parameters.
26
- message_config = substitute(data, fetch_message(channel_config, msg_id))
27
-
28
- data = (message_config['variables'] || {}).merge(data)
29
- template = message_config['template']
30
-
31
- text = ''
32
- text = template.render(data) unless template.nil?
33
-
30
+ channel_config, message_config, text =
31
+ prepare_message(channel_id, msg_id, data)
34
32
  chats = channel_config.chat_ids
35
33
  connector = channel_config.connector
36
34
 
37
- chats.map { |chat| connector.send_message(chat, text, message_config) }
35
+ if message_allowed?(message_config, data)
36
+ chats.map { |chat| connector.send_message(chat, text, message_config) }
37
+ else
38
+ logger.warn "#{channel_id}/#{msg_id}: " \
39
+ "Skip sending message. Because it's not allowed"
40
+ []
41
+ end
38
42
  end
39
43
 
40
44
  # Executes message handling and renders template using connector logic
@@ -47,20 +51,16 @@ module Pechkin
47
51
  # deserialized json.
48
52
  # @see Configuration
49
53
  def preview(channel_id, msg_id, data)
50
- channel_config = fetch_channel(channel_id)
51
- # Find message and try substitute values to message parameters.
52
- message_config = substitute(data, fetch_message(channel_config, msg_id))
53
-
54
- data = (message_config['variables'] || {}).merge(data)
55
- template = message_config['template']
56
-
57
- text = ''
58
- text = template.render(data) unless template.nil?
59
-
54
+ channel_config, message_config, text =
55
+ prepare_message(channel_id, msg_id, data)
60
56
  chats = channel_config.chat_ids
61
57
  connector = channel_config.connector
62
58
 
63
- connector.preview(chats, text, message_config)
59
+ if message_allowed?(message_config, data)
60
+ connector.preview(chats, text, message_config)
61
+ else
62
+ puts "No message sent beacuse it's not allowed"
63
+ end
64
64
  end
65
65
 
66
66
  def message?(channel_id, msg_id)
@@ -69,6 +69,10 @@ module Pechkin
69
69
 
70
70
  private
71
71
 
72
+ def puts(msg)
73
+ @stdout.puts(msg)
74
+ end
75
+
72
76
  # Find channel by it's id or trow ChannelNotFoundError
73
77
  def fetch_channel(channel_id)
74
78
  raise ChannelNotFoundError, channel_id unless channels.key?(channel_id)
@@ -84,6 +88,19 @@ module Pechkin
84
88
  message_list[msg_id]
85
89
  end
86
90
 
91
+ def message_allowed?(message_config, data)
92
+ message_matcher.matches?(message_config, data)
93
+ end
94
+
95
+ def prepare_message(channel_id, msg_id, data)
96
+ channel_config = fetch_channel(channel_id)
97
+ # Find message and try substitute values to message parameters.
98
+ message = fetch_message(channel_config, msg_id)
99
+ message_config, text = message.prepare(data)
100
+
101
+ [channel_config, message_config, text]
102
+ end
103
+
87
104
  def substitute(data, message_desc)
88
105
  substitute_recursive(Substitute.new(data), message_desc)
89
106
  end
@@ -0,0 +1,64 @@
1
+ module Pechkin
2
+ # Message object
3
+ #
4
+ # TBD
5
+ class Message
6
+ def initialize(message)
7
+ @message = message
8
+ end
9
+
10
+ def prepare(data)
11
+ data = (@message['variables'] || {}).merge(data)
12
+ # Find message and try substitute values to message parameters.
13
+ message_config = render(data, substitute(data, @message))
14
+ text = ''
15
+ text = message_config.delete('template') if message_config.key?('template')
16
+
17
+ [message_config, text]
18
+ end
19
+
20
+ def to_h
21
+ Marshal.load(Marshal.dump(@message))
22
+ end
23
+
24
+ private
25
+
26
+ def substitute(data, message_desc)
27
+ substitute_recursive(Substitute.new(data), message_desc)
28
+ end
29
+
30
+ def substitute_recursive(substitutions, object)
31
+ case object
32
+ when String
33
+ substitutions.process(object)
34
+ when Array
35
+ object.map { |o| substitute_recursive(substitutions, o) }
36
+ when Hash
37
+ r = {}
38
+ object.each { |k, v| r[k] = substitute_recursive(substitutions, v) }
39
+ r
40
+ else
41
+ object
42
+ end
43
+ end
44
+
45
+ def render(data, message_desc)
46
+ render_recursive(data, message_desc)
47
+ end
48
+
49
+ def render_recursive(data, object)
50
+ case object
51
+ when MessageTemplate
52
+ object.render(data)
53
+ when Array
54
+ object.map { |o| render_recursive(data, o) }
55
+ when Hash
56
+ r = {}
57
+ object.each { |k, v| r[k] = render_recursive(data, v) }
58
+ r
59
+ else
60
+ object
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,94 @@
1
+ module Pechkin
2
+ class MessageMatchError < StandardError; end
3
+
4
+ # Allows to match message configuration against received data.
5
+ #
6
+ # Data is checked againts either allow or forbid rules. But not both at the
7
+ # same time. Each field can contain list of rules to check. 'allow' list means
8
+ # we need at least one matching rule to allow data processing. And 'forbid'
9
+ # list respectievly means we need at least one matching rule to decline data
10
+ # processing.
11
+ class MessageMatcher
12
+ KEY_ALLOW = 'allow'.freeze
13
+ KEY_FORBID = 'forbid'.freeze
14
+
15
+ attr_reader :logger
16
+
17
+ def initialize(logger)
18
+ @logger = logger
19
+ end
20
+
21
+ # Checks data object against allow / forbid rule sets in message
22
+ # configuration. If data object matches rules it means we can process this
23
+ # data and send message.
24
+ #
25
+ # @param message_config [Hash] message description.
26
+ # @param data [Hash] request object that need to be inspected whether we
27
+ # should process this data or not
28
+ # @return [Boolean] is data object matches message_config rules or not
29
+ def matches?(message_config, data)
30
+ check(message_config)
31
+
32
+ if message_config.key?(KEY_ALLOW)
33
+ rules = message_config[KEY_ALLOW]
34
+ rules.any? { |r| check_rule(r, r, data) }
35
+ elsif message_config.key?(KEY_FORBID)
36
+ rules = message_config[KEY_FORBID]
37
+ rules.all? { |r| !check_rule(r, r, data) }
38
+ else
39
+ true
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def check(msg)
46
+ return unless msg.key?(KEY_ALLOW) && msg.key?(KEY_FORBID)
47
+
48
+ raise MessageMatchError, 'Both allow and forbid present in message config'
49
+ end
50
+
51
+ # Check rule object against data. Rules are checked recursievly, i.e. we
52
+ # can take one field in rule and check it separately as new rule. If all
53
+ # fields are passed check then whole rule passed.
54
+ def check_rule(top_rule, sub_rule, data)
55
+ result = case sub_rule
56
+ when Hash
57
+ check_hash_rule(top_rule, sub_rule, data)
58
+ when Array
59
+ check_array_rule(top_rule, sub_rule, data)
60
+ when String
61
+ check_string_rule(top_rule, sub_rule, data)
62
+ else
63
+ sub_rule.eql?(data)
64
+ end
65
+
66
+ unless result
67
+ logger.info "Expected #{sub_rule.to_json} got #{data.to_json} when" \
68
+ " checking #{top_rule.to_json}"
69
+ end
70
+
71
+ result
72
+ end
73
+
74
+ def check_hash_rule(top_rule, hash, data)
75
+ return false unless data.is_a?(Hash)
76
+
77
+ hash.all? do |key, value|
78
+ data.key?(key) && check_rule(top_rule, value, data[key])
79
+ end
80
+ end
81
+
82
+ def check_array_rule(top_rule, array, data)
83
+ return false unless data.is_a?(Array)
84
+
85
+ # Deep array check needs to be done against all elements so we zip arrays
86
+ # to pair each rule with data element
87
+ array.zip(data).all? { |r, d| check_rule(top_rule, r, d) }
88
+ end
89
+
90
+ def check_string_rule(_top_rule, str, data)
91
+ str.eql? data
92
+ end
93
+ end
94
+ end
@@ -1,7 +1,7 @@
1
1
  module Pechkin
2
2
  # Keeps actual version
3
3
  module Version
4
- VERSION = [1, 2, 2].freeze
4
+ VERSION = [1, 5, 1].freeze
5
5
  class << self
6
6
  def version_string
7
7
  VERSION.join('.')
data/lib/pechkin.rb CHANGED
@@ -8,14 +8,15 @@ require 'htauth'
8
8
  require 'base64'
9
9
 
10
10
  require_relative 'pechkin/cli'
11
+ require_relative 'pechkin/bot'
12
+ require_relative 'pechkin/channel'
13
+ require_relative 'pechkin/message'
11
14
  require_relative 'pechkin/command'
12
15
  require_relative 'pechkin/exceptions'
13
16
  require_relative 'pechkin/handler'
17
+ require_relative 'pechkin/message_matcher'
14
18
  require_relative 'pechkin/message_template'
15
19
  require_relative 'pechkin/connector'
16
- require_relative 'pechkin/connector_slack'
17
- require_relative 'pechkin/connector_telegram'
18
- require_relative 'pechkin/channel'
19
20
  require_relative 'pechkin/configuration'
20
21
  require_relative 'pechkin/substitute'
21
22
  require_relative 'pechkin/prometheus_utils'
@@ -29,8 +30,8 @@ module Pechkin # :nodoc:
29
30
  cmd = Command::Dispatcher.new(options).dispatch
30
31
  cmd.execute
31
32
  rescue StandardError => e
32
- warn 'Error: ' + e.message
33
- warn "\t" + e.backtrace.reverse.join("\n\t") if options.debug?
33
+ warn "Error: #{e.message}"
34
+ warn "\t#{e.backtrace.reverse.join("\n\t")}" if options.debug?
34
35
  exit 2
35
36
  end
36
37
  end
metadata CHANGED
@@ -1,43 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pechkin
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.2
4
+ version: 1.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ilya Arkhanhelsky
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-02-04 00:00:00.000000000 Z
11
+ date: 2021-10-26 00:00:00.000000000 Z
12
12
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: grape
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - '='
18
- - !ruby/object:Gem::Version
19
- version: 1.1.0
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - '='
25
- - !ruby/object:Gem::Version
26
- version: 1.1.0
27
13
  - !ruby/object:Gem::Dependency
28
14
  name: htauth
29
15
  requirement: !ruby/object:Gem::Requirement
30
16
  requirements:
31
17
  - - '='
32
18
  - !ruby/object:Gem::Version
33
- version: 2.0.0
19
+ version: 2.1.1
34
20
  type: :runtime
35
21
  prerelease: false
36
22
  version_requirements: !ruby/object:Gem::Requirement
37
23
  requirements:
38
24
  - - '='
39
25
  - !ruby/object:Gem::Version
40
- version: 2.0.0
26
+ version: 2.1.1
41
27
  - !ruby/object:Gem::Dependency
42
28
  name: powerpack
43
29
  requirement: !ruby/object:Gem::Requirement
@@ -72,14 +58,14 @@ dependencies:
72
58
  requirements:
73
59
  - - '='
74
60
  - !ruby/object:Gem::Version
75
- version: 2.0.8
61
+ version: 2.2.3
76
62
  type: :runtime
77
63
  prerelease: false
78
64
  version_requirements: !ruby/object:Gem::Requirement
79
65
  requirements:
80
66
  - - '='
81
67
  - !ruby/object:Gem::Version
82
- version: 2.0.8
68
+ version: 2.2.3
83
69
  description:
84
70
  email: ilya.arkhanhelsky at gmail.com
85
71
  executables:
@@ -90,7 +76,12 @@ files:
90
76
  - bin/pechkin
91
77
  - lib/pechkin.rb
92
78
  - lib/pechkin/app.rb
79
+ - lib/pechkin/app/app.rb
80
+ - lib/pechkin/app/app_builder.rb
81
+ - lib/pechkin/app/app_error.rb
82
+ - lib/pechkin/app/request_handler.rb
93
83
  - lib/pechkin/auth.rb
84
+ - lib/pechkin/bot.rb
94
85
  - lib/pechkin/channel.rb
95
86
  - lib/pechkin/cli.rb
96
87
  - lib/pechkin/command.rb
@@ -105,12 +96,14 @@ files:
105
96
  - lib/pechkin/configuration/configuration_loader_bots.rb
106
97
  - lib/pechkin/configuration/configuration_loader_channels.rb
107
98
  - lib/pechkin/configuration/configuration_loader_views.rb
108
- - lib/pechkin/configuration/model.rb
109
99
  - lib/pechkin/connector.rb
110
- - lib/pechkin/connector_slack.rb
111
- - lib/pechkin/connector_telegram.rb
100
+ - lib/pechkin/connector/base.rb
101
+ - lib/pechkin/connector/slack.rb
102
+ - lib/pechkin/connector/telegram.rb
112
103
  - lib/pechkin/exceptions.rb
113
104
  - lib/pechkin/handler.rb
105
+ - lib/pechkin/message.rb
106
+ - lib/pechkin/message_matcher.rb
114
107
  - lib/pechkin/message_template.rb
115
108
  - lib/pechkin/prometheus_utils.rb
116
109
  - lib/pechkin/substitute.rb
@@ -125,16 +118,16 @@ require_paths:
125
118
  - lib
126
119
  required_ruby_version: !ruby/object:Gem::Requirement
127
120
  requirements:
128
- - - ">="
121
+ - - ">"
129
122
  - !ruby/object:Gem::Version
130
- version: '0'
123
+ version: '2.5'
131
124
  required_rubygems_version: !ruby/object:Gem::Requirement
132
125
  requirements:
133
126
  - - ">="
134
127
  - !ruby/object:Gem::Version
135
128
  version: '0'
136
129
  requirements: []
137
- rubygems_version: 3.0.3
130
+ rubygems_version: 3.2.22
138
131
  signing_key:
139
132
  specification_version: 4
140
133
  summary: Web service to proxy webhooks to Telegram Bot API
@@ -1,27 +0,0 @@
1
- module Pechkin # :nodoc:
2
- class SlackConnector < Connector # :nodoc:
3
- attr_reader :name
4
-
5
- def initialize(bot_token, name)
6
- @headers = { 'Authorization' => "Bearer #{bot_token}" }
7
- @name = name
8
- end
9
-
10
- def send_message(channel, message, message_desc)
11
- text = CGI.unescape_html(message)
12
-
13
- attachments = message_desc['slack_attachments'] || {}
14
-
15
- if text.strip.empty? && attachments.empty?
16
- return [channel, 400, 'Internal error: message is empty']
17
- end
18
-
19
- params = { channel: channel, text: text, attachments: attachments }
20
-
21
- url = 'https://slack.com/api/chat.postMessage'
22
- response = post_data(url, params, headers: @headers)
23
-
24
- [channel, response.code.to_i, response.body]
25
- end
26
- end
27
- end
@@ -1,24 +0,0 @@
1
- module Pechkin
2
- class TelegramConnector < Connector #:nodoc:
3
- attr_reader :name
4
-
5
- def initialize(bot_token, name)
6
- @bot_token = bot_token
7
- @name = name
8
- end
9
-
10
- def send_message(chat_id, message, message_desc)
11
- options = { parse_mode: message_desc['telegram_parse_mode'] || 'HTML' }
12
- params = options.update(chat_id: chat_id, text: message)
13
-
14
- response = post_data(method_url('sendMessage'), params)
15
- [chat_id, response.code.to_i, response.body]
16
- end
17
-
18
- private
19
-
20
- def method_url(method)
21
- "https://api.telegram.org/bot#{@bot_token}/#{method}"
22
- end
23
- end
24
- end