├── Rakefile ├── .rubocop.yml ├── lib └── ruboty │ ├── slack_rtm │ ├── version.rb │ └── client.rb │ ├── slack_rtm.rb │ ├── robot.rb │ ├── message.rb │ └── adapters │ └── slack_rtm.rb ├── Gemfile ├── .gitignore ├── LICENSE.txt ├── README.md └── ruboty-slack_rtm.gemspec /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Style/Documentation: 2 | Enabled: false 3 | 4 | Style/RegexpLiteral: 5 | MaxSlashes: 0 6 | -------------------------------------------------------------------------------- /lib/ruboty/slack_rtm/version.rb: -------------------------------------------------------------------------------- 1 | module Ruboty 2 | module SlackRTM 3 | VERSION = '2.9.0' 4 | end 5 | end 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 | -------------------------------------------------------------------------------- /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_relative 'robot' 5 | require_relative 'message' 6 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /lib/ruboty/robot.rb: -------------------------------------------------------------------------------- 1 | module Ruboty 2 | class Robot 3 | delegate :add_reaction, to: :adapter 4 | 5 | def add_reaction(reaction, channel_id, timestamp) 6 | adapter.add_reaction(reaction, channel_id, timestamp) 7 | true 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/ruboty/message.rb: -------------------------------------------------------------------------------- 1 | module Ruboty 2 | class Message 3 | def add_reaction(reaction) 4 | channel_id = @original[:channel]["id"] 5 | timestamp = @original[:time].to_f 6 | robot.add_reaction(reaction, channel_id, timestamp) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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', '~> 1.7' 22 | spec.add_development_dependency 'rake', '~> 11.1' 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.3.0' 28 | end 29 | -------------------------------------------------------------------------------- /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 | @client.send('', type: 'ping') 69 | end 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/ruboty/adapters/slack_rtm.rb: -------------------------------------------------------------------------------- 1 | require 'cgi' 2 | require 'time' 3 | require 'json' 4 | require 'slack' 5 | require 'ruboty/adapters/base' 6 | 7 | module Ruboty 8 | module Adapters 9 | class SlackRTM < Base 10 | env :SLACK_TOKEN, "Account's token. get one on https://api.slack.com/web#basics" 11 | env :SLACK_EXPOSE_CHANNEL_NAME, "if this set to 1, message.to will be channel name instead of id", optional: true 12 | env :SLACK_IGNORE_BOT_MESSAGE, "If this set to 1, bot ignores bot_messages", optional: true 13 | env :SLACK_IGNORE_GENERAL, "if this set to 1, bot ignores all messages on #general channel", optional: true 14 | env :SLACK_GENERAL_NAME, "Set general channel name if your Slack changes general name", optional: true 15 | env :SLACK_AUTO_RECONNECT, "Enable auto reconnect", optional: true 16 | 17 | def run 18 | init 19 | bind 20 | connect 21 | end 22 | 23 | def say(message) 24 | channel = message[:to] 25 | if channel[0] == '#' 26 | channel = resolve_channel_id(channel[1..-1]) 27 | end 28 | 29 | return unless channel 30 | 31 | if message[:attachments] && !message[:attachments].empty? 32 | client.chat_postMessage( 33 | channel: channel, 34 | text: message[:code] ? "```\n#{message[:body]}\n```" : message[:body], 35 | parse: message[:parse] || 'full', 36 | unfurl_links: true, 37 | as_user: true, 38 | attachments: message[:attachments].to_json 39 | ) 40 | else 41 | realtime.send_message( 42 | type: 'message', 43 | channel: channel, 44 | text: message[:code] ? "```\n#{message[:body]}\n```" : resolve_send_mention(message[:body]), 45 | mrkdwn: true 46 | ) 47 | end 48 | end 49 | 50 | def add_reaction(reaction, channel_id, timestamp) 51 | client.reactions_add(name: reaction, channel: channel_id, timestamp: timestamp) 52 | end 53 | 54 | private 55 | 56 | def init 57 | response = client.auth_test 58 | @user_info_caches = {} 59 | @channel_info_caches = {} 60 | @usergroup_info_caches = {} 61 | 62 | ENV['RUBOTY_NAME'] ||= response['user'] 63 | 64 | make_users_cache 65 | make_channels_cache 66 | make_usergroups_cache 67 | end 68 | 69 | def bind 70 | realtime.on_text do |data| 71 | method_name = "on_#{data['type']}".to_sym 72 | send(method_name, data) if respond_to?(method_name, true) 73 | end 74 | end 75 | 76 | def connect 77 | Thread.start do 78 | loop do 79 | sleep 5 80 | set_active 81 | end 82 | end 83 | 84 | loop do 85 | realtime.main_loop rescue nil 86 | break unless ENV['SLACK_AUTO_RECONNECT'] 87 | @url = null 88 | @realtime = null 89 | end 90 | end 91 | 92 | def url 93 | @url ||= begin 94 | response = Net::HTTP.post_form(URI.parse('https://slack.com/api/rtm.connect'), token: ENV['SLACK_TOKEN']) 95 | body = JSON.parse(response.body) 96 | 97 | URI.parse(body['url']) 98 | end 99 | end 100 | 101 | def client 102 | @client ||= ::Slack::Client.new(token: ENV['SLACK_TOKEN']) 103 | end 104 | 105 | def realtime 106 | @realtime ||= ::Ruboty::SlackRTM::Client.new(websocket_url: url) 107 | end 108 | 109 | def expose_channel_name? 110 | if @expose_channel_name.nil? 111 | @expose_channel_name = ENV['SLACK_EXPOSE_CHANNEL_NAME'] == '1' 112 | else 113 | @expose_channel_name 114 | end 115 | end 116 | 117 | def set_active 118 | client.users_setActive 119 | end 120 | 121 | # event handlers 122 | 123 | def on_message(data) 124 | user = user_info(data['user']) || {} 125 | 126 | channel = channel_info(data['channel']) 127 | 128 | if (data['subtype'] == 'bot_message' || user['is_bot']) && ENV['SLACK_IGNORE_BOT_MESSAGE'] == '1' 129 | return 130 | end 131 | 132 | if channel 133 | return if channel['name'] == (ENV['SLACK_GENERAL_NAME'] || 'general') && ENV['SLACK_IGNORE_GENERAL'] == '1' 134 | 135 | channel_to = expose_channel_name? ? "##{channel['name']}" : channel['id'] 136 | else # direct message 137 | channel_to = data['channel'] 138 | end 139 | 140 | message_info = { 141 | from: data['channel'], 142 | from_name: user['name'], 143 | to: channel_to, 144 | channel: channel, 145 | user: user, 146 | time: Time.at(data['ts'].to_f) 147 | } 148 | 149 | text, mention_to = extract_mention(data['text']) 150 | robot.receive(message_info.merge(body: text, mention_to: mention_to)) 151 | 152 | (data['attachments'] || []).each do |attachment| 153 | body, body_mention_to = extract_mention(attachment['fallback'] || "#{attachment['text']} #{attachment['pretext']}".strip) 154 | 155 | unless body.empty? 156 | robot.receive(message_info.merge(body: body, mention_to: body_mention_to)) 157 | end 158 | end 159 | end 160 | 161 | def on_channel_change(data) 162 | make_channels_cache 163 | end 164 | alias_method :on_channel_deleted, :on_channel_change 165 | alias_method :on_channel_renamed, :on_channel_change 166 | alias_method :on_channel_archived, :on_channel_change 167 | alias_method :on_channel_unarchived, :on_channel_change 168 | 169 | def on_user_change(data) 170 | user = data['user'] || data['bot'] 171 | @user_info_caches[user['id']] = user 172 | end 173 | alias_method :on_bot_added, :on_user_change 174 | alias_method :on_bot_changed, :on_user_change 175 | 176 | def extract_mention(text) 177 | mention_to = [] 178 | 179 | text = (text || '').gsub(/\<\@(?[0-9A-Z]+)(?:\|(?[^>]+))?\>/) do |_| 180 | name = Regexp.last_match[:name] 181 | 182 | unless name 183 | user = user_info(Regexp.last_match[:uid]) 184 | 185 | mention_to << user 186 | 187 | name = user['name'] 188 | end 189 | 190 | "@#{name}" 191 | end 192 | 193 | text.gsub!(/\[0-9A-Z]+)(?:\|(?[^>]+))?\>/) do |_| 194 | handle = Regexp.last_match[:handle] 195 | 196 | unless handle 197 | handle = usergroup_info(Regexp.last_match[:usergroup_id]) 198 | end 199 | 200 | "#{handle}" 201 | end 202 | 203 | text.gsub!(/\[^>|@]+)(\|\@[^>]+)?\>/) do |_| 204 | "@#{Regexp.last_match[:special]}" 205 | end 206 | 207 | text.gsub!(/\<((?[^>|]+)(?:\|(?[^>]*))?)\>/) do |_| 208 | Regexp.last_match[:ref] || Regexp.last_match[:link] 209 | end 210 | 211 | 212 | text.gsub!(/\#(?[A-Z0-9]+)/) do |_| 213 | room_id = Regexp.last_match[:room_id] 214 | msg = "##{room_id}" 215 | 216 | if channel = channel_info(room_id) 217 | msg = "##{channel['name']}" 218 | end 219 | 220 | msg 221 | end 222 | 223 | [CGI.unescapeHTML(text), mention_to] 224 | end 225 | 226 | def resolve_send_mention(text) 227 | text = text.to_s 228 | text.gsub!(/@(?[0-9a-z._-]+)/) do |_| 229 | mention = Regexp.last_match[:mention] 230 | msg = "@#{mention}" 231 | 232 | @user_info_caches.each_pair do |id, user| 233 | if user['name'].downcase == mention.downcase 234 | msg = "<@#{id}>" 235 | end 236 | end 237 | 238 | msg 239 | end 240 | 241 | text.gsub!(/@(?(?:everyone|group|channel|here))/) do |_| 242 | "" 243 | end 244 | 245 | text.gsub!(/@(?[0-9a-z._-]+)/) do |_| 246 | subteam_name = Regexp.last_match[:subteam_name] 247 | msg = "@#{subteam_name}" 248 | 249 | @usergroup_info_caches.each_pair do |id, usergroup| 250 | if usergroup && usergroup['handle'] == subteam_name 251 | msg = "" 252 | end 253 | end 254 | msg 255 | end 256 | 257 | text.gsub!(/\#(?[a-z0-9_-]+)/) do |_| 258 | room_id = Regexp.last_match[:room_id] 259 | msg = "##{room_id}" 260 | 261 | @channel_info_caches.each_pair do |id, channel| 262 | if channel && channel['name'] == room_id 263 | msg = "<##{id}|#{room_id}>" 264 | end 265 | end 266 | 267 | msg 268 | end 269 | 270 | text 271 | end 272 | 273 | def make_users_cache 274 | resp = client.users_list 275 | if resp['ok'] 276 | resp['members'].each do |user| 277 | @user_info_caches[user['id']] = user 278 | end 279 | end 280 | end 281 | 282 | def make_channels_cache 283 | resp = client.channels_list 284 | if resp['ok'] 285 | resp['channels'].each do |channel| 286 | @channel_info_caches[channel['id']] = channel 287 | end 288 | end 289 | end 290 | 291 | def make_usergroups_cache 292 | resp = client.get("usergroups.list") 293 | if resp['ok'] 294 | resp['usergroups'].each do |usergroup| 295 | @usergroup_info_caches[usergroup['id']] = usergroup 296 | end 297 | end 298 | end 299 | 300 | def user_info(user_id) 301 | return {} if user_id.to_s.empty? 302 | 303 | @user_info_caches[user_id] ||= begin 304 | resp = client.users_info(user: user_id) 305 | 306 | resp['user'] 307 | end 308 | end 309 | 310 | def channel_info(channel_id) 311 | @channel_info_caches[channel_id] ||= begin 312 | resp = case channel_id 313 | when /^C/ 314 | client.channels_info(channel: channel_id) 315 | else 316 | {} 317 | end 318 | 319 | resp['channel'] 320 | end 321 | end 322 | 323 | def resolve_channel_id(name) 324 | ret_id = nil 325 | @channel_info_caches.each_pair do |id, channel| 326 | if channel['name'] == name 327 | ret_id = id 328 | break 329 | end 330 | end 331 | return ret_id 332 | end 333 | 334 | def usergroup_info(usergroup_id) 335 | @usergroup_info_caches[usergroup_id] || begin 336 | make_usergroups_cache 337 | @usergroup_info_caches[usergroup_id] 338 | end 339 | end 340 | end 341 | end 342 | end 343 | --------------------------------------------------------------------------------