├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── Gemfile
├── Gemfile.fluentd.0.12
├── README.md
├── Rakefile
├── VERSION
├── example.conf
├── fluent-plugin-slack.gemspec
├── lib
└── fluent
│ └── plugin
│ ├── out_buffered_slack.rb
│ ├── out_slack.rb
│ ├── slack_client.rb
│ └── slack_client
│ └── error.rb
├── test.sh
└── test
├── plugin
├── test_out_slack.rb
└── test_slack_client.rb
└── test_helper.rb
/.gitignore:
--------------------------------------------------------------------------------
1 | /fluent/
2 | /pkg/
3 | /coverage/
4 | /vendor/
5 | Gemfile.lock
6 | tmp/
7 | .ruby-version
8 | .env
9 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | rvm:
3 | - 2.1.*
4 | - 2.2.*
5 | - 2.3.*
6 | - 2.4.*
7 | gemfile:
8 | - Gemfile
9 | - Gemfile.fluentd.0.12
10 | before_install:
11 | - gem update bundler
12 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 0.6.7 (2017/05/23)
2 |
3 | Enhancements:
4 |
5 | * Allow channel @username (DM)
6 |
7 | ## 0.6.6 (2017/05/23)
8 |
9 | Enhancements:
10 |
11 | * Make channel config optional on webhook because webhook has its defaul channel setting (thanks to @hirakiuc)
12 |
13 | ## 0.6.5 (2017/05/20)
14 |
15 | Enhancements:
16 |
17 | * Avoid Encoding::UndefinedConversionError from ASCII-8BIT to UTF-8 on to_json by doing String#scrub! (thanks @yoheimuta)
18 |
19 | ## 0.6.4 (2016/07/07)
20 |
21 | Enhancements:
22 |
23 | * Add `as_user` option (thanks @yacchin1205)
24 |
25 | ## 0.6.3 (2016/05/11)
26 |
27 | Enhancements:
28 |
29 | * Add `verbose_fallback` option to show fallback (popup) verbosely (thanks @eisuke)
30 |
31 | ## 0.6.2 (2015/12/17)
32 |
33 | Fixes:
34 |
35 | * escape special characters in message (thanks @fujiwara)
36 |
37 | ## 0.6.1 (2015/05/17)
38 |
39 | Fixes:
40 |
41 | * Support ruby 1.9.3
42 |
43 | ## 0.6.0 (2015/04/02)
44 |
45 | This version has impcompatibility with previous versions in default option values
46 |
47 | Enhancements:
48 |
49 | * Support `link_names` and `parse` option. `link_names` option is `true` as default
50 |
51 | Changes:
52 |
53 | * the default payload of Incoming Webhook was changed
54 | * `color` is `nil` as default
55 | * `icon_emoji` is `nil` as default
56 | * `username` is `nil` as default
57 | * `mrkdwn` is `true` as default
58 |
59 | ## 0.5.5 (2015/04/01)
60 |
61 | Enhancements:
62 |
63 | * Support Slackbot Remote Control API
64 |
65 | ## 0.5.4 (2015/03/31)
66 |
67 | Enhancements:
68 |
69 | * Support `mrkdwn` option
70 |
71 | ## 0.5.3 (2015/03/29)
72 |
73 | Enhancements:
74 |
75 | * Support `https_proxy` option
76 |
77 | ## 0.5.2 (2015/03/29)
78 |
79 | Enhancements:
80 |
81 | * Support `icon_url` option (thanks to @jwyjoy)
82 |
83 | ## 0.5.1 (2015/03/27)
84 |
85 | Enhancements:
86 |
87 | * Support `auto_channels_create` option to automatically create channels.
88 |
89 | ## 0.5.0 (2015/03/22)
90 |
91 | Enhancements:
92 |
93 | * Support `message` and `message_keys` options
94 | * Support `title` and `title_keys` options
95 | * Support `channel_keys` options to dynamically change channels
96 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gemspec
4 |
--------------------------------------------------------------------------------
/Gemfile.fluentd.0.12:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gemspec
4 | gem 'fluentd', '~> 0.12.0'
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # fluent-plugin-slack [](https://travis-ci.org/sowawa/fluent-plugin-slack)
2 |
3 | # Installation
4 |
5 | ```
6 | $ fluent-gem install fluent-plugin-slack
7 | ```
8 |
9 | # Usage (Incoming Webhook)
10 |
11 | ```apache
12 |
13 | @type slack
14 | webhook_url https://hooks.slack.com/services/XXX/XXX/XXX
15 | channel general
16 | username sowasowa
17 | icon_emoji :ghost:
18 | flush_interval 60s
19 |
20 | ```
21 |
22 | ```ruby
23 | fluent_logger.post('slack', {
24 | :message => 'Hello
World!'
25 | })
26 | ```
27 |
28 | # Usage (Slackbot)
29 |
30 | ```apache
31 |
32 | @type slack
33 | slackbot_url https://xxxx.slack.com/services/hooks/slackbot?token=XXXXXXXXX
34 | channel general
35 | flush_interval 60s
36 |
37 | ```
38 |
39 | ```ruby
40 | fluent_logger.post('slack', {
41 | :message => 'Hello
World!'
42 | })
43 | ```
44 |
45 | # Usage (Web API a.k.a. Bots)
46 |
47 | ```apache
48 |
49 | @type slack
50 | token xoxb-XXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX
51 | channel C061EG9SL
52 | username sowasowa
53 | icon_emoji :ghost:
54 | flush_interval 60s
55 |
56 | ```
57 |
58 | ```ruby
59 | fluent_logger.post('slack', {
60 | :message => 'Hello
World!'
61 | })
62 | ```
63 |
64 | ### Parameter
65 |
66 | |parameter|description|default|
67 | |---|---|---|
68 | |webhook_url|Incoming Webhook URI (Required for Incoming Webhook mode). See https://api.slack.com/incoming-webhooks||
69 | |slackbot_url|Slackbot URI (Required for Slackbot mode). See https://api.slack.com/slackbot. NOTE: most of optional parameters such as `username`, `color`, `icon_emoji`, `icon_url`, and `title` are not available for this mode, but Desktop Notification via Highlight Words works with only this mode||
70 | |token|Token for Web API (Required for Web API mode). See https://api.slack.com/web||
71 | |as_user|post messages as a bot user. See https://api.slack.com/bot-users#post_messages_and_react_to_users. NOTE: This parameter is only enabled if you use the Web API with your bot token. You cannot use both of `username` and `icon_emoji`(`icon_url`) when you set this parameter to `true`.|nil|
72 | |username|name of bot|nil|
73 | |color|color to use such as `good` or `bad`. See `Color` section of https://api.slack.com/docs/attachments. NOTE: This parameter must **not** be specified to receive Desktop Notification via Mentions in cases of Incoming Webhook and Slack Web API|nil|
74 | |icon_emoji|emoji to use as the icon. either of `icon_emoji` or `icon_url` can be specified|nil|
75 | |icon_url|url to an image to use as the icon. either of `icon_emoji` or `icon_url` can be specified|nil|
76 | |mrkdwn|enable formatting. see https://api.slack.com/docs/formatting|true|
77 | |link_names|find and link channel names and usernames. NOTE: This parameter must be `true` to receive Desktop Notification via Mentions in cases of Incoming Webhook and Slack Web API|true|
78 | |parse|change how messages are treated. `none` or `full` can be specified. See `Parsing mode` section of https://api.slack.com/docs/formatting|nil|
79 | |channel|Channel name or id to send messages (without first '#'). Channel ID is recommended because it is unchanged even if a channel is renamed||
80 | |channel_keys|keys used to format channel. %s will be replaced with value specified by channel_keys if this option is used|nil|
81 | |title|title format. %s will be replaced with value specified by title_keys. title is created from the first appeared record on each tag. NOTE: This parameter must **not** be specified to receive Desktop Notification via Mentions in cases of Incoming Webhook and Slack Web API|nil|
82 | |title_keys|keys used to format the title|nil|
83 | |message|message format. %s will be replaced with value specified by message_keys|%s|
84 | |message_keys|keys used to format messages|message|
85 | |auto_channels_create|Create channels if not exist. Not available for Incoming Webhook mode (since Incoming Webhook is specific to a channel). A web api `token` for Normal User is required (Bot User can not create channels. See https://api.slack.com/bot-users)|false|
86 | |https_proxy|https proxy url such as `https://proxy.foo.bar:443`|nil|
87 | |verbose_fallback|Originally, only `title` is used for the fallback which is the message shown on popup if `title` is given. If this option is set to be `true`, messages are also included to the fallback attribute|false|
88 |
89 | `fluent-plugin-slack` uses `SetTimeKeyMixin` and `SetTagKeyMixin`, so you can also use:
90 |
91 | |parameter|description|default|
92 | |---|---|---|
93 | |timezone|timezone such as `Asia/Tokyo`||
94 | |localtime|use localtime as timezone|true|
95 | |utc|use utc as timezone||
96 | |time_key|key name for time used in xxx_keys|time|
97 | |time_format|time format. This will be formatted with Time#strftime.|%H:%M:%S|
98 | |tag_key|key name for tag used in xxx_keys|tag|
99 |
100 | `fluent-plugin-slack` is a kind of BufferedOutput plugin, so you can also use [Buffer Parameters](http://docs.fluentd.org/articles/out_exec#buffer-parameters).
101 |
102 | ## FAQ
103 |
104 | ### Desktop Notification seems not working?
105 |
106 | Currently, slack.com has following limitations:
107 |
108 | 1. Desktop Notification via both Highlight Words and Mentions works only with Slackbot Remote Control
109 | 2. Desktop Notification via Mentions works for the `text` field if `link_names` parameter is specified in cases of Incoming Webhook and Slack Web API, that is,
110 | * Desktop Notification does not work for the `attachments` filed (used in `color` and `title`)
111 | * Desktop Notification via Highlight Words does not work for Incoming Webhook and Slack Web API anyway
112 |
113 | ## ChangeLog
114 |
115 | See [CHANGELOG.md](CHANGELOG.md) for details.
116 |
117 | # Contributors
118 |
119 | - [@sonots](https://github.com/sonots)
120 | - [@kenjiskywalker](https://github.com/kenjiskywalker)
121 |
122 | # Copyright
123 |
124 | * Copyright:: Copyright (c) 2014- Keisuke SOGAWA
125 | * License:: Apache License, Version 2.0
126 |
127 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env rake
2 | require "bundler/gem_tasks"
3 |
4 | require 'rake/testtask'
5 | Rake::TestTask.new(:test) do |test|
6 | test.libs << 'lib' << 'test'
7 | test.pattern = 'test/**/test_*.rb'
8 | test.verbose = true
9 | end
10 | task :default => :test
11 |
12 | desc 'Open an irb session preloaded with the gem library'
13 | task :console do
14 | sh 'irb -rubygems -I lib'
15 | end
16 | task :c => :console
17 |
--------------------------------------------------------------------------------
/VERSION:
--------------------------------------------------------------------------------
1 | 0.6.7
2 |
--------------------------------------------------------------------------------
/example.conf:
--------------------------------------------------------------------------------
1 |
2 | @type forward
3 |
4 |
5 |
6 | @type slack
7 | token "#{ENV['TOKEN']}"
8 | username fluentd
9 | color good
10 | icon_emoji :ghost: # if you want to use icon_url, delete this param.
11 | #icon_url http://www.google.com/s2/favicons?domain=www.google.de
12 | channel general
13 | message %s %s
14 | message_keys tag,message
15 | title %s %s
16 | title_keys tag,message
17 | flush_interval 1s # slack API has limit as a post / sec
18 |
19 |
--------------------------------------------------------------------------------
/fluent-plugin-slack.gemspec:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | $:.push File.expand_path('../lib', __FILE__)
3 |
4 | Gem::Specification.new do |gem|
5 | gem.name = "fluent-plugin-slack"
6 | gem.description = "fluent Slack plugin"
7 | gem.homepage = "https://github.com/sowawa/fluent-plugin-slack"
8 | gem.license = "Apache-2.0"
9 | gem.summary = gem.description
10 | gem.version = File.read("VERSION").strip
11 | gem.authors = ["Keisuke SOGAWA", "Naotoshi Seo"]
12 | gem.email = ["keisuke.sogawa@gmail.com", "sonots@gmail.com"]
13 | gem.has_rdoc = false
14 | gem.files = `git ls-files`.split("\n")
15 | gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
16 | gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
17 | gem.require_paths = ['lib']
18 |
19 | gem.add_dependency "fluentd", ">= 0.12.0"
20 |
21 | gem.add_development_dependency "rake", ">= 10.1.1"
22 | gem.add_development_dependency "rr", ">= 1.0.0"
23 | gem.add_development_dependency "pry"
24 | gem.add_development_dependency "pry-nav"
25 | gem.add_development_dependency "test-unit", "~> 3.0.2"
26 | gem.add_development_dependency "test-unit-rr", "~> 1.0.3"
27 | gem.add_development_dependency "dotenv"
28 | end
29 |
--------------------------------------------------------------------------------
/lib/fluent/plugin/out_buffered_slack.rb:
--------------------------------------------------------------------------------
1 | out_slack.rb
--------------------------------------------------------------------------------
/lib/fluent/plugin/out_slack.rb:
--------------------------------------------------------------------------------
1 | require_relative 'slack_client'
2 |
3 | module Fluent
4 | class SlackOutput < Fluent::BufferedOutput
5 | Fluent::Plugin.register_output('buffered_slack', self) # old version compatiblity
6 | Fluent::Plugin.register_output('slack', self)
7 |
8 | # For fluentd v0.12.16 or earlier
9 | class << self
10 | unless method_defined?(:desc)
11 | def desc(description)
12 | end
13 | end
14 | end
15 |
16 | include SetTimeKeyMixin
17 | include SetTagKeyMixin
18 |
19 | config_set_default :include_time_key, true
20 | config_set_default :include_tag_key, true
21 |
22 | desc <<-DESC
23 | Incoming Webhook URI (Required for Incoming Webhook mode).
24 | See: https://api.slack.com/incoming-webhooks
25 | DESC
26 | config_param :webhook_url, :string, default: nil
27 | desc <<-DESC
28 | Slackbot URI (Required for Slackbot mode).
29 | See https://api.slack.com/slackbot.
30 | NOTE: most of optional parameters such as `username`, `color`, `icon_emoji`,
31 | `icon_url`, and `title` are not available for this mode, but Desktop Notification
32 | via Highlight Words works with only this mode.
33 | DESC
34 | config_param :slackbot_url, :string, default: nil
35 | desc <<-DESC
36 | Token for Web API (Required for Web API mode). See: https://api.slack.com/web.
37 | DESC
38 | config_param :token, :string, default: nil
39 | desc "Name of bot."
40 | config_param :username, :string, default: nil
41 | desc <<-DESC
42 | Color to use such as `good` or `bad`.
43 | See Color section of https://api.slack.com/docs/attachments.
44 | NOTE: This parameter must not be specified to receive Desktop Notification
45 | via Mentions in cases of Incoming Webhook and Slack Web API.
46 | DESC
47 | config_param :color, :string, default: nil
48 | desc <<-DESC
49 | Emoji to use as the icon.
50 | Either of `icon_emoji` or `icon_url` can be specified.
51 | DESC
52 | config_param :as_user, :bool, default: nil
53 | desc <<-DESC
54 | Post message as the authenticated user.
55 | NOTE: This parameter is only enabled if you use the Web API with your bot token.
56 | You cannot use both of `username` and `icon_emoji`(`icon_url`) when
57 | you set this parameter to `true`.
58 | DESC
59 | config_param :icon_emoji, :string, default: nil
60 | desc <<-DESC
61 | Url to an image to use as the icon.
62 | Either of `icon_emoji` or `icon_url` can be specified.
63 | DESC
64 | config_param :icon_url, :string, default: nil
65 | desc "Enable formatting. See: https://api.slack.com/docs/formatting."
66 | config_param :mrkdwn, :bool, default: true
67 | desc <<-DESC
68 | Find and link channel names and usernames.
69 | NOTE: This parameter must be `true` to receive Desktop Notification
70 | via Mentions in cases of Incoming Webhook and Slack Web API.
71 | DESC
72 | config_param :link_names, :bool, default: true
73 | desc <<-DESC
74 | Change how messages are treated. `none` or `full` can be specified.
75 | See Parsing mode section of https://api.slack.com/docs/formatting.
76 | DESC
77 | config_param :parse, :string, default: nil
78 | desc <<-DESC
79 | Create channels if not exist. Not available for Incoming Webhook mode
80 | (since Incoming Webhook is specific to a channel).
81 | A web api token for Normal User is required.
82 | (Bot User can not create channels. See https://api.slack.com/bot-users)
83 | DESC
84 | config_param :auto_channels_create, :bool, default: false
85 | desc "https proxy url such as https://proxy.foo.bar:443"
86 | config_param :https_proxy, :string, default: nil
87 |
88 | desc "channel to send messages (without first '#')."
89 | config_param :channel, :string, default: nil
90 | desc <<-DESC
91 | Keys used to format channel.
92 | %s will be replaced with value specified by channel_keys if this option is used.
93 | DESC
94 | config_param :channel_keys, default: nil do |val|
95 | val.split(',')
96 | end
97 | desc <<-DESC
98 | Title format.
99 | %s will be replaced with value specified by title_keys.
100 | Title is created from the first appeared record on each tag.
101 | NOTE: This parameter must **not** be specified to receive Desktop Notification
102 | via Mentions in cases of Incoming Webhook and Slack Web API.
103 | DESC
104 | config_param :title, :string, default: nil
105 | desc "Keys used to format the title."
106 | config_param :title_keys, default: nil do |val|
107 | val.split(',')
108 | end
109 | desc <<-DESC
110 | Message format.
111 | %s will be replaced with value specified by message_keys.
112 | DESC
113 | config_param :message, :string, default: nil
114 | desc "Keys used to format messages."
115 | config_param :message_keys, default: nil do |val|
116 | val.split(',')
117 | end
118 |
119 | desc "Include messages to the fallback attributes"
120 | config_param :verbose_fallback, :bool, default: false
121 |
122 | # for test
123 | attr_reader :slack, :time_format, :localtime, :timef, :mrkdwn_in, :post_message_opts
124 |
125 | def initialize
126 | super
127 | require 'uri'
128 | end
129 |
130 | def configure(conf)
131 | conf['time_format'] ||= '%H:%M:%S' # old version compatiblity
132 | conf['localtime'] ||= true unless conf['utc']
133 |
134 | super
135 |
136 | if @channel
137 | @channel = URI.unescape(@channel) # old version compatibility
138 | if !@channel.start_with?('#') and !@channel.start_with?('@')
139 | @channel = '#' + @channel # Add # since `#` is handled as a comment in fluentd conf
140 | end
141 | end
142 |
143 | if @webhook_url
144 | if @webhook_url.empty?
145 | raise Fluent::ConfigError.new("`webhook_url` is an empty string")
146 | end
147 | unless @as_user.nil?
148 | log.warn "out_slack: `as_user` parameter are not available for Incoming Webhook"
149 | end
150 | @slack = Fluent::SlackClient::IncomingWebhook.new(@webhook_url)
151 | elsif @slackbot_url
152 | if @slackbot_url.empty?
153 | raise Fluent::ConfigError.new("`slackbot_url` is an empty string")
154 | end
155 | if @channel.nil?
156 | raise Fluent::ConfigError.new("`channel` parameter required for Slackbot Remote Control")
157 | end
158 |
159 | if @username or @color or @icon_emoji or @icon_url
160 | log.warn "out_slack: `username`, `color`, `icon_emoji`, `icon_url` parameters are not available for Slackbot Remote Control"
161 | end
162 | unless @as_user.nil?
163 | log.warn "out_slack: `as_user` parameter are not available for Slackbot Remote Control"
164 | end
165 | @slack = Fluent::SlackClient::Slackbot.new(@slackbot_url)
166 | elsif @token
167 | if @token.empty?
168 | raise Fluent::ConfigError.new("`token` is an empty string")
169 | end
170 | if @channel.nil?
171 | raise Fluent::ConfigError.new("`channel` parameter required for Slack WebApi")
172 | end
173 |
174 | @slack = Fluent::SlackClient::WebApi.new
175 | else
176 | raise Fluent::ConfigError.new("One of `webhook_url` or `slackbot_url`, or `token` is required")
177 | end
178 | @slack.log = log
179 | @slack.debug_dev = log.out if log.level <= Fluent::Log::LEVEL_TRACE
180 |
181 | if @https_proxy
182 | @slack.https_proxy = @https_proxy
183 | end
184 |
185 | @message ||= '%s'
186 | @message_keys ||= %w[message]
187 | begin
188 | @message % (['1'] * @message_keys.length)
189 | rescue ArgumentError
190 | raise Fluent::ConfigError, "string specifier '%s' for `message` and `message_keys` specification mismatch"
191 | end
192 | if @title and @title_keys
193 | begin
194 | @title % (['1'] * @title_keys.length)
195 | rescue ArgumentError
196 | raise Fluent::ConfigError, "string specifier '%s' for `title` and `title_keys` specification mismatch"
197 | end
198 | end
199 | if @channel && @channel_keys
200 | begin
201 | @channel % (['1'] * @channel_keys.length)
202 | rescue ArgumentError
203 | raise Fluent::ConfigError, "string specifier '%s' for `channel` and `channel_keys` specification mismatch"
204 | end
205 | end
206 |
207 | if @icon_emoji and @icon_url
208 | raise Fluent::ConfigError, "either of `icon_emoji` or `icon_url` can be specified"
209 | end
210 |
211 | if @as_user and (@icon_emoji or @icon_url or @username)
212 | raise Fluent::ConfigError, "`username`, `icon_emoji` and `icon_url` cannot be specified when `as_user` is set to true"
213 | end
214 |
215 | if @mrkdwn
216 | # Enable markdown for attachments. See https://api.slack.com/docs/formatting
217 | @mrkdwn_in = %w[text fields]
218 | end
219 |
220 | if @parse and !%w[none full].include?(@parse)
221 | raise Fluent::ConfigError, "`parse` must be either of `none` or `full`"
222 | end
223 |
224 | @post_message_opts = {}
225 | if @auto_channels_create
226 | raise Fluent::ConfigError, "`token` parameter is required to use `auto_channels_create`" unless @token
227 | @post_message_opts = {auto_channels_create: true}
228 | end
229 | end
230 |
231 | def format(tag, time, record)
232 | [tag, time, record].to_msgpack
233 | end
234 |
235 | def write(chunk)
236 | begin
237 | payloads = build_payloads(chunk)
238 | payloads.each {|payload| @slack.post_message(payload, @post_message_opts) }
239 | rescue Timeout::Error => e
240 | log.warn "out_slack:", :error => e.to_s, :error_class => e.class.to_s
241 | raise e # let Fluentd retry
242 | rescue => e
243 | log.error "out_slack:", :error => e.to_s, :error_class => e.class.to_s
244 | log.warn_backtrace e.backtrace
245 | # discard. @todo: add more retriable errors
246 | end
247 | end
248 |
249 | private
250 |
251 | def build_payloads(chunk)
252 | if @title
253 | build_title_payloads(chunk)
254 | elsif @color
255 | build_color_payloads(chunk)
256 | else
257 | build_plain_payloads(chunk)
258 | end
259 | end
260 |
261 | def common_payload
262 | return @common_payload if @common_payload
263 | @common_payload = {}
264 | @common_payload[:as_user] = @as_user unless @as_user.nil?
265 | @common_payload[:username] = @username if @username
266 | @common_payload[:icon_emoji] = @icon_emoji if @icon_emoji
267 | @common_payload[:icon_url] = @icon_url if @icon_url
268 | @common_payload[:mrkdwn] = @mrkdwn if @mrkdwn
269 | @common_payload[:link_names] = @link_names if @link_names
270 | @common_payload[:parse] = @parse if @parse
271 | @common_payload[:token] = @token if @token
272 | @common_payload
273 | end
274 |
275 | def common_attachment
276 | return @common_attachment if @common_attachment
277 | @common_attachment = {}
278 | @common_attachment[:color] = @color if @color
279 | @common_attachment[:mrkdwn_in] = @mrkdwn_in if @mrkdwn_in
280 | @common_attachment
281 | end
282 |
283 | Field = Struct.new("Field", :title, :value)
284 | # ruby 1.9.x does not provide #to_h
285 | Field.send(:define_method, :to_h) { {title: title, value: value} }
286 |
287 | def build_title_payloads(chunk)
288 | ch_fields = {}
289 | chunk.msgpack_each do |tag, time, record|
290 | channel = build_channel(record)
291 | per = tag # title per tag
292 | ch_fields[channel] ||= {}
293 | ch_fields[channel][per] ||= Field.new(build_title(record), '')
294 | ch_fields[channel][per].value << "#{build_message(record)}\n"
295 | end
296 | ch_fields.map do |channel, fields|
297 | fallback_text = if @verbose_fallback
298 | fields.values.map { |f| "#{f.title} #{f.value}" }.join(' ')
299 | else
300 | fields.values.map(&:title).join(' ')
301 | end
302 |
303 | msg = {
304 | attachments: [{
305 | :fallback => fallback_text, # fallback is the message shown on popup
306 | :fields => fields.values.map(&:to_h)
307 | }.merge(common_attachment)],
308 | }
309 | msg.merge!(channel: channel) if channel
310 | msg.merge!(common_payload)
311 | end
312 | end
313 |
314 | def build_color_payloads(chunk)
315 | messages = {}
316 | chunk.msgpack_each do |tag, time, record|
317 | channel = build_channel(record)
318 | messages[channel] ||= ''
319 | messages[channel] << "#{build_message(record)}\n"
320 | end
321 | messages.map do |channel, text|
322 | msg = {
323 | attachments: [{
324 | :fallback => text,
325 | :text => text,
326 | }.merge(common_attachment)],
327 | }
328 | msg.merge!(channel: channel) if channel
329 | msg.merge!(common_payload)
330 | end
331 | end
332 |
333 | def build_plain_payloads(chunk)
334 | messages = {}
335 | chunk.msgpack_each do |tag, time, record|
336 | channel = build_channel(record)
337 | messages[channel] ||= ''
338 | messages[channel] << "#{build_message(record)}\n"
339 | end
340 | messages.map do |channel, text|
341 | msg = {text: text}
342 | msg.merge!(channel: channel) if channel
343 | msg.merge!(common_payload)
344 | end
345 | end
346 |
347 | def build_message(record)
348 | values = fetch_keys(record, @message_keys)
349 | @message % values
350 | end
351 |
352 | def build_title(record)
353 | return @title unless @title_keys
354 |
355 | values = fetch_keys(record, @title_keys)
356 | @title % values
357 | end
358 |
359 | def build_channel(record)
360 | return nil if @channel.nil?
361 | return @channel unless @channel_keys
362 |
363 | values = fetch_keys(record, @channel_keys)
364 | @channel % values
365 | end
366 |
367 | def fetch_keys(record, keys)
368 | Array(keys).map do |key|
369 | begin
370 | record.fetch(key).to_s
371 | rescue KeyError
372 | log.warn "out_slack: the specified key '#{key}' not found in record. [#{record}]"
373 | ''
374 | end
375 | end
376 | end
377 | end
378 | end
379 |
--------------------------------------------------------------------------------
/lib/fluent/plugin/slack_client.rb:
--------------------------------------------------------------------------------
1 | require 'uri'
2 | require 'net/http'
3 | require 'net/https'
4 | require 'logger'
5 | require_relative 'slack_client/error'
6 |
7 | module Fluent
8 | module SlackClient
9 | # The base framework of slack client
10 | class Base
11 | attr_accessor :log, :debug_dev
12 | attr_reader :endpoint, :https_proxy
13 |
14 | # @param [String] endpoint
15 | #
16 | # (Incoming Webhook) required
17 | # https://hooks.slack.com/services/XXX/XXX/XXX
18 | #
19 | # (Slackbot) required
20 | # https://xxxx.slack.com/services/hooks/slackbot?token=XXXXX
21 | #
22 | # (Web API) optional and default to be
23 | # https://slack.com/api/
24 | #
25 | # @param [String] https_proxy (optional)
26 | #
27 | # https://proxy.foo.bar:port
28 | #
29 | def initialize(endpoint = nil, https_proxy = nil)
30 | self.endpoint = endpoint if endpoint
31 | self.https_proxy = https_proxy if https_proxy
32 | @log = Logger.new('/dev/null')
33 | end
34 |
35 | def endpoint=(endpoint)
36 | @endpoint = URI.parse(endpoint)
37 | end
38 |
39 | def https_proxy=(https_proxy)
40 | @https_proxy = URI.parse(https_proxy)
41 | @proxy_class = Net::HTTP.Proxy(@https_proxy.host, @https_proxy.port)
42 | end
43 |
44 | def proxy_class
45 | @proxy_class ||= Net::HTTP
46 | end
47 |
48 | def post(endpoint, params)
49 | http = proxy_class.new(endpoint.host, endpoint.port)
50 | http.use_ssl = (endpoint.scheme == 'https')
51 | http.verify_mode = OpenSSL::SSL::VERIFY_NONE
52 | http.set_debug_output(debug_dev) if debug_dev
53 |
54 | req = Net::HTTP::Post.new(endpoint.request_uri)
55 | req['Host'] = endpoint.host
56 | req['Accept'] = 'application/json; charset=utf-8'
57 | req['User-Agent'] = 'fluent-plugin-slack'
58 | req.body = encode_body(params)
59 |
60 | res = http.request(req)
61 | response_check(res, params)
62 | end
63 |
64 | private
65 |
66 | def encode_body(params)
67 | raise NotImplementedError
68 | end
69 |
70 | def response_check(res, params)
71 | if res.code != "200"
72 | raise Error.new(res, params)
73 | end
74 | end
75 |
76 | def filter_params(params)
77 | params.dup.tap {|p| p[:token] = '[FILTERED]' if p[:token] }
78 | end
79 |
80 | # Required to implement to use #with_channels_create
81 | # channels.create API is available from only Slack Web API
82 | def api
83 | raise NotImplementedError
84 | end
85 |
86 | def with_channels_create(params = {}, opts = {})
87 | retries = 1
88 | begin
89 | yield
90 | rescue ChannelNotFoundError => e
91 | if params[:token] and opts[:auto_channels_create]
92 | log.warn "out_slack: channel \"#{params[:channel]}\" is not found. try to create the channel, and then retry to post the message."
93 | api.channels_create({name: params[:channel], token: params[:token]})
94 | retry if (retries -= 1) >= 0 # one time retry
95 | else
96 | raise e
97 | end
98 | end
99 | end
100 |
101 | def to_json_with_scrub!(params)
102 | retries = 1
103 | begin
104 | params.to_json
105 | rescue Encoding::UndefinedConversionError => e
106 | recursive_scrub!(params)
107 | if (retries -= 1) >= 0 # one time retry
108 | log.warn "out_slack: to_json `#{params}` failed. retry after scrub!. #{e.message}"
109 | retry
110 | else
111 | raise e
112 | end
113 | end
114 | end
115 |
116 | def recursive_scrub!(params)
117 | case params
118 | when Hash
119 | params.each {|k, v| recursive_scrub!(v)}
120 | when Array
121 | params.each {|elm| recursive_scrub!(elm)}
122 | when String
123 | params.force_encoding(Encoding::UTF_8) if params.encoding == Encoding::ASCII_8BIT
124 | params.scrub!('?') if params.respond_to?(:scrub!)
125 | else
126 | params
127 | end
128 | end
129 | end
130 |
131 | # Slack client for Incoming Webhook
132 | # https://api.slack.com/incoming-webhooks
133 | class IncomingWebhook < Base
134 | def initialize(endpoint, https_proxy = nil)
135 | super
136 | end
137 |
138 | def post_message(params = {}, opts = {})
139 | log.info { "out_slack: post_message #{params}" }
140 | post(endpoint, params)
141 | end
142 |
143 | private
144 |
145 | def encode_body(params = {})
146 | # https://api.slack.com/docs/formatting
147 | to_json_with_scrub!(params).gsub(/&/, '&').gsub(/, '<').gsub(/>/, '>')
148 | end
149 |
150 | def response_check(res, params)
151 | super
152 | unless res.body == 'ok'
153 | raise Error.new(res, params)
154 | end
155 | end
156 | end
157 |
158 | # Slack client for Slackbot Remote Control
159 | # https://api.slack.com/slackbot
160 | class Slackbot < Base
161 | def initialize(endpoint, https_proxy = nil)
162 | super
163 | end
164 |
165 | def api
166 | @api ||= WebApi.new(nil, https_proxy)
167 | end
168 |
169 | def post_message(params = {}, opts = {})
170 | raise ArgumentError, "channel parameter is required" unless params[:channel]
171 | with_channels_create(params, opts) do
172 | log.info { "out_slack: post_message #{filter_params(params)}" }
173 | post(slackbot_endpoint(params), params)
174 | end
175 | end
176 |
177 | private
178 |
179 | def slackbot_endpoint(params)
180 | endpoint.dup.tap {|e| e.query += "&channel=#{URI.encode(params[:channel])}" }
181 | end
182 |
183 | def encode_body(params = {})
184 | return params[:text]if params[:text]
185 | unless params[:attachments]
186 | raise ArgumentError, 'params[:text] or params[:attachments] is required'
187 | end
188 | # handle params[:attachments]
189 | attachment = Array(params[:attachments]).first # see only the first for now
190 | # {
191 | # attachments: [{
192 | # text: "HERE",
193 | # }]
194 | # }
195 | text = attachment[:text]
196 | # {
197 | # attachments: [{
198 | # fields: [{
199 | # title: "title",
200 | # value: "HERE",
201 | # }]
202 | # }]
203 | # }
204 | if text.nil? and attachment[:fields]
205 | text = Array(attachment[:fields]).first[:value] # see only the first for now
206 | end
207 | text
208 | end
209 |
210 | def response_check(res, params)
211 | if res.body == 'channel_not_found'
212 | raise ChannelNotFoundError.new(res, params)
213 | elsif res.body != 'ok'
214 | raise Error.new(res, params)
215 | end
216 | end
217 | end
218 |
219 | # Slack client for Web API
220 | class WebApi < Base
221 | DEFAULT_ENDPOINT = "https://slack.com/api/".freeze
222 |
223 | def api
224 | self
225 | end
226 |
227 | def endpoint
228 | @endpoint ||= URI.parse(DEFAULT_ENDPOINT)
229 | end
230 |
231 | def post_message_endpoint
232 | @post_message_endpoint ||= URI.join(endpoint, "chat.postMessage")
233 | end
234 |
235 | def channels_create_endpoint
236 | @channels_create_endpoint ||= URI.join(endpoint, "channels.create")
237 | end
238 |
239 | # Sends a message to a channel.
240 | #
241 | # @see https://api.slack.com/methods/chat.postMessage
242 | # @see https://github.com/slackhq/slack-api-docs/blob/master/methods/chat.postMessage.md
243 | # @see https://github.com/slackhq/slack-api-docs/blob/master/methods/chat.postMessage.json
244 | def post_message(params = {}, opts = {})
245 | with_channels_create(params, opts) do
246 | log.info { "out_slack: post_message #{filter_params(params)}" }
247 | post(post_message_endpoint, params)
248 | end
249 | end
250 |
251 | # Creates a channel.
252 | #
253 | # NOTE: Bot user can not create a channel. Token must be issued by Normal User Account
254 | # @see https://api.slack.com/bot-users
255 | #
256 | # @see https://api.slack.com/methods/channels.create
257 | # @see https://github.com/slackhq/slack-api-docs/blob/master/methods/channels.create.md
258 | # @see https://github.com/slackhq/slack-api-docs/blob/master/methods/channels.create.json
259 | def channels_create(params = {}, opts = {})
260 | log.info { "out_slack: channels_create #{filter_params(params)}" }
261 | post(channels_create_endpoint, params)
262 | end
263 |
264 | private
265 |
266 | def encode_body(params = {})
267 | body = params.dup
268 | if params[:attachments]
269 | body[:attachments] = to_json_with_scrub!(params[:attachments])
270 | end
271 | URI.encode_www_form(body)
272 | end
273 |
274 | def response_check(res, params)
275 | super
276 | res_params = JSON.parse(res.body)
277 | return if res_params['ok']
278 | case res_params['error']
279 | when 'channel_not_found'
280 | raise ChannelNotFoundError.new(res, params)
281 | when 'name_taken'
282 | raise NameTakenError.new(res, params)
283 | else
284 | raise Error.new(res, params)
285 | end
286 | end
287 | end
288 | end
289 | end
290 |
--------------------------------------------------------------------------------
/lib/fluent/plugin/slack_client/error.rb:
--------------------------------------------------------------------------------
1 | require 'net/http'
2 |
3 | module Fluent
4 | module SlackClient
5 | class Error < StandardError
6 | attr_reader :res, :req_params
7 |
8 | def initialize(res, req_params = {})
9 | @res = res
10 | @req_params = req_params.dup
11 | end
12 |
13 | def message
14 | @req_params[:token] = '[FILTERED]' if @req_params[:token]
15 | "res.code:#{@res.code}, res.body:#{@res.body}, req_params:#{@req_params}"
16 | end
17 |
18 | alias :to_s :message
19 | end
20 |
21 | class ChannelNotFoundError < Error; end
22 | class NameTakenError < Error; end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | echo '{"message":"message"}' | bundle exec fluent-cat tag
3 |
--------------------------------------------------------------------------------
/test/plugin/test_out_slack.rb:
--------------------------------------------------------------------------------
1 | require_relative '../test_helper'
2 | require 'fluent/plugin/out_slack'
3 | require 'time'
4 |
5 | class SlackOutputTest < Test::Unit::TestCase
6 |
7 | def setup
8 | super
9 | Fluent::Test.setup
10 | @icon_url = 'http://www.google.com/s2/favicons?domain=www.google.de'
11 | end
12 |
13 | CONFIG = %[
14 | channel channel
15 | webhook_url https://hooks.slack.com/services/XXXX/XXXX/XXX
16 | ]
17 |
18 | def default_payload
19 | {
20 | channel: '#channel',
21 | mrkdwn: true,
22 | link_names: true,
23 | }
24 | end
25 |
26 | def default_attachment
27 | {
28 | mrkdwn_in: %w[text fields],
29 | }
30 | end
31 |
32 | def create_driver(conf = CONFIG)
33 | Fluent::Test::BufferedOutputTestDriver.new(Fluent::SlackOutput).configure(conf)
34 | end
35 |
36 | # old version compatibility with v0.4.0"
37 | def test_old_config
38 | # default check
39 | d = create_driver
40 | assert_equal true, d.instance.localtime
41 | assert_equal nil, d.instance.username # 'fluentd' break lower version compatibility
42 | assert_equal nil, d.instance.color # 'good' break lower version compatibility
43 | assert_equal nil, d.instance.icon_emoji # ':question:' break lower version compatibility
44 | assert_equal nil, d.instance.icon_url
45 | assert_equal true, d.instance.mrkdwn
46 | assert_equal true, d.instance.link_names
47 | assert_equal nil, d.instance.parse
48 |
49 | assert_nothing_raised do
50 | create_driver(CONFIG + %[api_key testtoken])
51 | end
52 |
53 | # incoming webhook endpoint was changed. team option should be ignored
54 | assert_nothing_raised do
55 | create_driver(CONFIG + %[team sowasowa])
56 | end
57 |
58 | # rtm? it was not calling `rtm.start`. rtm option was removed and should be ignored
59 | assert_nothing_raised do
60 | create_driver(CONFIG + %[rtm true])
61 | end
62 |
63 | # channel should be URI.unescape-ed
64 | d = create_driver(CONFIG + %[channel %23test])
65 | assert_equal '#test', d.instance.channel
66 |
67 | # timezone should work
68 | d = create_driver(CONFIG + %[timezone Asia/Tokyo])
69 | assert_equal 'Asia/Tokyo', d.instance.timezone
70 | end
71 |
72 | def test_configure
73 | d = create_driver(%[
74 | channel channel
75 | time_format %Y/%m/%d %H:%M:%S
76 | username username
77 | color bad
78 | icon_emoji :ghost:
79 | token XX-XX-XX
80 | title slack notice!
81 | message %s
82 | message_keys message
83 | ])
84 | assert_equal '#channel', d.instance.channel
85 | assert_equal '%Y/%m/%d %H:%M:%S', d.instance.time_format
86 | assert_equal 'username', d.instance.username
87 | assert_equal 'bad', d.instance.color
88 | assert_equal ':ghost:', d.instance.icon_emoji
89 | assert_equal 'XX-XX-XX', d.instance.token
90 | assert_equal '%s', d.instance.message
91 | assert_equal ['message'], d.instance.message_keys
92 |
93 | # Allow DM
94 | d = create_driver(CONFIG + %[channel @test])
95 | assert_equal '@test', d.instance.channel
96 |
97 | assert_raise(Fluent::ConfigError) do
98 | create_driver(CONFIG + %[title %s %s\ntitle_keys foo])
99 | end
100 |
101 | assert_raise(Fluent::ConfigError) do
102 | create_driver(CONFIG + %[message %s %s\nmessage_keys foo])
103 | end
104 |
105 | assert_raise(Fluent::ConfigError) do
106 | create_driver(CONFIG + %[channel %s %s\nchannel_keys foo])
107 | end
108 | end
109 |
110 | def test_slack_configure
111 | # One of webhook_url or slackbot_url, or token is required
112 | assert_raise(Fluent::ConfigError) do
113 | create_driver(%[channel foo])
114 | end
115 |
116 | # webhook_url is an empty string
117 | assert_raise(Fluent::ConfigError) do
118 | create_driver(%[channel foo\nwebhook_url])
119 | end
120 |
121 | # webhook without channel (it works because webhook has a default channel)
122 | assert_nothing_raised do
123 | create_driver(%[webhook_url https://example.com/path/to/webhook])
124 | end
125 |
126 | # slackbot_url is an empty string
127 | assert_raise(Fluent::ConfigError) do
128 | create_driver(%[channel foo\nslackbot_url])
129 | end
130 |
131 | # slackbot without channel
132 | assert_raise(Fluent::ConfigError) do
133 | create_driver(%[slackbot_url https://example.com/path/to/slackbot])
134 | end
135 |
136 | # token is an empty string
137 | assert_raise(Fluent::ConfigError) do
138 | create_driver(%[channel foo\ntoken])
139 | end
140 |
141 | # slack webapi token without channel
142 | assert_raise(Fluent::ConfigError) do
143 | create_driver(%[token some_token])
144 | end
145 | end
146 |
147 | def test_timezone_configure
148 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
149 |
150 | d = create_driver(CONFIG + %[localtime])
151 | with_timezone('Asia/Tokyo') do
152 | assert_equal true, d.instance.localtime
153 | assert_equal "07:00:00", d.instance.timef.format(time)
154 | end
155 |
156 | d = create_driver(CONFIG + %[utc])
157 | with_timezone('Asia/Tokyo') do
158 | assert_equal false, d.instance.localtime
159 | assert_equal "22:00:00", d.instance.timef.format(time)
160 | end
161 |
162 | d = create_driver(CONFIG + %[timezone Asia/Taipei])
163 | with_timezone('Asia/Tokyo') do
164 | assert_equal "Asia/Taipei", d.instance.timezone
165 | assert_equal "06:00:00", d.instance.timef.format(time)
166 | end
167 | end
168 |
169 | def test_time_format_configure
170 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
171 |
172 | d = create_driver(CONFIG + %[time_format %Y/%m/%d %H:%M:%S])
173 | with_timezone('Asia/Tokyo') do
174 | assert_equal "2014/01/02 07:00:00", d.instance.timef.format(time)
175 | end
176 | end
177 |
178 | def test_buffer_configure
179 | assert_nothing_raised do
180 | create_driver(CONFIG + %[buffer_type file\nbuffer_path tmp/])
181 | end
182 | end
183 |
184 | def test_icon_configure
185 | # default
186 | d = create_driver(CONFIG)
187 | assert_equal nil, d.instance.icon_emoji
188 | assert_equal nil, d.instance.icon_url
189 |
190 | # either of icon_emoji or icon_url can be specified
191 | assert_raise(Fluent::ConfigError) do
192 | d = create_driver(CONFIG + %[icon_emoji :ghost:\nicon_url #{@icon_url}])
193 | end
194 |
195 | # icon_emoji
196 | d = create_driver(CONFIG + %[icon_emoji :ghost:])
197 | assert_equal ':ghost:', d.instance.icon_emoji
198 | assert_equal nil, d.instance.icon_url
199 |
200 | # icon_url
201 | d = create_driver(CONFIG + %[icon_url #{@icon_url}])
202 | assert_equal nil, d.instance.icon_emoji
203 | assert_equal @icon_url, d.instance.icon_url
204 | end
205 |
206 | def test_link_names_configure
207 | # default
208 | d = create_driver(CONFIG)
209 | assert_equal true, d.instance.link_names
210 |
211 | # true
212 | d = create_driver(CONFIG + %[link_names true])
213 | assert_equal true, d.instance.link_names
214 |
215 | # false
216 | d = create_driver(CONFIG + %[link_names false])
217 | assert_equal false, d.instance.link_names
218 | end
219 |
220 | def test_parse_configure
221 | # default
222 | d = create_driver(CONFIG)
223 | assert_equal nil, d.instance.parse
224 |
225 | # none
226 | d = create_driver(CONFIG + %[parse none])
227 | assert_equal 'none', d.instance.parse
228 |
229 | # full
230 | d = create_driver(CONFIG + %[parse full])
231 | assert_equal 'full', d.instance.parse
232 |
233 | # invalid
234 | assert_raise(Fluent::ConfigError) do
235 | d = create_driver(CONFIG + %[parse invalid])
236 | end
237 | end
238 |
239 | def test_mrkwn_configure
240 | # default
241 | d = create_driver(CONFIG)
242 | assert_equal true, d.instance.mrkdwn
243 | assert_equal %w[text fields], d.instance.mrkdwn_in
244 |
245 | # true
246 | d = create_driver(CONFIG + %[mrkdwn true])
247 | assert_equal true, d.instance.mrkdwn
248 | assert_equal %w[text fields], d.instance.mrkdwn_in
249 |
250 | # false
251 | d = create_driver(CONFIG + %[mrkdwn false])
252 | assert_equal false, d.instance.mrkdwn
253 | assert_equal nil, d.instance.mrkdwn_in
254 | end
255 |
256 | def test_https_proxy_configure
257 | # default
258 | d = create_driver(CONFIG)
259 | assert_equal nil, d.instance.slack.https_proxy
260 | assert_equal Net::HTTP, d.instance.slack.proxy_class
261 |
262 | # https_proxy
263 | d = create_driver(CONFIG + %[https_proxy https://proxy.foo.bar:443])
264 | assert_equal URI.parse('https://proxy.foo.bar:443'), d.instance.slack.https_proxy
265 | assert_not_equal Net::HTTP, d.instance.slack.proxy_class # Net::HTTP.Proxy
266 | end
267 |
268 | def test_auto_channels_create_configure
269 | # default
270 | d = create_driver(CONFIG)
271 | assert_equal false, d.instance.auto_channels_create
272 | assert_equal({}, d.instance.post_message_opts)
273 |
274 | # require `token`
275 | assert_raise(Fluent::ConfigError) do
276 | d = create_driver(CONFIG + %[auto_channels_create true])
277 | end
278 |
279 | # auto_channels_create
280 | d = create_driver(CONFIG + %[auto_channels_create true\ntoken XXX-XX-XXX])
281 | assert_equal true, d.instance.auto_channels_create
282 | assert_equal({auto_channels_create: true}, d.instance.post_message_opts)
283 | end
284 |
285 | def test_default_incoming_webhook
286 | d = create_driver(%[
287 | channel channel
288 | webhook_url https://hooks.slack.com/services/XXX/XXX/XXX
289 | ])
290 | assert_equal Fluent::SlackClient::IncomingWebhook, d.instance.slack.class
291 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
292 | d.tag = 'test'
293 | mock(d.instance.slack).post_message(default_payload.merge({
294 | text: "sowawa1\nsowawa2\n",
295 | }), {})
296 | with_timezone('Asia/Tokyo') do
297 | d.emit({message: 'sowawa1'}, time)
298 | d.emit({message: 'sowawa2'}, time)
299 | d.run
300 | end
301 | end
302 |
303 | def test_default_slackbot
304 | d = create_driver(%[
305 | channel channel
306 | slackbot_url https://xxxxx.slack.com/services/hooks/slackbot?token=XXXXXXX
307 | ])
308 | assert_equal Fluent::SlackClient::Slackbot, d.instance.slack.class
309 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
310 | d.tag = 'test'
311 | mock(d.instance.slack).post_message(default_payload.merge({
312 | text: "sowawa1\nsowawa2\n",
313 | }), {})
314 | with_timezone('Asia/Tokyo') do
315 | d.emit({message: 'sowawa1'}, time)
316 | d.emit({message: 'sowawa2'}, time)
317 | d.run
318 | end
319 | end
320 |
321 | def test_default_slack_api
322 | d = create_driver(%[
323 | channel channel
324 | token XX-XX-XX
325 | ])
326 | assert_equal Fluent::SlackClient::WebApi, d.instance.slack.class
327 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
328 | d.tag = 'test'
329 | mock(d.instance.slack).post_message(default_payload.merge({
330 | token: 'XX-XX-XX',
331 | text: "sowawa1\nsowawa2\n",
332 | }), {})
333 | with_timezone('Asia/Tokyo') do
334 | d.emit({message: 'sowawa1'}, time)
335 | d.emit({message: 'sowawa2'}, time)
336 | d.run
337 | end
338 | end
339 |
340 | def test_title_payload
341 | title = "mytitle"
342 | d = create_driver(CONFIG + %[title #{title}])
343 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
344 | d.tag = 'test'
345 | # attachments field should be changed to show the title
346 | mock(d.instance.slack).post_message(default_payload.merge({
347 | attachments: [default_attachment.merge({
348 | fallback: title,
349 | fields: [
350 | {
351 | title: title,
352 | value: "sowawa1\nsowawa2\n",
353 | }
354 | ],
355 | })]
356 | }), {})
357 | with_timezone('Asia/Tokyo') do
358 | d.emit({message: 'sowawa1'}, time)
359 | d.emit({message: 'sowawa2'}, time)
360 | d.run
361 | end
362 | end
363 |
364 | def test_title_payload_with_verbose_fallback_option
365 | title = "mytitle"
366 | d = create_driver(CONFIG + %[title #{title}\nverbose_fallback true])
367 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
368 | d.tag = 'test'
369 | # attachments field should be changed to show the title
370 | mock(d.instance.slack).post_message(default_payload.merge({
371 | attachments: [default_attachment.merge({
372 | fallback: "#{title} sowawa1\nsowawa2\n",
373 | fields: [
374 | {
375 | title: title,
376 | value: "sowawa1\nsowawa2\n",
377 | }
378 | ],
379 | })]
380 | }), {})
381 | with_timezone('Asia/Tokyo') do
382 | d.emit({message: 'sowawa1'}, time)
383 | d.emit({message: 'sowawa2'}, time)
384 | d.run
385 | end
386 | end
387 |
388 | def test_color_payload
389 | color = 'good'
390 | d = create_driver(CONFIG + %[color #{color}])
391 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
392 | d.tag = 'test'
393 | # attachments field should be changed to show the title
394 | mock(d.instance.slack).post_message(default_payload.merge({
395 | attachments: [default_attachment.merge({
396 | color: color,
397 | fallback: "sowawa1\nsowawa2\n",
398 | text: "sowawa1\nsowawa2\n",
399 | })]
400 | }), {})
401 | with_timezone('Asia/Tokyo') do
402 | d.emit({message: 'sowawa1'}, time)
403 | d.emit({message: 'sowawa2'}, time)
404 | d.run
405 | end
406 | end
407 |
408 | def test_plain_payload
409 | d = create_driver(CONFIG)
410 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
411 | d.tag = 'test'
412 | # attachments field should be changed to show the title
413 | mock(d.instance.slack).post_message(default_payload.merge({
414 | text: "sowawa1\nsowawa2\n",
415 | }), {})
416 | with_timezone('Asia/Tokyo') do
417 | d.emit({message: 'sowawa1'}, time)
418 | d.emit({message: 'sowawa2'}, time)
419 | d.run
420 | end
421 | end
422 |
423 | def test_title_keys
424 | d = create_driver(CONFIG + %[title [%s] %s\ntitle_keys time,tag])
425 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
426 | d.tag = 'test'
427 | # attachments field should be changed to show the title
428 | mock(d.instance.slack).post_message(default_payload.merge({
429 | attachments: [default_attachment.merge({
430 | fallback: "[07:00:00] #{d.tag}",
431 | fields: [
432 | {
433 | title: "[07:00:00] #{d.tag}",
434 | value: "sowawa1\nsowawa2\n",
435 | }
436 | ],
437 | })]
438 | }), {})
439 | with_timezone('Asia/Tokyo') do
440 | d.emit({message: 'sowawa1'}, time)
441 | d.emit({message: 'sowawa2'}, time)
442 | d.run
443 | end
444 | end
445 |
446 | def test_message_keys
447 | d = create_driver(CONFIG + %[message [%s] %s %s\nmessage_keys time,tag,message])
448 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
449 | d.tag = 'test'
450 | mock(d.instance.slack).post_message(default_payload.merge({
451 | text: "[07:00:00] test sowawa1\n[07:00:00] test sowawa2\n",
452 | }), {})
453 | with_timezone('Asia/Tokyo') do
454 | d.emit({message: 'sowawa1'}, time)
455 | d.emit({message: 'sowawa2'}, time)
456 | d.run
457 | end
458 | end
459 |
460 | def test_channel_keys
461 | d = create_driver(CONFIG + %[channel %s\nchannel_keys channel])
462 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
463 | d.tag = 'test'
464 | mock(d.instance.slack).post_message(default_payload.merge({
465 | channel: '#channel1',
466 | text: "sowawa1\n",
467 | }), {})
468 | mock(d.instance.slack).post_message(default_payload.merge({
469 | channel: '#channel2',
470 | text: "sowawa2\n",
471 | }), {})
472 | with_timezone('Asia/Tokyo') do
473 | d.emit({message: 'sowawa1', channel: 'channel1'}, time)
474 | d.emit({message: 'sowawa2', channel: 'channel2'}, time)
475 | d.run
476 | end
477 | end
478 |
479 | def test_icon_emoji
480 | d = create_driver(CONFIG + %[icon_emoji :ghost:])
481 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
482 | d.tag = 'test'
483 | mock(d.instance.slack).post_message(default_payload.merge({
484 | icon_emoji: ':ghost:',
485 | text: "foo\n",
486 | }), {})
487 | with_timezone('Asia/Tokyo') do
488 | d.emit({message: 'foo'}, time)
489 | d.run
490 | end
491 | end
492 |
493 | def test_icon_url
494 | d = create_driver(CONFIG + %[icon_url #{@icon_url}])
495 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
496 | d.tag = 'test'
497 | mock(d.instance.slack).post_message(default_payload.merge({
498 | icon_url: @icon_url,
499 | text: "foo\n",
500 | }), {})
501 | with_timezone('Asia/Tokyo') do
502 | d.emit({message: 'foo'}, time)
503 | d.run
504 | end
505 | end
506 |
507 | def test_mrkdwn
508 | d = create_driver(CONFIG + %[mrkdwn true])
509 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
510 | d.tag = 'test'
511 | mock(d.instance.slack).post_message(default_payload.merge({
512 | mrkdwn: true,
513 | text: "foo\n",
514 | }), {})
515 | with_timezone('Asia/Tokyo') do
516 | d.emit({message: 'foo'}, time)
517 | d.run
518 | end
519 | end
520 |
521 | def test_mrkdwn_in
522 | d = create_driver(CONFIG + %[mrkdwn true\ncolor good])
523 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
524 | d.tag = 'test'
525 | mock(d.instance.slack).post_message(default_payload.merge({
526 | attachments: [default_attachment.merge({
527 | color: "good",
528 | fallback: "foo\n",
529 | text: "foo\n",
530 | mrkdwn_in: ["text", "fields"],
531 | })]
532 | }), {})
533 | with_timezone('Asia/Tokyo') do
534 | d.emit({message: 'foo'}, time)
535 | d.run
536 | end
537 | end
538 |
539 | def test_link_names
540 | d = create_driver(CONFIG + %[link_names true])
541 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
542 | d.tag = 'test'
543 | mock(d.instance.slack).post_message(default_payload.merge({
544 | link_names: true,
545 | text: "foo\n",
546 | }), {})
547 | with_timezone('Asia/Tokyo') do
548 | d.emit({message: 'foo'}, time)
549 | d.run
550 | end
551 | end
552 |
553 | def test_parse
554 | d = create_driver(CONFIG + %[parse full])
555 | time = Time.parse("2014-01-01 22:00:00 UTC").to_i
556 | d.tag = 'test'
557 | mock(d.instance.slack).post_message(default_payload.merge({
558 | parse: "full",
559 | text: "foo\n",
560 | }), {})
561 | with_timezone('Asia/Tokyo') do
562 | d.emit({message: 'foo'}, time)
563 | d.run
564 | end
565 | end
566 | end
567 |
--------------------------------------------------------------------------------
/test/plugin/test_slack_client.rb:
--------------------------------------------------------------------------------
1 | require_relative '../test_helper'
2 | require 'fluent/plugin/slack_client'
3 | require 'time'
4 | require 'dotenv'
5 | require 'webrick'
6 | require 'webrick/httpproxy'
7 |
8 | # HOW TO RUN
9 | #
10 | # Create .env file with contents as:
11 | #
12 | # WEBHOOK_URL=https://hooks.slack.com/services/XXXX/YYYY/ZZZZ
13 | # SLACKBOt_URL=https://xxxx.slack.com/services/hooks/slackbot?token=XXXX
14 | # SLACK_API_TOKEN=XXXXX
15 | #
16 | Dotenv.load
17 | if ENV['WEBHOOK_URL'] and ENV['SLACKBOT_URL'] and ENV['SLACK_API_TOKEN']
18 |
19 | class TestProxyServer
20 | def initialize
21 | @proxy = WEBrick::HTTPProxyServer.new(
22 | :BindAddress => '127.0.0.1',
23 | :Port => unused_port,
24 | )
25 | end
26 |
27 | def proxy_url
28 | "https://127.0.0.1:#{unused_port}"
29 | end
30 |
31 | def start
32 | @thread = Thread.new do
33 | @proxy.start
34 | end
35 | end
36 |
37 | def shutdown
38 | @proxy.shutdown
39 | end
40 |
41 | def unused_port
42 | return @unused_port if @unused_port
43 | s = TCPServer.open(0)
44 | port = s.addr[1]
45 | s.close
46 | @unused_port = port
47 | end
48 | end
49 |
50 | class SlackClientTest < Test::Unit::TestCase
51 | class << self
52 | attr_reader :proxy
53 |
54 | def startup
55 | @proxy = TestProxyServer.new.tap {|proxy| proxy.start }
56 | end
57 |
58 | def shutdown
59 | @proxy.shutdown
60 | end
61 | end
62 |
63 | def setup
64 | super
65 | @incoming = Fluent::SlackClient::IncomingWebhook.new(ENV['WEBHOOK_URL'])
66 | @slackbot = Fluent::SlackClient::Slackbot.new(ENV['SLACKBOT_URL'])
67 | @api = Fluent::SlackClient::WebApi.new
68 |
69 | proxy_url = self.class.proxy.proxy_url
70 | @incoming_proxy = Fluent::SlackClient::IncomingWebhook.new(ENV['WEBHOOK_URL'], proxy_url)
71 | @slackbot_proxy = Fluent::SlackClient::Slackbot.new(ENV['SLACKBOT_URL'], proxy_url)
72 | @api_proxy = Fluent::SlackClient::WebApi.new(nil, proxy_url)
73 |
74 | @icon_url = 'http://www.google.com/s2/favicons?domain=www.google.de'
75 | end
76 |
77 | def token(client)
78 | client.is_a?(Fluent::SlackClient::IncomingWebhook) ? {} : {token: ENV['SLACK_API_TOKEN']}
79 | end
80 |
81 | def default_payload(client)
82 | {
83 | channel: '#general',
84 | mrkdwn: true,
85 | link_names: true,
86 | }.merge!(token(client))
87 | end
88 |
89 | def default_attachment
90 | {
91 | mrkdwn_in: %w[text fields]
92 | }
93 | end
94 |
95 | def valid_utf8_encoded_string
96 | "#general \xE3\x82\xA4\xE3\x83\xB3\xE3\x82\xB9\xE3\x83\x88\xE3\x83\xBC\xE3\x83\xAB\n"
97 | end
98 |
99 | def invalid_ascii8bit_encoded_utf8_string
100 | str = "#general \xE3\x82\xA4\xE3\x83\xB3\xE3\x82\xB9\xE3\x83\x88\xE3\x83\xBC\xE3\x83\xAB\x81\n"
101 | str.force_encoding(Encoding::ASCII_8BIT)
102 | end
103 |
104 | # Notification via Mention works for all three with plain text payload
105 | def test_post_message_plain_payload_mention
106 | [@incoming, @slackbot, @api].each do |slack|
107 | assert_nothing_raised do
108 | slack.post_message(default_payload(slack).merge({
109 | text: "#general @everyone\n",
110 | }))
111 | end
112 | end
113 | end
114 |
115 | # Notification via Highlight Words works with only Slackbot with plain text payload
116 | # NOTE: Please add `sowawa1` to Highlight Words
117 | def test_post_message_plain_payload_highlight_words
118 | [@incoming, @slackbot, @api].each do |slack|
119 | assert_nothing_raised do
120 | slack.post_message(default_payload(slack).merge({
121 | text: "sowawa1\n",
122 | }))
123 | end
124 | end
125 | end
126 |
127 | # Notification via Mention does not work for attachments
128 | def test_post_message_color_payload
129 | [@incoming, @slackbot, @api].each do |slack|
130 | assert_nothing_raised do
131 | slack.post_message(default_payload(slack).merge({
132 | attachments: [default_attachment.merge({
133 | color: 'good',
134 | fallback: "sowawa1\n@everyone\n",
135 | text: "sowawa1\n@everyone\n",
136 | })]
137 | }))
138 | end
139 | end
140 | end
141 |
142 | # Notification via Mention does not work for attachments
143 | def test_post_message_fields_payload
144 | [@incoming, @slackbot, @api].each do |slack|
145 | assert_nothing_raised do
146 | slack.post_message(default_payload(slack).merge({
147 | attachments: [default_attachment.merge({
148 | color: 'good',
149 | fallback: 'test1 test2',
150 | fields: [
151 | {
152 | title: 'test1',
153 | value: "[07:00:00] sowawa1\n[07:00:00] @everyone\n",
154 | },
155 | {
156 | title: 'test2',
157 | value: "[07:00:00] sowawa1\n[07:00:00] @everyone\n",
158 | },
159 | ],
160 | })]
161 | }))
162 | end
163 | end
164 | end
165 |
166 | def test_post_via_proxy
167 | [@incoming_proxy, @slackbot_proxy, @api_proxy].each do |slack|
168 | assert_nothing_raised do
169 | slack.post_message(default_payload(slack).merge({
170 | attachments: [default_attachment.merge({
171 | color: 'good',
172 | fallback: "sowawa1\n@everyone\n",
173 | text: "sowawa1\n@everyone\n",
174 | })]
175 | }))
176 | end
177 | end
178 | end
179 |
180 | def test_post_message_username
181 | [@incoming, @api].each do |slack|
182 | assert_nothing_raised do
183 | slack.post_message(default_payload(slack).merge({
184 | username: 'fluentd',
185 | text: "#general @everyone\n",
186 | }))
187 | end
188 | end
189 | end
190 |
191 | def test_post_message_icon_url
192 | [@incoming, @api].each do |slack|
193 | assert_nothing_raised do
194 | slack.post_message(default_payload(slack).merge({
195 | icon_url: @icon_url,
196 | attachments: [default_attachment.merge({
197 | color: 'good',
198 | fallback: "sowawa1\n@everyone\n",
199 | text: "sowawa1\n@everyone\n",
200 | })]
201 | }))
202 | end
203 | end
204 | end
205 |
206 | # Hmm, I need to delete channels to test repeatedly,
207 | # but slack does not provide channels.delete API
208 | def test_channels_create
209 | begin
210 | @api.channels_create(token(@api).merge({
211 | name: '#test_channels_create',
212 | }))
213 | rescue Fluent::SlackClient::NameTakenError
214 | end
215 | end
216 |
217 | # Hmm, I need to delete channels to test repeatedly,
218 | # but slack does not provide channels.delete API
219 | def test_auto_channels_create
220 | assert_nothing_raised do
221 | @api.post_message(default_payload(@api).merge(
222 | {
223 | channel: '#test_auto_api',
224 | text: "bar\n",
225 | }),
226 | {
227 | auto_channels_create: true,
228 | }
229 | )
230 | end
231 |
232 | assert_nothing_raised do
233 | @slackbot.post_message(default_payload(@slackbot).merge(
234 | {
235 | channel: '#test_auto_slackbot',
236 | text: "bar\n",
237 | }),
238 | {
239 | auto_channels_create: true,
240 | }
241 | )
242 | end
243 | end
244 |
245 | # IncomingWebhook posts "#general インストール"
246 | def test_post_message_utf8_encoded_text
247 | [@incoming].each do |slack|
248 | assert_nothing_raised do
249 | slack.post_message(default_payload(slack).merge({
250 | text: valid_utf8_encoded_string,
251 | }))
252 | end
253 | end
254 | end
255 |
256 | # IncomingWebhook posts "#general インストール?"
257 | def test_post_message_ascii8bit_encoded_utf8_text
258 | [@incoming].each do |slack|
259 | assert_nothing_raised do
260 | slack.post_message(default_payload(slack).merge({
261 | text: invalid_ascii8bit_encoded_utf8_string,
262 | }))
263 | end
264 | end
265 | end
266 |
267 | # IncomingWebhook and API posts "#general インストール?"
268 | def test_post_message_ascii8bit_encoded_utf8_attachments
269 | [@incoming, @api].each do |slack|
270 | assert_nothing_raised do
271 | slack.post_message(default_payload(slack).merge({
272 | attachments: [default_attachment.merge({
273 | color: 'good',
274 | fallback: invalid_ascii8bit_encoded_utf8_string,
275 | text: invalid_ascii8bit_encoded_utf8_string,
276 | })]
277 | }))
278 | end
279 | end
280 | end
281 | end
282 | end
283 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'bundler'
3 |
4 | begin
5 | Bundler.setup(:default, :development)
6 | rescue Bundler::BundlerError => e
7 | $stderr.puts e.message
8 | $stderr.puts "Run `bundle install` to install missing gems"
9 | exit e.status_code
10 | end
11 | require 'test/unit'
12 | require 'test/unit/rr'
13 |
14 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
15 | $LOAD_PATH.unshift(File.dirname(__FILE__))
16 | require 'fluent/test'
17 | unless ENV.has_key?('VERBOSE')
18 | nulllogger = Object.new
19 | nulllogger.instance_eval {|obj|
20 | def method_missing(method, *args)
21 | # pass
22 | end
23 | }
24 | $log = nulllogger
25 | end
26 |
27 | def with_timezone(tz)
28 | oldtz, ENV['TZ'] = ENV['TZ'], tz
29 | yield
30 | ensure
31 | ENV['TZ'] = oldtz
32 | end
33 |
--------------------------------------------------------------------------------