pechkin 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/pechkin/app/app.rb +44 -0
- data/lib/pechkin/app/app_builder.rb +39 -0
- data/lib/pechkin/app/app_error.rb +27 -0
- data/lib/pechkin/app/request_handler.rb +49 -0
- data/lib/pechkin/app.rb +4 -114
- data/lib/pechkin/auth.rb +1 -0
- data/lib/pechkin/channel.rb +1 -1
- data/lib/pechkin/cli.rb +1 -1
- data/lib/pechkin/command/base.rb +1 -1
- data/lib/pechkin/command/run_server.rb +1 -1
- data/lib/pechkin/command/send_data.rb +1 -0
- data/lib/pechkin/configuration/configuration_loader.rb +3 -3
- data/lib/pechkin/configuration/configuration_loader_bots.rb +1 -3
- data/lib/pechkin/configuration/configuration_loader_channels.rb +2 -6
- data/lib/pechkin/configuration/configuration_loader_views.rb +1 -3
- data/lib/pechkin/configuration.rb +4 -4
- data/lib/pechkin/connector/base.rb +29 -0
- data/lib/pechkin/connector/slack.rb +32 -0
- data/lib/pechkin/connector/telegram.rb +28 -0
- data/lib/pechkin/connector.rb +3 -23
- data/lib/pechkin/exceptions.rb +3 -0
- data/lib/pechkin/handler.rb +46 -24
- data/lib/pechkin/message_matcher.rb +94 -0
- data/lib/pechkin/version.rb +1 -1
- data/lib/pechkin.rb +3 -4
- metadata +17 -25
- data/lib/pechkin/connector_slack.rb +0 -27
- data/lib/pechkin/connector_telegram.rb +0 -24
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3fa8abd425ad6a776104aabeeb6916d4b8b4d77ae0f0d7e24b75f102bb70b016
|
4
|
+
data.tar.gz: 1b5b43910b029f8553f54ddfa5d70a7195b2db86d35a38ba1fcffc37521b7d4b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d636b920cb207c03ce1f96ceeb47ec478214743e0ef4f49895c07982fcf68d2b724d4006058ef00de6903c986c6f40e8678ebbb7ccdaa681d6b552cfb70111a1
|
7
|
+
data.tar.gz: 5254cb1f323cb46af16b889d82ffd565a9a0f30e5665cc6c5487e59926f25e66b8a573e004ffcf4c2a34b8f17187b0e67443ef5bf5ffec3b8e2a68e079557def
|
@@ -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
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
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
data/lib/pechkin/channel.rb
CHANGED
@@ -3,7 +3,7 @@ module Pechkin
|
|
3
3
|
class Chanel
|
4
4
|
attr_accessor :logger
|
5
5
|
|
6
|
-
def initialize(connector, channel_list, logger = ::Logger.new(
|
6
|
+
def initialize(connector, channel_list, logger = ::Logger.new($stdout))
|
7
7
|
@connector = connector
|
8
8
|
@channel_list = channel_list
|
9
9
|
@channel_list = [channel_list] unless channel_list.is_a?(Array)
|
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,
|
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?
|
data/lib/pechkin/command/base.rb
CHANGED
@@ -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:
|
12
|
+
def initialize(options, stdout: $stdout, stderr: $stderr)
|
13
13
|
@options = options
|
14
14
|
@stdout = stdout
|
15
15
|
@stderr = stderr
|
@@ -13,11 +13,11 @@ module Pechkin
|
|
13
13
|
def create_connector(bot)
|
14
14
|
case bot.connector
|
15
15
|
when 'tg', 'telegram'
|
16
|
-
|
16
|
+
Connector::Telegram.new(bot.token, bot.name)
|
17
17
|
when 'slack'
|
18
|
-
|
18
|
+
Connector::Slack.new(bot.token, bot.name)
|
19
19
|
else
|
20
|
-
raise
|
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,9 +61,7 @@ module Pechkin
|
|
63
61
|
message_config = YAML.safe_load(IO.read(file))
|
64
62
|
name = File.basename(file, '.yml')
|
65
63
|
|
66
|
-
if message_config.key?('template')
|
67
|
-
message_config['template'] = get_template(message_config['template'])
|
68
|
-
end
|
64
|
+
message_config['template'] = get_template(message_config['template']) if message_config.key?('template')
|
69
65
|
|
70
66
|
messages[name] = message_config
|
71
67
|
end
|
@@ -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]
|
@@ -10,16 +10,16 @@ module Pechkin
|
|
10
10
|
# Pechkin reads its configuration from provided directory structure. Basic
|
11
11
|
# layout expected to be as follows:
|
12
12
|
# .
|
13
|
-
# | - bots/
|
14
|
-
# | | - marvin.yml
|
13
|
+
# | - bots/ <- Bots configuration
|
14
|
+
# | | - marvin.yml <- Each bot described by yaml file
|
15
15
|
# | | - bender.yml
|
16
16
|
# |
|
17
|
-
# | - channels/
|
17
|
+
# | - channels/ <- Channels description
|
18
18
|
# | | - slack-repository-feed
|
19
19
|
# | | - commit-hg.yml
|
20
20
|
# | | - commit-svn.yml
|
21
21
|
# |
|
22
|
-
# | - views/
|
22
|
+
# | - views/ <- Template storage
|
23
23
|
# | - commit-hg.erb
|
24
24
|
# | - commit-svn.erb
|
25
25
|
#
|
@@ -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
|
data/lib/pechkin/connector.rb
CHANGED
@@ -4,26 +4,6 @@ require 'uri'
|
|
4
4
|
require 'json'
|
5
5
|
require 'cgi'
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
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'
|
data/lib/pechkin/exceptions.rb
CHANGED
data/lib/pechkin/handler.rb
CHANGED
@@ -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 =
|
25
|
-
|
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
|
-
|
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 =
|
51
|
-
|
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
|
-
|
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,24 @@ 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_config = substitute(data, fetch_message(channel_config, msg_id))
|
99
|
+
|
100
|
+
data = (message_config['variables'] || {}).merge(data)
|
101
|
+
template = message_config['template']
|
102
|
+
|
103
|
+
text = ''
|
104
|
+
text = template.render(data) unless template.nil?
|
105
|
+
|
106
|
+
[channel_config, message_config, text]
|
107
|
+
end
|
108
|
+
|
87
109
|
def substitute(data, message_desc)
|
88
110
|
substitute_recursive(Substitute.new(data), message_desc)
|
89
111
|
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
|
data/lib/pechkin/version.rb
CHANGED
data/lib/pechkin.rb
CHANGED
@@ -11,10 +11,9 @@ require_relative 'pechkin/cli'
|
|
11
11
|
require_relative 'pechkin/command'
|
12
12
|
require_relative 'pechkin/exceptions'
|
13
13
|
require_relative 'pechkin/handler'
|
14
|
+
require_relative 'pechkin/message_matcher'
|
14
15
|
require_relative 'pechkin/message_template'
|
15
16
|
require_relative 'pechkin/connector'
|
16
|
-
require_relative 'pechkin/connector_slack'
|
17
|
-
require_relative 'pechkin/connector_telegram'
|
18
17
|
require_relative 'pechkin/channel'
|
19
18
|
require_relative 'pechkin/configuration'
|
20
19
|
require_relative 'pechkin/substitute'
|
@@ -29,8 +28,8 @@ module Pechkin # :nodoc:
|
|
29
28
|
cmd = Command::Dispatcher.new(options).dispatch
|
30
29
|
cmd.execute
|
31
30
|
rescue StandardError => e
|
32
|
-
warn
|
33
|
-
warn "\t
|
31
|
+
warn "Error: #{e.message}"
|
32
|
+
warn "\t#{e.backtrace.reverse.join("\n\t")}" if options.debug?
|
34
33
|
exit 2
|
35
34
|
end
|
36
35
|
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.
|
4
|
+
version: 1.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ilya Arkhanhelsky
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-10-16 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.
|
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.
|
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.
|
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.
|
68
|
+
version: 2.2.3
|
83
69
|
description:
|
84
70
|
email: ilya.arkhanhelsky at gmail.com
|
85
71
|
executables:
|
@@ -90,6 +76,10 @@ 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
|
94
84
|
- lib/pechkin/channel.rb
|
95
85
|
- lib/pechkin/cli.rb
|
@@ -107,10 +97,12 @@ files:
|
|
107
97
|
- lib/pechkin/configuration/configuration_loader_views.rb
|
108
98
|
- lib/pechkin/configuration/model.rb
|
109
99
|
- lib/pechkin/connector.rb
|
110
|
-
- lib/pechkin/
|
111
|
-
- lib/pechkin/
|
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_matcher.rb
|
114
106
|
- lib/pechkin/message_template.rb
|
115
107
|
- lib/pechkin/prometheus_utils.rb
|
116
108
|
- lib/pechkin/substitute.rb
|
@@ -125,16 +117,16 @@ require_paths:
|
|
125
117
|
- lib
|
126
118
|
required_ruby_version: !ruby/object:Gem::Requirement
|
127
119
|
requirements:
|
128
|
-
- - "
|
120
|
+
- - ">"
|
129
121
|
- !ruby/object:Gem::Version
|
130
|
-
version: '
|
122
|
+
version: '2.5'
|
131
123
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
132
124
|
requirements:
|
133
125
|
- - ">="
|
134
126
|
- !ruby/object:Gem::Version
|
135
127
|
version: '0'
|
136
128
|
requirements: []
|
137
|
-
rubygems_version: 3.
|
129
|
+
rubygems_version: 3.2.22
|
138
130
|
signing_key:
|
139
131
|
specification_version: 4
|
140
132
|
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
|