├── .gitignore ├── .rubocop.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib └── ruboty │ ├── adapters │ └── slack_rtm.rb │ ├── slack_rtm.rb │ └── slack_rtm │ ├── client.rb │ ├── message.rb │ ├── robot.rb │ └── version.rb └── ruboty-slack_rtm.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | .env 16 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Style/Documentation: 2 | Enabled: false 3 | 4 | Style/RegexpLiteral: 5 | MaxSlashes: 0 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in ruboty-slack_rtm.gemspec 4 | gemspec 5 | 6 | group :development, :test do 7 | gem 'ruboty-echo' 8 | end 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Sho Kusano 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ruboty::SlackRTM 2 | 3 | Slack(real time api) adapter for [ruboty](https://github.com/r7kamura/ruboty). 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | ```ruby 10 | gem 'ruboty-slack_rtm' 11 | ``` 12 | 13 | ## ENV 14 | 15 | - `SLACK_TOKEN`: Account's token. get one on https://api.slack.com/web#basics 16 | - `SLACK_EXPOSE_CHANNEL_NAME`: if this set to 1, `message.to` will be channel name instead of id (optional) 17 | - `SLACK_IGNORE_GENERAL`: if this set to 1, bot ignores all messages on #general channel (optional) 18 | - `SLACK_GENERAL_NAME`: Set general channel name if your Slack changes general name (optional) 19 | - `SLACK_AUTO_RECONNECT`: Enable auto reconnect if rtm disconnected by Slack (optional) 20 | 21 | This adapter doesn't require a real user account. Using with bot integration's API token is recommended. 22 | See: https://api.slack.com/bot-users 23 | 24 | ## Contributing 25 | 26 | 1. Fork it ( https://github.com/rosylilly/ruboty-slack_rtm/fork ) 27 | 2. Create your feature branch (`git checkout -b my-new-feature`) 28 | 3. Commit your changes (`git commit -am 'Add some feature'`) 29 | 4. Push to the branch (`git push origin my-new-feature`) 30 | 5. Create a new Pull Request 31 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | -------------------------------------------------------------------------------- /lib/ruboty/adapters/slack_rtm.rb: -------------------------------------------------------------------------------- 1 | require 'cgi' 2 | require 'time' 3 | require 'json' 4 | require 'slack' 5 | require 'ruboty/adapters/base' 6 | require 'faraday' 7 | 8 | module Ruboty 9 | module Adapters 10 | class SlackRTM < Base 11 | env :SLACK_TOKEN, "Account's token. get one on https://api.slack.com/web#basics" 12 | env :SLACK_EXPOSE_CHANNEL_NAME, "if this set to 1, message.to will be channel name instead of id", optional: true 13 | env :SLACK_IGNORE_BOT_MESSAGE, "If this set to 1, bot ignores bot_messages", optional: true 14 | env :SLACK_IGNORE_GENERAL, "if this set to 1, bot ignores all messages on #general channel", optional: true 15 | env :SLACK_GENERAL_NAME, "Set general channel name if your Slack changes general name", optional: true 16 | env :SLACK_AUTO_RECONNECT, "Enable auto reconnect", optional: true 17 | 18 | def run 19 | init 20 | bind 21 | connect 22 | end 23 | 24 | def say(message) 25 | channel = message[:to] 26 | if channel[0] == '#' 27 | channel = resolve_channel_id(channel[1..-1]) 28 | end 29 | 30 | return unless channel 31 | 32 | args = { 33 | as_user: true 34 | } 35 | if message[:thread_ts] || (message[:original] && message[:original][:thread_ts]) 36 | args.merge!(thread_ts: message[:thread_ts] || message[:original][:thread_ts]) 37 | end 38 | 39 | if message[:attachments] && !message[:attachments].empty? 40 | args.merge!( 41 | channel: channel, 42 | text: message[:code] ? "```\n#{message[:body]}\n```" : message[:body], 43 | parse: message[:parse] || 'full', 44 | unfurl_links: true, 45 | attachments: message[:attachments].to_json 46 | ) 47 | client.chat_postMessage(args) 48 | elsif message[:file] 49 | path = message[:file][:path] 50 | args.merge!( 51 | channels: channel, 52 | file: Faraday::UploadIO.new(path, message[:file][:content_type]), 53 | title: message[:file][:title] || path, 54 | filename: File.basename(path), 55 | initial_comment: message[:body] || '' 56 | ) 57 | client.files_upload(args) 58 | else 59 | args.merge!( 60 | channel: channel, 61 | text: message[:code] ? "```\n#{message[:body]}\n```" : resolve_send_mention(message[:body]), 62 | mrkdwn: true 63 | ) 64 | client.chat_postMessage(args) 65 | end 66 | end 67 | 68 | def add_reaction(reaction, channel_id, timestamp) 69 | client.reactions_add(name: reaction, channel: channel_id, timestamp: timestamp) 70 | end 71 | 72 | private 73 | 74 | def init 75 | response = client.auth_test 76 | @user_info_caches = {} 77 | @channel_info_caches = {} 78 | @usergroup_info_caches = {} 79 | 80 | ENV['RUBOTY_NAME'] ||= response['user'] 81 | 82 | make_users_cache 83 | make_channels_cache 84 | make_usergroups_cache 85 | end 86 | 87 | def bind 88 | realtime.on_text do |data| 89 | method_name = "on_#{data['type']}".to_sym 90 | send(method_name, data) if respond_to?(method_name, true) 91 | end 92 | end 93 | 94 | def connect 95 | Thread.start do 96 | loop do 97 | sleep 5 98 | set_active 99 | end 100 | end 101 | 102 | loop do 103 | realtime.main_loop rescue nil 104 | break unless ENV['SLACK_AUTO_RECONNECT'] 105 | @url = nil 106 | @realtime = nil 107 | sleep 3 108 | bind 109 | end 110 | end 111 | 112 | def url 113 | @url ||= begin 114 | response = Net::HTTP.post_form(URI.parse('https://slack.com/api/rtm.connect'), token: ENV['SLACK_TOKEN']) 115 | body = JSON.parse(response.body) 116 | 117 | URI.parse(body['url']) 118 | end 119 | end 120 | 121 | def client 122 | @client ||= ::Slack::Client.new(token: ENV['SLACK_TOKEN']) 123 | end 124 | 125 | def realtime 126 | @realtime ||= ::Ruboty::SlackRTM::Client.new(websocket_url: url) 127 | end 128 | 129 | def expose_channel_name? 130 | if @expose_channel_name.nil? 131 | @expose_channel_name = ENV['SLACK_EXPOSE_CHANNEL_NAME'] == '1' 132 | else 133 | @expose_channel_name 134 | end 135 | end 136 | 137 | def set_active 138 | client.users_setActive 139 | end 140 | 141 | # event handlers 142 | 143 | def on_message(data) 144 | user = user_info(data['user']) || {} 145 | 146 | channel = channel_info(data['channel']) 147 | 148 | if (data['subtype'] == 'bot_message' || user['is_bot']) && ENV['SLACK_IGNORE_BOT_MESSAGE'] == '1' 149 | return 150 | end 151 | 152 | if channel 153 | return if channel['name'] == (ENV['SLACK_GENERAL_NAME'] || 'general') && ENV['SLACK_IGNORE_GENERAL'] == '1' 154 | 155 | channel_to = expose_channel_name? ? "##{channel['name']}" : channel['id'] 156 | else # direct message 157 | channel_to = data['channel'] 158 | end 159 | 160 | message_info = { 161 | from: data['channel'], 162 | from_name: user['name'], 163 | to: channel_to, 164 | channel: channel, 165 | user: user, 166 | ts: data['ts'], 167 | thread_ts: data['thread_ts'], 168 | time: Time.at(data['ts'].to_f) 169 | } 170 | 171 | text, mention_to = extract_mention(data['text']) 172 | robot.receive(message_info.merge(body: text, mention_to: mention_to)) 173 | 174 | (data['attachments'] || []).each do |attachment| 175 | body, body_mention_to = extract_mention(attachment['fallback'] || "#{attachment['text']} #{attachment['pretext']}".strip) 176 | 177 | unless body.empty? 178 | robot.receive(message_info.merge(body: body, mention_to: body_mention_to)) 179 | end 180 | end 181 | end 182 | 183 | def on_channel_change(data) 184 | make_channels_cache 185 | end 186 | alias_method :on_channel_deleted, :on_channel_change 187 | alias_method :on_channel_renamed, :on_channel_change 188 | alias_method :on_channel_archived, :on_channel_change 189 | alias_method :on_channel_unarchived, :on_channel_change 190 | 191 | def on_user_change(data) 192 | user = data['user'] || data['bot'] 193 | @user_info_caches[user['id']] = user 194 | end 195 | alias_method :on_bot_added, :on_user_change 196 | alias_method :on_bot_changed, :on_user_change 197 | 198 | def extract_mention(text) 199 | mention_to = [] 200 | 201 | text = (text || '').gsub(/\<\@(?[0-9A-Z]+)(?:\|(?[^>]+))?\>/) do |_| 202 | name = Regexp.last_match[:name] 203 | 204 | unless name 205 | user = user_info(Regexp.last_match[:uid]) 206 | 207 | mention_to << user 208 | 209 | name = user['name'] 210 | end 211 | 212 | "@#{name}" 213 | end 214 | 215 | text.gsub!(/\[0-9A-Z]+)(?:\|(?[^>]+))?\>/) do |_| 216 | handle = Regexp.last_match[:handle] 217 | 218 | unless handle 219 | handle = usergroup_info(Regexp.last_match[:usergroup_id]) 220 | end 221 | 222 | "#{handle}" 223 | end 224 | 225 | text.gsub!(/\[^>|@]+)(\|\@[^>]+)?\>/) do |_| 226 | "@#{Regexp.last_match[:special]}" 227 | end 228 | 229 | text.gsub!(/\<((?[^>|]+)(?:\|(?[^>]*))?)\>/) do |_| 230 | Regexp.last_match[:ref] || Regexp.last_match[:link] 231 | end 232 | 233 | 234 | text.gsub!(/\#(?[A-Z0-9]+)/) do |_| 235 | room_id = Regexp.last_match[:room_id] 236 | msg = "##{room_id}" 237 | 238 | if channel = channel_info(room_id) 239 | msg = "##{channel['name']}" 240 | end 241 | 242 | msg 243 | end 244 | 245 | [CGI.unescapeHTML(text), mention_to] 246 | end 247 | 248 | def resolve_send_mention(text) 249 | return '' if text.nil? 250 | text = text.dup.to_s 251 | text.gsub!(/@(?[0-9a-z._-]+)/) do |_| 252 | mention = Regexp.last_match[:mention] 253 | msg = "@#{mention}" 254 | 255 | @user_info_caches.each_pair do |id, user| 256 | if user['name'].downcase == mention.downcase 257 | msg = "<@#{id}>" 258 | end 259 | end 260 | 261 | msg 262 | end 263 | 264 | text.gsub!(/@(?(?:everyone|group|channel|here))/) do |_| 265 | "" 266 | end 267 | 268 | text.gsub!(/@(?[0-9a-z._-]+)/) do |_| 269 | subteam_name = Regexp.last_match[:subteam_name] 270 | msg = "@#{subteam_name}" 271 | 272 | @usergroup_info_caches.each_pair do |id, usergroup| 273 | if usergroup && usergroup['handle'] == subteam_name 274 | msg = "" 275 | end 276 | end 277 | msg 278 | end 279 | 280 | text.gsub!(/\#(?[a-z0-9_-]+)/) do |_| 281 | room_id = Regexp.last_match[:room_id] 282 | msg = "##{room_id}" 283 | 284 | @channel_info_caches.each_pair do |id, channel| 285 | if channel && channel['name'] == room_id 286 | msg = "<##{id}|#{room_id}>" 287 | end 288 | end 289 | 290 | msg 291 | end 292 | 293 | text 294 | end 295 | 296 | def make_users_cache 297 | resp = client.users_list 298 | if resp['ok'] 299 | resp['members'].each do |user| 300 | @user_info_caches[user['id']] = user 301 | end 302 | end 303 | end 304 | 305 | def make_channels_cache 306 | resp = client.channels_list 307 | if resp['ok'] 308 | resp['channels'].each do |channel| 309 | @channel_info_caches[channel['id']] = channel 310 | end 311 | end 312 | end 313 | 314 | def make_usergroups_cache 315 | resp = client.get("usergroups.list") 316 | if resp['ok'] 317 | resp['usergroups'].each do |usergroup| 318 | @usergroup_info_caches[usergroup['id']] = usergroup 319 | end 320 | end 321 | end 322 | 323 | def user_info(user_id) 324 | return {} if user_id.to_s.empty? 325 | 326 | @user_info_caches[user_id] ||= begin 327 | resp = client.users_info(user: user_id) 328 | 329 | resp['user'] 330 | end 331 | end 332 | 333 | def channel_info(channel_id) 334 | @channel_info_caches[channel_id] ||= begin 335 | resp = case channel_id 336 | when /^C/ 337 | client.conversations_info(channel: channel_id) 338 | else 339 | {} 340 | end 341 | 342 | resp['channel'] 343 | end 344 | end 345 | 346 | def resolve_channel_id(name) 347 | ret_id = nil 348 | @channel_info_caches.each_pair do |id, channel| 349 | if channel['name'] == name 350 | ret_id = id 351 | break 352 | end 353 | end 354 | return ret_id 355 | end 356 | 357 | def usergroup_info(usergroup_id) 358 | @usergroup_info_caches[usergroup_id] || begin 359 | make_usergroups_cache 360 | @usergroup_info_caches[usergroup_id] 361 | end 362 | end 363 | end 364 | end 365 | end 366 | -------------------------------------------------------------------------------- /lib/ruboty/slack_rtm.rb: -------------------------------------------------------------------------------- 1 | require 'ruboty/slack_rtm/version' 2 | require 'ruboty/slack_rtm/client' 3 | require 'ruboty/adapters/slack_rtm' 4 | require 'ruboty/slack_rtm/robot' 5 | require 'ruboty/slack_rtm/message' 6 | -------------------------------------------------------------------------------- /lib/ruboty/slack_rtm/client.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'websocket-client-simple' 3 | 4 | module Ruboty 5 | module SlackRTM 6 | class Client 7 | CONNECTION_CLOSED = Object.new 8 | 9 | def initialize(websocket_url:) 10 | @queue = Queue.new 11 | @client = create_client(websocket_url.to_s) 12 | end 13 | 14 | def send_message(data) 15 | data[:id] = (Time.now.to_i * 10 + rand(10)) % (1 << 31) 16 | @queue.enq(data.to_json) 17 | end 18 | 19 | def on_text(&block) 20 | @client.on(:message) do |message| 21 | case message.type 22 | when :ping 23 | Ruboty.logger.debug("#{Client.name}: Received ping message") 24 | send('', type: 'pong') 25 | when :pong 26 | Ruboty.logger.debug("#{Client.name}: Received pong message") 27 | when :text 28 | block.call(JSON.parse(message.data)) 29 | else 30 | Ruboty.logger.warn("#{Client.name}: Received unknown message type=#{message.type}: #{message.data}") 31 | end 32 | end 33 | end 34 | 35 | def main_loop 36 | keep_connection 37 | 38 | loop do 39 | message = @queue.deq 40 | if message.equal?(CONNECTION_CLOSED) 41 | break 42 | end 43 | @client.send(message) 44 | end 45 | end 46 | 47 | private 48 | 49 | def create_client(url) 50 | WebSocket::Client::Simple.connect(url, verify_mode: OpenSSL::SSL::VERIFY_PEER).tap do |client| 51 | client.on(:error) do |err| 52 | Ruboty.logger.error("#{err.class}: #{err.message}\n#{err.backtrace.join("\n")}") 53 | end 54 | queue = @queue 55 | client.on(:close) do 56 | Ruboty.logger.info('Disconnected') 57 | # XXX: This block is called via BasicObject#instance_exec from 58 | # EventEmitter, so `@queue` isn't visible here. 59 | queue.enq(CONNECTION_CLOSED) 60 | end 61 | end 62 | end 63 | 64 | def keep_connection 65 | Thread.start do 66 | loop do 67 | sleep(30) 68 | begin 69 | @client.send('', type: 'ping') 70 | rescue => e 71 | Ruboty.logger.error("#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}") 72 | @queue.enq(CONNECTION_CLOSED) 73 | break 74 | end 75 | end 76 | end 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/ruboty/slack_rtm/message.rb: -------------------------------------------------------------------------------- 1 | require 'ruboty/message' 2 | 3 | module Ruboty 4 | module SlackRTM 5 | module Message 6 | def add_reaction(reaction) 7 | channel_id = @original[:channel]["id"] 8 | timestamp = @original[:time].to_f 9 | robot.add_reaction(reaction, channel_id, timestamp) 10 | end 11 | end 12 | end 13 | 14 | Message.include SlackRTM::Message 15 | end 16 | -------------------------------------------------------------------------------- /lib/ruboty/slack_rtm/robot.rb: -------------------------------------------------------------------------------- 1 | require 'ruboty/robot' 2 | 3 | module Ruboty 4 | module SlackRTM 5 | module Robot 6 | delegate :add_reaction, to: :adapter 7 | 8 | def add_reaction(reaction, channel_id, timestamp) 9 | adapter.add_reaction(reaction, channel_id, timestamp) 10 | true 11 | end 12 | end 13 | end 14 | 15 | Robot.include SlackRTM::Robot 16 | end 17 | -------------------------------------------------------------------------------- /lib/ruboty/slack_rtm/version.rb: -------------------------------------------------------------------------------- 1 | module Ruboty 2 | module SlackRTM 3 | VERSION = '3.2.5' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /ruboty-slack_rtm.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'ruboty/slack_rtm/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'ruboty-slack_rtm' 8 | spec.version = Ruboty::SlackRTM::VERSION 9 | spec.authors = ['Sho Kusano'] 10 | spec.email = ['rosylilly@aduca.org'] 11 | spec.summary = 'Slack real time messaging adapter for Ruboty' 12 | spec.description = spec.summary 13 | spec.homepage = 'https://github.com/rosylilly/ruboty-slack_rtm' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_development_dependency 'bundler' 22 | spec.add_development_dependency 'rake' 23 | spec.add_development_dependency 'rubocop', '>= 0.28.0' 24 | 25 | spec.add_dependency 'ruboty', '>= 1.1.4' 26 | spec.add_dependency 'slack-api', '~> 1.6' 27 | spec.add_dependency 'websocket-client-simple', '>= 0.5.0' 28 | spec.add_dependency 'faraday', '~> 0.11' 29 | end 30 | --------------------------------------------------------------------------------