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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b4d225464fc987cff4a7df181e175e4756c60ccc9982582d7906b4f85b194f3d
4
- data.tar.gz: 5ca5b0e4c4aacf29e38a51a12217494539f5ef4a31f1eedaf799fc0170f9eb40
3
+ metadata.gz: 4b9031bb8570fd2ca66241e30271167194aebae7a832ecdc3b98fb07630505e2
4
+ data.tar.gz: 57685b4ededcd3935e412611750b0ed716f386b5d759f88004993cd64162f90d
5
5
  SHA512:
6
- metadata.gz: f23b61d62cc9fbf3df9d4e578080147f9de3717f8e924d71329778674dcfa21a0bccd46b45e32d33c9ac30eb515d82b9cceeca2af34da1dea210414f20e6d8b3
7
- data.tar.gz: ddc485f1678e2a4d4b6ab61f655dbaf69e7fbeb5e1dcc1ae338f2b039f6f77c4cdad6d7d1bfb828c7087c128d09a47e93cb40eb17b7c9a2aba455bef1ff5cdde
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/message'
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/api'
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
- configuration = Config.new(options.config_file)
17
- setup_logging(options.log_dir) if options.log_dir
18
- app = Pechkin.create(configuration)
19
- PechkinAPI.logger.info 'Starting pechkin service...'
20
- Rack::Server.start(app: app,
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
- def setup_logging(log_dir)
26
- logger = ::Logger.new(File.join(log_dir, 'pechkin.log'), 'daily')
27
- logger.level = ::Logger::INFO
69
+ data = IO.read(f)
70
+ end
28
71
 
29
- PechkinAPI.logger = logger
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
@@ -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
@@ -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 = ::Logger.new(STDOUT)
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
- # Command Line parser
9
- module CLI
10
- # Default values for CLI options
11
- DEFAULT_OPTIONS = {
12
- config_file: '/etc/pechkin/config.yml'
13
- }.freeze
14
- # Command Line Parser Builder
15
- class CLIBuilder
16
- attr_reader :options
17
- def initialize(options_keeper)
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
- def build(parser) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
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
- parser.on('-c', '--config CONFIG_FILE', 'default value is /etc/pechkin/config.yml') do |value|
27
- options.config_file = value
28
- end
21
+ @cli_options << { name: name,
22
+ default: default,
23
+ names: names,
24
+ type: type,
25
+ desc: desc }
26
+ end
29
27
 
30
- parser.on('-p', '--port PORT', Integer) do |value|
31
- options.port = value
32
- end
28
+ def separator(string)
29
+ @cli_options ||= []
30
+ @cli_options << string
31
+ end
33
32
 
34
- parser.on('--pid PID_FILE') do |value|
35
- options.pid_file = value
36
- end
33
+ def banner(banner)
34
+ @cli_banner = banner
35
+ end
37
36
 
38
- parser.on('--log-dir LOG_DIR') do |value|
39
- options.log_dir = value
40
- end
37
+ def parse(args)
38
+ values = OpenStruct.new
39
+ parser = parser_create(values)
41
40
 
42
- parser.separator ''
43
- parser.separator 'Common options:'
44
- parser.on_tail('-h', '--help', 'Show this message') do
45
- puts parser
46
- exit
47
- end
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
- # Another typical switch to print the version.
50
- parser.on_tail('--version', 'Show version') do
51
- puts Version.version_string
52
- exit
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
- class << self
59
- def parse(args)
60
- options_keeper = OpenStruct.new(DEFAULT_OPTIONS)
61
- parser = OptionParser.new do |p|
62
- CLIBuilder.new(options_keeper).build(p)
63
- end
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
- parser.parse(args)
66
- options_keeper
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
@@ -0,0 +1,4 @@
1
+ module Pechkin
2
+ Bot = Struct.new(:token, :connector, :name, keyword_init: true)
3
+ Channel = Struct.new(:chat_ids, :connector, :messages, keyword_init: true)
4
+ end
@@ -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
@@ -1,7 +1,7 @@
1
1
  module Pechkin
2
2
  # Keeps actual version
3
3
  module Version
4
- VERSION = [0, 2, 0].freeze
4
+ VERSION = [1, 0, 0].freeze
5
5
  class << self
6
6
  def version_string
7
7
  VERSION.join('.')
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.2.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-18 00:00:00.000000000 Z
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/api.rb
64
+ - lib/pechkin/app.rb
51
65
  - lib/pechkin/channel.rb
52
66
  - lib/pechkin/cli.rb
53
- - lib/pechkin/config.rb
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/message.rb
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
@@ -1,10 +0,0 @@
1
- require 'yaml'
2
-
3
- module Pechkin
4
- # Loads pechkin configuration
5
- class Config < OpenStruct
6
- def initialize(file)
7
- super(YAML.safe_load(IO.read(file)))
8
- end
9
- end
10
- end
@@ -1,8 +0,0 @@
1
- module Pechkin
2
- # Easy way to render erb template
3
- class Message < OpenStruct
4
- def render(template_file)
5
- ERB.new(IO.read(template_file)).result(binding)
6
- end
7
- end
8
- end