pechkin 0.2.0 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/pechkin.rb +58 -14
- data/lib/pechkin/app.rb +105 -0
- data/lib/pechkin/channel.rb +2 -2
- data/lib/pechkin/cli.rb +108 -47
- data/lib/pechkin/configuration.rb +102 -0
- data/lib/pechkin/configuration/configuration_loader.rb +28 -0
- data/lib/pechkin/configuration/configuration_loader_bots.rb +39 -0
- data/lib/pechkin/configuration/configuration_loader_channels.rb +83 -0
- data/lib/pechkin/configuration/configuration_loader_views.rb +28 -0
- data/lib/pechkin/configuration/model.rb +4 -0
- data/lib/pechkin/connector.rb +5 -44
- data/lib/pechkin/connector_slack.rb +27 -0
- data/lib/pechkin/connector_telegram.rb +24 -0
- data/lib/pechkin/exceptions.rb +10 -0
- data/lib/pechkin/handler.rb +87 -0
- data/lib/pechkin/message_template.rb +19 -0
- data/lib/pechkin/version.rb +1 -1
- metadata +28 -5
- data/lib/pechkin/api.rb +0 -106
- data/lib/pechkin/config.rb +0 -10
- data/lib/pechkin/message.rb +0 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4b9031bb8570fd2ca66241e30271167194aebae7a832ecdc3b98fb07630505e2
|
4
|
+
data.tar.gz: 57685b4ededcd3935e412611750b0ed716f386b5d759f88004993cd64162f90d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3d04eab93cb96a74e68a1f3d7b47707c917c8909b20d251180db0dadfa0144b35b38ac6be19dfed89c4fc66f1ab50ae9d83f9f4df7782bd05c1b085e246d388a
|
7
|
+
data.tar.gz: 680509c985a97814062b85d3039268334f8b8b5623a4724c772c2c054f4fdbcddf32d13c2dd94d2932119a9c445c1664472730eb5c2f1ceda982d9314584155b
|
data/lib/pechkin.rb
CHANGED
@@ -1,32 +1,76 @@
|
|
1
|
+
require 'erb'
|
1
2
|
require 'rack'
|
2
3
|
require 'logger'
|
4
|
+
require 'prometheus/middleware/collector'
|
5
|
+
require 'prometheus/middleware/exporter'
|
3
6
|
|
4
7
|
require_relative 'pechkin/cli'
|
5
|
-
require_relative 'pechkin/
|
8
|
+
require_relative 'pechkin/exceptions'
|
9
|
+
require_relative 'pechkin/handler'
|
10
|
+
require_relative 'pechkin/message_template'
|
6
11
|
require_relative 'pechkin/connector'
|
12
|
+
require_relative 'pechkin/connector_slack'
|
13
|
+
require_relative 'pechkin/connector_telegram'
|
7
14
|
require_relative 'pechkin/channel'
|
8
|
-
require_relative 'pechkin/
|
9
|
-
require_relative 'pechkin/config'
|
15
|
+
require_relative 'pechkin/configuration'
|
10
16
|
require_relative 'pechkin/substitute'
|
17
|
+
require_relative 'pechkin/app'
|
11
18
|
|
12
19
|
module Pechkin # :nodoc:
|
13
20
|
class << self
|
14
21
|
def run
|
15
22
|
options = CLI.parse(ARGV)
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
Port: options.port || configuration.port,
|
22
|
-
pid: options.pid_file)
|
23
|
+
Main.new(options).run
|
24
|
+
rescue StandardError => e
|
25
|
+
warn 'Error: ' + e.message
|
26
|
+
warn "\t" + e.backtrace.reverse.join("\n\t") if options.debug?
|
27
|
+
exit 2
|
23
28
|
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class Main # :nodoc:
|
32
|
+
attr_reader :options, :configuration, :handler
|
33
|
+
|
34
|
+
def initialize(options)
|
35
|
+
@options = options
|
36
|
+
@configuration = Configuration.load_from_directory(options.config_file)
|
37
|
+
@handler = Handler.new(@configuration.channels)
|
38
|
+
end
|
39
|
+
|
40
|
+
def run
|
41
|
+
configuration.list if options.list?
|
42
|
+
exit 0 if options.check?
|
43
|
+
|
44
|
+
if options.send_data
|
45
|
+
send_data
|
46
|
+
exit 0
|
47
|
+
end
|
48
|
+
|
49
|
+
run_server
|
50
|
+
end
|
51
|
+
|
52
|
+
def run_server
|
53
|
+
Rack::Server.start(app: AppBuilder.new.build(handler, options),
|
54
|
+
Port: options.port, pid: options.pid_file)
|
55
|
+
end
|
56
|
+
|
57
|
+
def send_data
|
58
|
+
ch, msg = options.send_data.match(%r{^([^/]+)/(.+)}) do |m|
|
59
|
+
[m[1], m[2]]
|
60
|
+
end
|
61
|
+
|
62
|
+
raise "#{ch}/#{msg} not found" unless handler.message?(ch, msg)
|
63
|
+
|
64
|
+
data = options.data
|
65
|
+
if data.start_with?('@')
|
66
|
+
f = data[1..-1]
|
67
|
+
raise "File not found #{f}" unless File.exist?(f)
|
24
68
|
|
25
|
-
|
26
|
-
|
27
|
-
logger.level = ::Logger::INFO
|
69
|
+
data = IO.read(f)
|
70
|
+
end
|
28
71
|
|
29
|
-
|
72
|
+
handler.preview = options.preview
|
73
|
+
handler.handle(ch, msg, JSON.parse(data, symbolize_names: true))
|
30
74
|
end
|
31
75
|
end
|
32
76
|
end
|
data/lib/pechkin/app.rb
ADDED
@@ -0,0 +1,105 @@
|
|
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
|
+
|
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
|
15
|
+
use Prometheus::Middleware::Exporter
|
16
|
+
|
17
|
+
run app
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def create_logger(log_dir)
|
24
|
+
if log_dir
|
25
|
+
raise "Directory #{log_dir} does not exist" unless File.exist?(log_dir)
|
26
|
+
|
27
|
+
log_file = File.join(log_dir, 'pechkin.log')
|
28
|
+
file = File.open(log_file, File::WRONLY | File::APPEND)
|
29
|
+
Logger.new(file)
|
30
|
+
else
|
31
|
+
Logger.new(STDOUT)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Rack application to handle requests
|
37
|
+
class App
|
38
|
+
attr_accessor :handler
|
39
|
+
|
40
|
+
def call(env)
|
41
|
+
RequestHandler.new(handler, env).handle
|
42
|
+
rescue StandardError => e
|
43
|
+
body = '{"status": "error", "reason":"' + e.message + '"'
|
44
|
+
['503', { 'Content-Type' => 'application/json' }, body]
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Http requests handler. We need fresh instance per each request. To keep
|
49
|
+
# internal state isolated
|
50
|
+
class RequestHandler
|
51
|
+
REQ_PATH_PATTERN = %r{^/(.+)/([^/]+)/?$}
|
52
|
+
DEFAULT_CONTENT_TYPE = { 'Content-Type' => 'application/json' }.freeze
|
53
|
+
DEFAULT_HEADERS = {}.merge(DEFAULT_CONTENT_TYPE).freeze
|
54
|
+
|
55
|
+
attr_reader :req, :env, :handler,
|
56
|
+
:channel_id, :message_id
|
57
|
+
|
58
|
+
def initialize(handler, env)
|
59
|
+
@handler = handler
|
60
|
+
@env = env
|
61
|
+
@req = Rack::Request.new(env)
|
62
|
+
|
63
|
+
@channel_id, @message_id = req.path_info.match(REQ_PATH_PATTERN) do |m|
|
64
|
+
[m[1], m[2]]
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def handle
|
69
|
+
return not_allowed unless post?
|
70
|
+
return not_found unless message?
|
71
|
+
|
72
|
+
begin
|
73
|
+
data = JSON.parse(req.body.read, symbolize_names: true)
|
74
|
+
rescue JSON::JSONError => e
|
75
|
+
return bad_request(e.message)
|
76
|
+
end
|
77
|
+
|
78
|
+
response(200, handler.handle(channel_id, message_id, data).to_json)
|
79
|
+
end
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
def message?
|
84
|
+
return false unless @channel_id && @message_id
|
85
|
+
|
86
|
+
handler.message?(@channel_id, @message_id)
|
87
|
+
end
|
88
|
+
|
89
|
+
def not_allowed
|
90
|
+
response(405, '{"status":"error", "reason":"method not allowed"}')
|
91
|
+
end
|
92
|
+
|
93
|
+
def not_found
|
94
|
+
response(404, '{"status":"error", "reason":"message not found"}')
|
95
|
+
end
|
96
|
+
|
97
|
+
def response(code, body)
|
98
|
+
[code.to_s, DEFAULT_HEADERS, body]
|
99
|
+
end
|
100
|
+
|
101
|
+
def post?
|
102
|
+
req.post?
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
data/lib/pechkin/channel.rb
CHANGED
@@ -3,11 +3,11 @@ module Pechkin
|
|
3
3
|
class Chanel
|
4
4
|
attr_accessor :logger
|
5
5
|
|
6
|
-
def initialize(connector, channel_list)
|
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)
|
10
|
-
@logger =
|
10
|
+
@logger = logger
|
11
11
|
end
|
12
12
|
|
13
13
|
def send_message(message, data, message_desc)
|
data/lib/pechkin/cli.rb
CHANGED
@@ -5,66 +5,127 @@ require 'ostruct'
|
|
5
5
|
require_relative 'version'
|
6
6
|
|
7
7
|
module Pechkin
|
8
|
-
#
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
#
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
@options = options_keeper
|
19
|
-
end
|
8
|
+
# Helper methods to declare all command line options. This should remove most
|
9
|
+
# optparse configuration boilerplate
|
10
|
+
module CLIHelper
|
11
|
+
# @param name [Symbol] variable name to store values
|
12
|
+
# @opt default [Object] default value
|
13
|
+
# @opt names [Array<String>] list of command line keys
|
14
|
+
# @opt desc [String] option description
|
15
|
+
# @opt type [Class] argument type to parse from command line, e.g. Integer
|
16
|
+
def opt(name, default: nil, names:, desc: '', type: nil)
|
17
|
+
@cli_options ||= []
|
20
18
|
|
21
|
-
|
22
|
-
# rubocop:disable Metrics/LineLength
|
23
|
-
parser.banner = 'Usage: pechkin [options]'
|
24
|
-
parser.separator ''
|
19
|
+
# raise ':names is nil or empty' if names.nil? || names.empty?
|
25
20
|
|
26
|
-
|
27
|
-
|
28
|
-
|
21
|
+
@cli_options << { name: name,
|
22
|
+
default: default,
|
23
|
+
names: names,
|
24
|
+
type: type,
|
25
|
+
desc: desc }
|
26
|
+
end
|
29
27
|
|
30
|
-
|
31
|
-
|
32
|
-
|
28
|
+
def separator(string)
|
29
|
+
@cli_options ||= []
|
30
|
+
@cli_options << string
|
31
|
+
end
|
33
32
|
|
34
|
-
|
35
|
-
|
36
|
-
|
33
|
+
def banner(banner)
|
34
|
+
@cli_banner = banner
|
35
|
+
end
|
37
36
|
|
38
|
-
|
39
|
-
|
40
|
-
|
37
|
+
def parse(args)
|
38
|
+
values = OpenStruct.new
|
39
|
+
parser = parser_create(values)
|
41
40
|
|
42
|
-
|
43
|
-
parser.
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
41
|
+
if args.empty?
|
42
|
+
puts parser.help
|
43
|
+
exit 2
|
44
|
+
else
|
45
|
+
parser.parse(args)
|
46
|
+
values
|
47
|
+
end
|
48
|
+
end
|
48
49
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
50
|
+
def parser_create(values)
|
51
|
+
parser = OptionParser.new
|
52
|
+
parser.banner = @cli_banner
|
53
|
+
|
54
|
+
(@cli_options || []).each do |o|
|
55
|
+
if o.is_a?(String)
|
56
|
+
parser.separator o
|
57
|
+
else
|
58
|
+
values[o[:name]] = o[:default] if o[:default]
|
59
|
+
|
60
|
+
args = []
|
61
|
+
args += o[:names]
|
62
|
+
args << o[:type] if o[:type]
|
63
|
+
args << o[:desc] if o[:desc]
|
64
|
+
|
65
|
+
parser.on(*args) { |v| values[o[:name]] = v }
|
53
66
|
end
|
54
|
-
# rubocop:enable Metrics/LineLength
|
55
67
|
end
|
68
|
+
|
69
|
+
parser_create_default_opts(parser)
|
70
|
+
|
71
|
+
parser
|
56
72
|
end
|
57
73
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
74
|
+
def parser_create_default_opts(parser)
|
75
|
+
parser.separator ''
|
76
|
+
parser.separator 'Common options:'
|
77
|
+
parser.on_tail('-h', '--help', 'Show this message') do
|
78
|
+
puts parser
|
79
|
+
exit 1
|
80
|
+
end
|
64
81
|
|
65
|
-
|
66
|
-
|
82
|
+
# Another typical switch to print the version.
|
83
|
+
parser.on_tail('--version', 'Show version') do
|
84
|
+
puts Version.version_string
|
85
|
+
exit 0
|
67
86
|
end
|
68
87
|
end
|
69
88
|
end
|
89
|
+
|
90
|
+
# Command Line Parser Builder
|
91
|
+
class CLI
|
92
|
+
extend CLIHelper
|
93
|
+
|
94
|
+
separator 'Run options'
|
95
|
+
|
96
|
+
opt :config_file, default: Dir.pwd,
|
97
|
+
names: ['-c', '--config-dir FILE'],
|
98
|
+
desc: 'Path to configuration file'
|
99
|
+
|
100
|
+
opt :port, names: ['--port PORT'], default: 9292, type: Integer
|
101
|
+
|
102
|
+
opt :pid_file, names: ['-p', '--pid-file [FILE]'],
|
103
|
+
desc: 'Path to output PID file'
|
104
|
+
|
105
|
+
opt :log_dir, names: ['--log-dir [DIR]'],
|
106
|
+
desc: 'Path to log directory. Output will be writen to' \
|
107
|
+
'pechkin.log file. If not specified will write to' \
|
108
|
+
'STDOUT'
|
109
|
+
|
110
|
+
separator 'Utils for configuration maintenance'
|
111
|
+
|
112
|
+
opt :list?, names: ['-l', '--[no-]list'],
|
113
|
+
desc: 'List all endpoints'
|
114
|
+
|
115
|
+
opt :check?, names: ['-k', '--[no-]check'],
|
116
|
+
desc: 'Load configuration and exit'
|
117
|
+
opt :send_data, names: ['-s', '--send ENDPOINT'],
|
118
|
+
desc: 'Send data to specified ENDPOINT and exit. Requires' \
|
119
|
+
'--data to be set.'
|
120
|
+
opt :preview, names: ['--preview'],
|
121
|
+
desc: 'Print rendering result to STDOUT and exit. ' \
|
122
|
+
'Use with send'
|
123
|
+
opt :data, names: ['--data DATA'],
|
124
|
+
desc: 'Data to send with --send flag. Json string or @filename.'
|
125
|
+
|
126
|
+
separator 'Debug options'
|
127
|
+
|
128
|
+
opt :debug?, names: ['--[no-]debug'],
|
129
|
+
desc: 'Print debug information and stack trace on errors'
|
130
|
+
end
|
70
131
|
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
require_relative 'configuration/model'
|
4
|
+
require_relative 'configuration/configuration_loader'
|
5
|
+
require_relative 'configuration/configuration_loader_bots'
|
6
|
+
require_relative 'configuration/configuration_loader_channels'
|
7
|
+
require_relative 'configuration/configuration_loader_views'
|
8
|
+
|
9
|
+
module Pechkin
|
10
|
+
# Pechkin reads its configuration from provided directory structure. Basic
|
11
|
+
# layout expected to be as follows:
|
12
|
+
# .
|
13
|
+
# | - bots/ <= Bots configuration
|
14
|
+
# | | - marvin.yml <= Each bot described by yaml file
|
15
|
+
# | | - bender.yml
|
16
|
+
# |
|
17
|
+
# | - channels/ <= Channels description
|
18
|
+
# | | - slack-repository-feed
|
19
|
+
# | | - commit-hg.yml
|
20
|
+
# | | - commit-svn.yml
|
21
|
+
# |
|
22
|
+
# | - views/ <= Template storage
|
23
|
+
# | - commit-hg.erb
|
24
|
+
# | - commit-svn.erb
|
25
|
+
#
|
26
|
+
# Bots
|
27
|
+
# Bots described in YAML files in `bots` directory. Bot described by
|
28
|
+
# following fields:
|
29
|
+
# - token - API token used to authorize when doing requests to messenger
|
30
|
+
# API
|
31
|
+
# - connector - Connector name to instantiate. For exapmle: 'telegram' or
|
32
|
+
# 'slack'
|
33
|
+
# Channels
|
34
|
+
# Channel is a description of message group. It used to describe group of
|
35
|
+
# messages that sould be send to sepceific channel or user. Each
|
36
|
+
# channel configuration is stored in its own folder. This folder name
|
37
|
+
# is channel internal id. Channel is described by `_channel.yml` file,
|
38
|
+
# Channel has following fields to configure:
|
39
|
+
# - chat_ids - list of ids to send all containing messages. It may be
|
40
|
+
# single item or list of ids.
|
41
|
+
# - bot - bot istance to use when messages are handled.
|
42
|
+
# Other `*.yml` files in channel folder are message descriptions. Message
|
43
|
+
# description has following fields to configure:
|
44
|
+
# - template - path to template relative to views/ folder. If no template
|
45
|
+
# specified then noop template will be used. No-op template returns empty
|
46
|
+
# string for each render request.
|
47
|
+
# - variables - predefined variables to use in template rendering. This is
|
48
|
+
# especialy useful when one wants to use same template in different
|
49
|
+
# channels. For exapmle when you need to render repository commit and
|
50
|
+
# want to substitute correct repository link
|
51
|
+
# - filters - list of rules which allows to deny some messages based on
|
52
|
+
# their content. For example we do not want to post commit messages from
|
53
|
+
# branches other than `master`.
|
54
|
+
#
|
55
|
+
# And other connector speceific fields. For example:
|
56
|
+
# - telegram_parse_mode
|
57
|
+
# - slack_attachments
|
58
|
+
#
|
59
|
+
# Views
|
60
|
+
# 'views' folder contains erb templates to render when data arives.
|
61
|
+
class Configuration
|
62
|
+
class << self
|
63
|
+
def load_from_directory(working_dir)
|
64
|
+
bots = ConfigurationLoaderBots.new.load_from_directory(working_dir)
|
65
|
+
views = ConfigurationLoaderViews.new.load_from_directory(working_dir)
|
66
|
+
|
67
|
+
channel_loader = ConfigurationLoaderChannels.new(bots, views)
|
68
|
+
channels = channel_loader.load_from_directory(working_dir)
|
69
|
+
|
70
|
+
Configuration.new(working_dir, bots, views, channels)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
attr_accessor :bots, :channels, :views, :working_dir
|
75
|
+
|
76
|
+
def initialize(working_dir, bots, views, channels)
|
77
|
+
@working_dir = working_dir
|
78
|
+
@bots = bots
|
79
|
+
@views = views
|
80
|
+
@channels = channels
|
81
|
+
end
|
82
|
+
|
83
|
+
def list
|
84
|
+
puts "Working dir: #{working_dir}\nBots:"
|
85
|
+
|
86
|
+
bots.each do |name, bot|
|
87
|
+
puts " #{name}(#{bot.connector}): #{bot.token}"
|
88
|
+
end
|
89
|
+
|
90
|
+
puts "\nChannels:"
|
91
|
+
channels.each do |channel_name, channel|
|
92
|
+
puts " - name #{channel_name}"
|
93
|
+
puts " bot: #{channel.connector.name}"
|
94
|
+
puts ' messages: '
|
95
|
+
channel.messages.each do |message_name, _message|
|
96
|
+
puts " - /#{channel_name}/#{message_name}"
|
97
|
+
end
|
98
|
+
puts
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Pechkin
|
2
|
+
# Common code for all configuration loaders. To use this code just include
|
3
|
+
# module in user class.
|
4
|
+
module ConfigurationLoader
|
5
|
+
def check_field(object, field, file)
|
6
|
+
contains = object.key?(field)
|
7
|
+
|
8
|
+
raise ConfigurationError, "#{file}: '#{field}' is missing" unless contains
|
9
|
+
|
10
|
+
object[field]
|
11
|
+
end
|
12
|
+
|
13
|
+
def create_connector(bot)
|
14
|
+
case bot.connector
|
15
|
+
when 'tg', 'telegram'
|
16
|
+
TelegramConnector.new(bot.token, bot.name)
|
17
|
+
when 'slack'
|
18
|
+
SlackConnector.new(bot.token, bot.name)
|
19
|
+
else
|
20
|
+
raise 'Unknown connector ' + bot.connector + ' for ' + bot.name
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def yaml_load(file)
|
25
|
+
YAML.safe_load(IO.read(file))
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Pechkin
|
2
|
+
# Configuration loader for bot descriptions
|
3
|
+
class ConfigurationLoaderBots
|
4
|
+
include ConfigurationLoader
|
5
|
+
|
6
|
+
def load_from_directory(working_directory)
|
7
|
+
bots = {}
|
8
|
+
load_bots_configuration(working_directory, bots)
|
9
|
+
|
10
|
+
bots
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def load_bots_configuration(working_dir, bots)
|
16
|
+
bots_dir = File.join(working_dir, 'bots')
|
17
|
+
|
18
|
+
unless File.directory?(bots_dir)
|
19
|
+
raise ConfigurationError, "'#{bots_dir}' is not a directory"
|
20
|
+
end
|
21
|
+
|
22
|
+
Dir["#{bots_dir}/*.yml"].each do |bot_file|
|
23
|
+
name = File.basename(bot_file, '.yml')
|
24
|
+
bot = load_bot_configuration(bot_file)
|
25
|
+
bot.name = name
|
26
|
+
bots[name] = bot
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def load_bot_configuration(bot_file)
|
31
|
+
bot_configuration = yaml_load(bot_file)
|
32
|
+
|
33
|
+
token = check_field(bot_configuration, 'token', bot_file)
|
34
|
+
connector = check_field(bot_configuration, 'connector', bot_file)
|
35
|
+
|
36
|
+
Bot.new(token: token, connector: connector)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module Pechkin
|
2
|
+
# Configuration loader for bot descriptions
|
3
|
+
class ConfigurationLoaderChannels
|
4
|
+
include ConfigurationLoader
|
5
|
+
|
6
|
+
attr_reader :bots
|
7
|
+
|
8
|
+
def initialize(bots, views)
|
9
|
+
@bots = bots
|
10
|
+
@views = views
|
11
|
+
end
|
12
|
+
|
13
|
+
def load_from_directory(working_directory)
|
14
|
+
channels = {}
|
15
|
+
load_channels_configuration(working_directory, channels)
|
16
|
+
|
17
|
+
channels
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def load_channels_configuration(working_dir, channels)
|
23
|
+
channels_dir = File.join(working_dir, 'channels')
|
24
|
+
|
25
|
+
unless File.directory?(channels_dir)
|
26
|
+
raise ConfigurationError, "'#{channels_dir}' is not a directory"
|
27
|
+
end
|
28
|
+
|
29
|
+
Dir["#{channels_dir}/*"].each do |channel_dir|
|
30
|
+
next unless File.directory?(channel_dir)
|
31
|
+
|
32
|
+
name = File.basename(channel_dir)
|
33
|
+
channels[name] = load_channel_configuration(channel_dir)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def load_channel_configuration(channel_dir)
|
38
|
+
channel_file = File.join(channel_dir, '_channel.yml')
|
39
|
+
|
40
|
+
msg = "_channel.yml not found at #{channel_dir}"
|
41
|
+
raise ConfigurationError, msg unless File.exist?(channel_file)
|
42
|
+
|
43
|
+
channel_config = yaml_load(channel_file)
|
44
|
+
|
45
|
+
bot = check_field(channel_config, 'bot', channel_file)
|
46
|
+
chat_ids = check_field(channel_config, 'chat_ids', channel_file)
|
47
|
+
chat_ids = [chat_ids] unless chat_ids.is_a?(Array)
|
48
|
+
messages = load_messages_configuration(channel_dir)
|
49
|
+
|
50
|
+
msg = "#{channel_file}: bot '#{bot}' not found"
|
51
|
+
raise ConfigurationError, msg unless bots.key?(bot)
|
52
|
+
|
53
|
+
connector = create_connector(bots[bot])
|
54
|
+
Channel.new(connector: connector, chat_ids: chat_ids, messages: messages)
|
55
|
+
end
|
56
|
+
|
57
|
+
def load_messages_configuration(channel_dir)
|
58
|
+
messages = {}
|
59
|
+
|
60
|
+
Dir["#{channel_dir}/*.yml"].each do |file|
|
61
|
+
next if File.basename(file) == '_channel.yml'
|
62
|
+
|
63
|
+
message_config = YAML.safe_load(IO.read(file))
|
64
|
+
name = File.basename(file, '.yml')
|
65
|
+
|
66
|
+
if message_config.key?('template')
|
67
|
+
message_config['template'] = get_template(message_config['template'])
|
68
|
+
end
|
69
|
+
|
70
|
+
messages[name] = message_config
|
71
|
+
end
|
72
|
+
|
73
|
+
messages
|
74
|
+
end
|
75
|
+
|
76
|
+
def get_template(path)
|
77
|
+
msg = "Can't find template: #{path}"
|
78
|
+
raise ConfigurationError, msg unless @views.key?(path)
|
79
|
+
|
80
|
+
@views[path]
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Pechkin
|
2
|
+
# Configuration loader for view descriptions
|
3
|
+
class ConfigurationLoaderViews
|
4
|
+
include ConfigurationLoader
|
5
|
+
|
6
|
+
def load_from_directory(working_dir)
|
7
|
+
views = {}
|
8
|
+
load_views_configuration(working_dir, views)
|
9
|
+
|
10
|
+
views
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def load_views_configuration(working_dir, views)
|
16
|
+
views_dir = File.join(working_dir, 'views')
|
17
|
+
|
18
|
+
unless File.directory?(views_dir)
|
19
|
+
raise ConfigurationError, "'#{views_dir}' is not a directory"
|
20
|
+
end
|
21
|
+
|
22
|
+
Dir["#{views_dir}/**/*.erb"].each do |f|
|
23
|
+
relative_path = f["#{views_dir}/".length..-1]
|
24
|
+
views[relative_path] = MessageTemplate.new(IO.read(f))
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/pechkin/connector.rb
CHANGED
@@ -9,8 +9,12 @@ module Pechkin
|
|
9
9
|
class Connector
|
10
10
|
def send_message(chat, message, message_desc); end
|
11
11
|
|
12
|
+
def preview(chats, message, _message_desc)
|
13
|
+
puts "Connector: #{self.class.name}; Chats: #{chats.join(', ')}\n"
|
14
|
+
puts "Message:\n#{message}"
|
15
|
+
end
|
16
|
+
|
12
17
|
def post_data(url, data, headers: {})
|
13
|
-
puts data.inspect
|
14
18
|
uri = URI.parse(url)
|
15
19
|
headers = { 'Content-Type' => 'application/json' }.merge(headers)
|
16
20
|
http = Net::HTTP.new(uri.host, uri.port)
|
@@ -22,47 +26,4 @@ module Pechkin
|
|
22
26
|
http.request(request)
|
23
27
|
end
|
24
28
|
end
|
25
|
-
|
26
|
-
class TelegramConnector < Connector #:nodoc:
|
27
|
-
def initialize(bot_token)
|
28
|
-
@bot_token = bot_token
|
29
|
-
end
|
30
|
-
|
31
|
-
def send_message(chat_id, message, message_desc)
|
32
|
-
options = { parse_mode: message_desc['telegram_parse_mode'] || 'HTML' }
|
33
|
-
params = options.update(chat_id: chat_id, text: message)
|
34
|
-
|
35
|
-
response = post_data(method_url('sendMessage'), params)
|
36
|
-
[chat_id, response.code.to_i, response.body]
|
37
|
-
end
|
38
|
-
|
39
|
-
private
|
40
|
-
|
41
|
-
def method_url(method)
|
42
|
-
"https://api.telegram.org/bot#{@bot_token}/#{method}"
|
43
|
-
end
|
44
|
-
end
|
45
|
-
|
46
|
-
class SlackConnector < Connector # :nodoc:
|
47
|
-
def initialize(bot_token)
|
48
|
-
@headers = { 'Authorization' => "Bearer #{bot_token}" }
|
49
|
-
end
|
50
|
-
|
51
|
-
def send_message(chat, message, message_desc)
|
52
|
-
text = CGI.unescape_html(message)
|
53
|
-
|
54
|
-
attachments = message_desc['slack_attachments'] || {}
|
55
|
-
|
56
|
-
if text.strip.empty? && attachments.empty?
|
57
|
-
return [chat, 400, 'not sent: empty']
|
58
|
-
end
|
59
|
-
|
60
|
-
params = { channel: chat, text: text, attachments: attachments }
|
61
|
-
|
62
|
-
url = 'https://slack.com/api/chat.postMessage'
|
63
|
-
response = post_data(url, params, headers: @headers)
|
64
|
-
|
65
|
-
[chat, response.code.to_i, response.body]
|
66
|
-
end
|
67
|
-
end
|
68
29
|
end
|
@@ -0,0 +1,27 @@
|
|
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
|
@@ -0,0 +1,24 @@
|
|
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
|
@@ -0,0 +1,10 @@
|
|
1
|
+
module Pechkin
|
2
|
+
class ChannelNotFoundError < StandardError # :nodoc:
|
3
|
+
def initialize(channel_name)
|
4
|
+
super("No such channel #{channel_name}")
|
5
|
+
end
|
6
|
+
end
|
7
|
+
class MessageNotFoundError < StandardError; end
|
8
|
+
class MessageContentIsEmptyError < StandardError; end
|
9
|
+
class ConfigurationError < StandardError; end
|
10
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module Pechkin
|
2
|
+
# Processes feeded data chunks and sends them via connectors to needed IM
|
3
|
+
# services. Can skip some requests acording to filters.
|
4
|
+
class Handler
|
5
|
+
attr_reader :channels
|
6
|
+
attr_writer :preview
|
7
|
+
|
8
|
+
def initialize(channels)
|
9
|
+
@channels = channels
|
10
|
+
end
|
11
|
+
|
12
|
+
# Handles message request. Each request has three parameters: channel id,
|
13
|
+
# message id, and data object. By channel id we determine where to send
|
14
|
+
# data, by message id we determine how to transform this data to real
|
15
|
+
# message.
|
16
|
+
# @param channel_id [String] channel name from configuration. This name is
|
17
|
+
# obtained from directory structure we have in configuration directory.
|
18
|
+
# @param msg_id [String] message name from configuration. This name is
|
19
|
+
# references yml file with message description
|
20
|
+
# @param data [Object] data object to render via template. This is usualy
|
21
|
+
# deserialized json.
|
22
|
+
# @see Configuration
|
23
|
+
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['parameters'] || {}).merge(data)
|
29
|
+
template = message_config['template']
|
30
|
+
|
31
|
+
text = ''
|
32
|
+
text = template.render(data) unless template.nil?
|
33
|
+
|
34
|
+
chats = channel_config.chat_ids
|
35
|
+
connector = channel_config.connector
|
36
|
+
if preview?
|
37
|
+
connector.preview(chats, text, message_config)
|
38
|
+
else
|
39
|
+
chats.map { |chat| connector.send_message(chat, text, message_config) }
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def message?(channel_id, msg_id)
|
44
|
+
channels.key?(channel_id) && channels[channel_id].messages.key?(msg_id)
|
45
|
+
end
|
46
|
+
|
47
|
+
def preview?
|
48
|
+
@preview
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
# Find channel by it's id or trow ChannelNotFoundError
|
54
|
+
def fetch_channel(channel_id)
|
55
|
+
raise ChannelNotFoundError, channel_id unless channels.key?(channel_id)
|
56
|
+
|
57
|
+
channels[channel_id]
|
58
|
+
end
|
59
|
+
|
60
|
+
# Find message config by it's id or throw MessageNotFoundError
|
61
|
+
def fetch_message(channel_config, msg_id)
|
62
|
+
message_list = channel_config.messages
|
63
|
+
raise MessageNotFoundError, msg_id unless message_list.key?(msg_id)
|
64
|
+
|
65
|
+
message_list[msg_id]
|
66
|
+
end
|
67
|
+
|
68
|
+
def substitute(data, message_desc)
|
69
|
+
substitute_recursive(Substitute.new(data), message_desc)
|
70
|
+
end
|
71
|
+
|
72
|
+
def substitute_recursive(substitutions, object)
|
73
|
+
case object
|
74
|
+
when String
|
75
|
+
substitutions.process(object)
|
76
|
+
when Array
|
77
|
+
object.map { |o| substitute_recursive(substitutions, o) }
|
78
|
+
when Hash
|
79
|
+
r = {}
|
80
|
+
object.each { |k, v| r[k] = substitute_recursive(substitutions, v) }
|
81
|
+
r
|
82
|
+
else
|
83
|
+
object
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Pechkin
|
2
|
+
# Class to provide binding for ERB templating engine
|
3
|
+
class MessageBinding < OpenStruct
|
4
|
+
def render_template(template)
|
5
|
+
template.result(binding)
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
# Message template to render final message.
|
10
|
+
class MessageTemplate
|
11
|
+
def initialize(erb)
|
12
|
+
@erb_template = ERB.new(erb, trim_mode: '-')
|
13
|
+
end
|
14
|
+
|
15
|
+
def render(data)
|
16
|
+
MessageBinding.new(data).render_template(@erb_template)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
data/lib/pechkin/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: pechkin
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 1.0.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: 2019-
|
11
|
+
date: 2019-12-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: grape
|
@@ -38,6 +38,20 @@ dependencies:
|
|
38
38
|
- - '='
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: 2.0.6
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: prometheus-client
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - '='
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 1.0.0
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - '='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 1.0.0
|
41
55
|
description:
|
42
56
|
email: ilya.arkhanhelsky at gmail.com
|
43
57
|
executables:
|
@@ -47,12 +61,21 @@ extra_rdoc_files: []
|
|
47
61
|
files:
|
48
62
|
- bin/pechkin
|
49
63
|
- lib/pechkin.rb
|
50
|
-
- lib/pechkin/
|
64
|
+
- lib/pechkin/app.rb
|
51
65
|
- lib/pechkin/channel.rb
|
52
66
|
- lib/pechkin/cli.rb
|
53
|
-
- lib/pechkin/
|
67
|
+
- lib/pechkin/configuration.rb
|
68
|
+
- lib/pechkin/configuration/configuration_loader.rb
|
69
|
+
- lib/pechkin/configuration/configuration_loader_bots.rb
|
70
|
+
- lib/pechkin/configuration/configuration_loader_channels.rb
|
71
|
+
- lib/pechkin/configuration/configuration_loader_views.rb
|
72
|
+
- lib/pechkin/configuration/model.rb
|
54
73
|
- lib/pechkin/connector.rb
|
55
|
-
- lib/pechkin/
|
74
|
+
- lib/pechkin/connector_slack.rb
|
75
|
+
- lib/pechkin/connector_telegram.rb
|
76
|
+
- lib/pechkin/exceptions.rb
|
77
|
+
- lib/pechkin/handler.rb
|
78
|
+
- lib/pechkin/message_template.rb
|
56
79
|
- lib/pechkin/substitute.rb
|
57
80
|
- lib/pechkin/version.rb
|
58
81
|
homepage: https://github.com/iarkhanhelsky/pechkin
|
data/lib/pechkin/api.rb
DELETED
@@ -1,106 +0,0 @@
|
|
1
|
-
require 'grape'
|
2
|
-
require 'json'
|
3
|
-
|
4
|
-
module Pechkin # :nodoc:
|
5
|
-
# Generates all routes based on configuration
|
6
|
-
module Generator
|
7
|
-
def configure(config)
|
8
|
-
base_path = config['base_path']
|
9
|
-
resource base_path do
|
10
|
-
create_chanels(config['chanels'], config['bots'])
|
11
|
-
end
|
12
|
-
|
13
|
-
self
|
14
|
-
end
|
15
|
-
|
16
|
-
def create_chanels(chanels, bots)
|
17
|
-
chanels.each do |chanel_name, chanel_desc|
|
18
|
-
bot = bots[chanel_desc['bot']]
|
19
|
-
|
20
|
-
raise "'#{chanel_desc['bot']}' not found." unless bot
|
21
|
-
|
22
|
-
connector = create_connector(bot, chanel_name)
|
23
|
-
|
24
|
-
chat_ids = chanel_desc['chat_ids']
|
25
|
-
channel = Chanel.new(connector, chat_ids)
|
26
|
-
channel.logger = logger
|
27
|
-
resource chanel_name do
|
28
|
-
create_chanel(channel, chanel_desc)
|
29
|
-
end
|
30
|
-
end
|
31
|
-
end
|
32
|
-
|
33
|
-
def create_connector(bot, channel_name)
|
34
|
-
case bot['connector']
|
35
|
-
when 'tg', 'telegram'
|
36
|
-
TelegramConnector.new(bot['token'])
|
37
|
-
when 'slack'
|
38
|
-
SlackConnector.new(bot['token'])
|
39
|
-
else
|
40
|
-
raise 'Unknown connector ' + bot['connector'] + ' for ' + channel_name
|
41
|
-
end
|
42
|
-
end
|
43
|
-
|
44
|
-
def create_chanel(channel, chanel_desc)
|
45
|
-
chanel_desc['messages'].each do |message_name, message_desc|
|
46
|
-
generate_endpoint(channel, message_name, message_desc)
|
47
|
-
end
|
48
|
-
end
|
49
|
-
|
50
|
-
# rubocop:disable Metrics/AbcSize
|
51
|
-
def generate_endpoint(channel, message_name, message_desc)
|
52
|
-
params do
|
53
|
-
# TODO: Can't extract this code to method because this block is
|
54
|
-
# evaluated in separate scope
|
55
|
-
(message_desc['filters'] || []).each do |field, filter|
|
56
|
-
filter.match(%r{^/(.*)/$}) do |m|
|
57
|
-
requires field.to_sym, type: String, regexp: Regexp.new(m[1])
|
58
|
-
end
|
59
|
-
end
|
60
|
-
end
|
61
|
-
post message_name do
|
62
|
-
template = message_desc['template']
|
63
|
-
# Some services will send json, but without correct content-type, then
|
64
|
-
# params will be parsed weirdely. So we try parse request body as json
|
65
|
-
params = ensure_json(request.body.read, params)
|
66
|
-
logger.info "Received message #{params.to_json}"
|
67
|
-
logger.info "Will render template file #{template}"
|
68
|
-
# If message description contains any variables will merge them with
|
69
|
-
# received parameters.
|
70
|
-
params = (message_desc['variables'] || {}).merge(params)
|
71
|
-
|
72
|
-
channel.send_message(template, params, message_desc)
|
73
|
-
end
|
74
|
-
# rubocop:enable Metrics/AbcSize
|
75
|
-
end
|
76
|
-
end
|
77
|
-
|
78
|
-
module Helpers # :nodoc:
|
79
|
-
def ensure_json(body, params)
|
80
|
-
if headers['Content-Type'] == 'application/json'
|
81
|
-
params # Expected content type. Do nothing, just return basic params
|
82
|
-
else
|
83
|
-
JSON.parse(body) # Try parse body as json. If it possible will return as
|
84
|
-
# params
|
85
|
-
end
|
86
|
-
rescue JSON::JSONError => _e
|
87
|
-
params
|
88
|
-
end
|
89
|
-
|
90
|
-
def logger
|
91
|
-
PechkinAPI.logger
|
92
|
-
end
|
93
|
-
end
|
94
|
-
|
95
|
-
# Base class for all pechkin apps
|
96
|
-
class PechkinAPI < Grape::API
|
97
|
-
extend Generator
|
98
|
-
helpers Helpers
|
99
|
-
end
|
100
|
-
|
101
|
-
class << self
|
102
|
-
def create(config)
|
103
|
-
Class.new(PechkinAPI).configure(config)
|
104
|
-
end
|
105
|
-
end
|
106
|
-
end
|
data/lib/pechkin/config.rb
DELETED