├── .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 [![Build Status](https://travis-ci.org/sowawa/fluent-plugin-slack.svg)](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(//, '>') 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 | --------------------------------------------------------------------------------