pechkin 0.2.0 → 1.0.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.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