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 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