slacks 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 789c9ac13d517eb62b249b38f9bdd781295e14e4
4
+ data.tar.gz: d05efc4b0f64cdc399324b52257f3fd88884f19d
5
+ SHA512:
6
+ metadata.gz: df416c6fdb36216f70a5d76537aef833ea11e1996f186aa9caaad4d0bf55e30ef7629a0e528fab52845e8b933e6ea500f2635dbc0ec6176f6fb8f710f5af409d
7
+ data.tar.gz: 02e5185cc2714d40259aedd3b4211d4b764ac5c6ca4d2bc7892f5cb20e3989fbe8c79b5d729ab01e5ceb88b390850083405ec5f49b7d7b4f2cf9944fb9d67f9d
data/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /.ruby-version
4
+ /Gemfile.lock
5
+ /_yardoc/
6
+ /coverage/
7
+ /doc/
8
+ /pkg/
9
+ /spec/reports/
10
+ /tmp/
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
4
+ before_install: gem install bundler -v 1.11.2
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in slacks.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Bob Lail
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # Slacks
2
+
3
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/slacks`. To experiment with that code, run `bin/console` for an interactive prompt.
4
+
5
+ TODO: Delete this and the text above, and describe your gem
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'slacks'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install slacks
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/slacks.
36
+
37
+
38
+ ## License
39
+
40
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
41
+
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "slacks"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,15 @@
1
+ module Slacks
2
+ class BotUser
3
+ attr_reader :id, :name
4
+
5
+ def initialize(data)
6
+ @id = data.fetch("id")
7
+ @name = data.fetch("name")
8
+ end
9
+
10
+ def to_s
11
+ "@#{name}"
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,79 @@
1
+ module Slacks
2
+ class Channel
3
+ attr_reader :id, :name, :type
4
+
5
+ def initialize(session, attributes={})
6
+ @session = session
7
+ @id = attributes["id"]
8
+ @name = attributes["name"]
9
+ @type = :channel
10
+ @type = :group if attributes["is_group"]
11
+ @type = :direct_message if attributes["is_im"]
12
+ end
13
+
14
+ def reply(*messages)
15
+ messages.flatten!
16
+ return unless messages.any?
17
+
18
+ first_message = messages.shift
19
+ message_options = {}
20
+ message_options = messages.shift if messages.length == 1 && messages[0].is_a?(Hash)
21
+ session.slack.send_message(first_message, message_options.merge(channel: id))
22
+
23
+ messages.each do |message|
24
+ sleep message.length / session.typing_speed
25
+ session.slack.send_message(message, channel: id)
26
+ end
27
+ end
28
+ alias :say :reply
29
+
30
+ def random_reply(replies)
31
+ if replies.is_a?(Hash)
32
+ weights = replies.values
33
+ unless weights.reduce(&:+) == 1.0
34
+ raise ArgumentError, "Reply weights don't add up to 1.0"
35
+ end
36
+
37
+ draw = rand
38
+ sum = 0
39
+ pick = nil
40
+ replies.each do |reply, weight|
41
+ pick = reply unless sum > draw
42
+ sum += weight
43
+ end
44
+ reply pick
45
+ else
46
+ reply replies.sample
47
+ end
48
+ end
49
+
50
+ def direct_message?
51
+ type == :direct_message
52
+ end
53
+ alias :dm? :direct_message?
54
+ alias :im? :direct_message?
55
+
56
+ def private_group?
57
+ type == :group
58
+ end
59
+ alias :group? :private_group?
60
+ alias :private? :private_group?
61
+
62
+ def guest?
63
+ false
64
+ end
65
+
66
+ def inspect
67
+ "<Slacks::Channel id=\"#{id}\" name=\"#{name}\">"
68
+ end
69
+
70
+ def to_s
71
+ return name if private?
72
+ return "@#{name}" if direct_message?
73
+ "##{name}"
74
+ end
75
+
76
+ private
77
+ attr_reader :session
78
+ end
79
+ end
@@ -0,0 +1,287 @@
1
+ require "slacks/bot_user"
2
+ require "slacks/team"
3
+ require "slacks/channel"
4
+ require "slacks/guest_channel"
5
+ require "slacks/conversation"
6
+ require "slacks/driver"
7
+ require "slacks/rtm_event"
8
+ require "slacks/listener"
9
+ require "slacks/message"
10
+ require "slacks/user"
11
+ require "slacks/errors"
12
+ require "faraday"
13
+ require "faraday/raise_errors"
14
+
15
+ module Slacks
16
+ class Connection
17
+ attr_reader :team, :bot, :token
18
+
19
+ EVENT_MESSAGE = "message".freeze
20
+ EVENT_GROUP_JOINED = "group_joined".freeze
21
+ EVENT_USER_JOINED = "team_join".freeze
22
+
23
+ def initialize(session, token)
24
+ @user_ids_dm_ids = {}
25
+ @users_by_id = {}
26
+ @user_id_by_name = {}
27
+ @groups_by_id = {}
28
+ @group_id_by_name = {}
29
+ @channels_by_id = {}
30
+ @channel_id_by_name = {}
31
+
32
+ @session = session
33
+ @token = token
34
+ end
35
+
36
+
37
+
38
+ def send_message(message, options={})
39
+ channel = options.fetch(:channel) { raise ArgumentError, "Missing parameter :channel" }
40
+ attachments = Array(options[:attachments])
41
+ params = {
42
+ channel: to_channel_id(channel),
43
+ text: message,
44
+ as_user: true, # post as the authenticated user (rather than as slackbot)
45
+ link_names: 1} # find and link channel names and user names
46
+ params.merge!(attachments: MultiJson.dump(attachments)) if attachments.any?
47
+ params.merge!(options.select { |key, _| [:username, :as_user, :parse, :link_names,
48
+ :unfurl_links, :unfurl_media, :icon_url, :icon_emoji].member?(key) })
49
+ api("chat.postMessage", params)
50
+ end
51
+
52
+ def add_reaction(emojis, message)
53
+ Array(emojis).each do |emoji|
54
+ api("reactions.add", {
55
+ name: emoji.gsub(/^:|:$/, ""),
56
+ channel: message.channel.id,
57
+ timestamp: message.timestamp })
58
+ end
59
+ end
60
+
61
+
62
+
63
+ def listen!
64
+ response = api("rtm.start")
65
+
66
+ unless response["ok"]
67
+ raise MigrationInProgress if response["error"] == "migration_in_progress"
68
+ raise ResponseError.new(response, response["error"])
69
+ end
70
+
71
+ store_context!(response)
72
+
73
+ client = Slacks::Driver.new
74
+ client.connect_to websocket_url
75
+
76
+ client.on(:error) do |event|
77
+ raise ConnectionError.new(event)
78
+ end
79
+
80
+ client.on(:message) do |data|
81
+ case data["type"]
82
+ when EVENT_GROUP_JOINED
83
+ group = data["channel"]
84
+ @groups_by_id[group["id"]] = group
85
+ @group_id_by_name[group["name"]] = group["id"]
86
+
87
+ when EVENT_USER_JOINED
88
+ user = data["user"]
89
+ @users_by_id[user["id"]] = user
90
+ @user_id_by_name[user["name"]] = user["id"]
91
+
92
+ when EVENT_MESSAGE
93
+ # Don't respond to things that this bot said
94
+ next if data["user"] == bot.id
95
+ # ...or to messages with no text
96
+ next if data["text"].nil? || data["text"].empty?
97
+ yield data
98
+ end
99
+ end
100
+
101
+ client.main_loop
102
+
103
+ rescue EOFError
104
+ # Slack hung up on us, we'll ask for a new WebSocket URL and reconnect.
105
+ session.error "Websocket Driver received EOF; reconnecting"
106
+ retry
107
+ end
108
+
109
+
110
+
111
+ def channels
112
+ user_id_by_name.keys + group_id_by_name.keys + channel_id_by_name.keys
113
+ end
114
+
115
+
116
+
117
+ def find_channel(id)
118
+ case id
119
+ when /^U/ then find_user(id)
120
+ when /^D/
121
+ user = find_user(get_user_id_for_dm(id))
122
+ Slacks::Channel.new session, {
123
+ "id" => id,
124
+ "is_im" => true,
125
+ "name" => user.username }
126
+ when /^G/
127
+ Slacks::Channel.new session, groups_by_id.fetch(id) do
128
+ raise ArgumentError, "Unable to find a group with the ID #{id.inspect}"
129
+ end
130
+ else
131
+ Slacks::Channel.new session, channels_by_id.fetch(id) do
132
+ raise ArgumentError, "Unable to find a channel with the ID #{id.inspect}"
133
+ end
134
+ end
135
+ end
136
+
137
+ def find_user(id)
138
+ Slacks::User.new session, users_by_id.fetch(id) do
139
+ raise ArgumentError, "Unable to find a user with the ID #{id.inspect}"
140
+ end
141
+ end
142
+
143
+
144
+
145
+ def user_exists?(username)
146
+ return false if username.nil?
147
+ to_user_id(username).present?
148
+ rescue ArgumentError
149
+ false
150
+ end
151
+
152
+ def users
153
+ fetch_users! if @users_by_id.empty?
154
+ @users_by_id.values
155
+ end
156
+
157
+
158
+
159
+ private
160
+ attr_reader :session,
161
+ :user_ids_dm_ids,
162
+ :users_by_id,
163
+ :user_id_by_name,
164
+ :groups_by_id,
165
+ :group_id_by_name,
166
+ :channels_by_id,
167
+ :channel_id_by_name,
168
+ :websocket_url
169
+
170
+
171
+
172
+ def store_context!(response)
173
+ @websocket_url = response.fetch("url")
174
+ @bot = BotUser.new(response.fetch("self"))
175
+ @team = Team.new(response.fetch("team"))
176
+
177
+ @channels_by_id = Hash[response.fetch("channels").map { |attrs| [attrs.fetch("id"), attrs] }]
178
+ @channel_id_by_name = Hash[response.fetch("channels").map { |attrs| ["##{attrs.fetch("name")}", attrs.fetch("id")] }]
179
+
180
+ @users_by_id = Hash[response.fetch("users").map { |attrs| [attrs.fetch("id"), attrs] }]
181
+ @user_id_by_name = Hash[response.fetch("users").map { |attrs| ["@#{attrs.fetch("name")}", attrs.fetch("id")] }]
182
+
183
+ @groups_by_id = Hash[response.fetch("groups").map { |attrs| [attrs.fetch("id"), attrs] }]
184
+ @group_id_by_name = Hash[response.fetch("groups").map { |attrs| [attrs.fetch("name"), attrs.fetch("id")] }]
185
+ rescue KeyError
186
+ raise ResponseError.new(response, $!.message)
187
+ end
188
+
189
+
190
+
191
+ def to_channel_id(name)
192
+ return name if name =~ /^[DGC]/ # this already looks like a channel id
193
+ return get_dm_for_username(name) if name.start_with?("@")
194
+ return to_group_id(name) unless name.start_with?("#")
195
+
196
+ channel_id_by_name[name] || fetch_channels![name] || missing_channel!(name)
197
+ end
198
+
199
+ def to_group_id(name)
200
+ group_id_by_name[name] || fetch_groups![name] || missing_group!(name)
201
+ end
202
+
203
+ def to_user_id(name)
204
+ user_id_by_name[name] || fetch_users![name] || missing_user!(name)
205
+ end
206
+
207
+ def get_dm_for_username(name)
208
+ get_dm_for_user_id to_user_id(name)
209
+ end
210
+
211
+ def get_dm_for_user_id(user_id)
212
+ channel_id = user_ids_dm_ids[user_id] ||= begin
213
+ response = api("im.open", user: user_id)
214
+ raise ArgumentError, "Unable to direct message the user #{user_id.inspect}: #{response["error"]}" unless response["ok"]
215
+ response["channel"]["id"]
216
+ end
217
+ raise ArgumentError, "Unable to direct message the user #{user_id.inspect}" unless channel_id
218
+ channel_id
219
+ end
220
+
221
+
222
+
223
+ def fetch_channels!
224
+ response = api("channels.list")
225
+ @channels_by_id = response["channels"].index_by { |attrs| attrs["id"] }
226
+ @channel_id_by_name = Hash[response["channels"].map { |attrs| ["##{attrs["name"]}", attrs["id"]] }]
227
+ end
228
+
229
+ def fetch_groups!
230
+ response = api("groups.list")
231
+ @groups_by_id = response["groups"].index_by { |attrs| attrs["id"] }
232
+ @group_id_by_name = Hash[response["groups"].map { |attrs| [attrs["name"], attrs["id"]] }]
233
+ end
234
+
235
+ def fetch_users!
236
+ response = api("users.list")
237
+ @users_by_id = response["members"].index_by { |attrs| attrs["id"] }
238
+ @user_id_by_name = Hash[response["members"].map { |attrs| ["@#{attrs["name"]}", attrs["id"]] }]
239
+ end
240
+
241
+
242
+
243
+ def missing_channel!(name)
244
+ raise ArgumentError, "Couldn't find a channel named #{name}"
245
+ end
246
+
247
+ def missing_group!(name)
248
+ raise ArgumentError, "Couldn't find a private group named #{name}"
249
+ end
250
+
251
+ def missing_user!(name)
252
+ raise ArgumentError, "Couldn't find a user named #{name}"
253
+ end
254
+
255
+
256
+
257
+ def get_user_id_for_dm(dm)
258
+ user_id = user_ids_dm_ids.key(dm)
259
+ unless user_id
260
+ response = api("im.list")
261
+ user_ids_dm_ids.merge! Hash[response["ims"].map { |attrs| attrs.values_at("user", "id") }]
262
+ user_id = user_ids_dm_ids.key(dm)
263
+ end
264
+ raise ArgumentError, "Unable to find a user for the direct message ID #{dm.inspect}" unless user_id
265
+ user_id
266
+ end
267
+
268
+
269
+
270
+ def api(command, options={})
271
+ response = http.post(command, options.merge(token: token))
272
+ MultiJson.load(response.body)
273
+
274
+ rescue MultiJson::ParseError
275
+ $!.additional_information[:response_body] = response.body
276
+ $!.additional_information[:response_status] = response.status
277
+ raise
278
+ end
279
+
280
+ def http
281
+ @http ||= Faraday.new(url: "https://slack.com/api").tap do |connection|
282
+ connection.use Faraday::RaiseErrors
283
+ end
284
+ end
285
+
286
+ end
287
+ end
@@ -0,0 +1,48 @@
1
+ require "thread_safe"
2
+
3
+ module Slacks
4
+ class Conversation
5
+
6
+ def initialize(session, channel, sender)
7
+ @session = session
8
+ raise NotInChannelError, channel if channel.guest?
9
+
10
+ @channel = channel
11
+ @sender = sender
12
+ @listeners = ThreadSafe::Array.new
13
+ end
14
+
15
+ def listen_for(matcher, flags=[], &block)
16
+ session.listen_for(matcher, flags, &block).tap do |listener|
17
+ listener.conversation = self
18
+ listeners.push listener
19
+ end
20
+ end
21
+
22
+ def includes?(e)
23
+ e.channel.id == channel.id && e.sender.id == sender.id
24
+ end
25
+
26
+ def reply(*messages)
27
+ channel.reply(*messages)
28
+ end
29
+ alias :say :reply
30
+
31
+ def ask(question, expect: nil)
32
+ listen_for(expect) do |e|
33
+ e.stop_listening!
34
+ yield e
35
+ end
36
+
37
+ reply question
38
+ end
39
+
40
+ def end!
41
+ listeners.each(&:stop_listening!)
42
+ end
43
+
44
+ private
45
+ attr_reader :session, :channel, :sender, :listeners
46
+
47
+ end
48
+ end
@@ -0,0 +1,7 @@
1
+ # Allow exception handlers to add information
2
+ # to an exception for upstream
3
+ class Exception
4
+ def additional_information
5
+ @additional_information ||= {}
6
+ end
7
+ end
@@ -0,0 +1,91 @@
1
+ require "multi_json"
2
+ require "socket"
3
+ require "websocket/driver"
4
+
5
+ # Adapted from https://github.com/mackwic/slack-rtmapi
6
+
7
+ module Slacks
8
+ class Driver
9
+ attr_accessor :stop
10
+
11
+ def initialize
12
+ @has_been_init = false
13
+ @stop = false
14
+ @callbacks = {}
15
+ end
16
+
17
+ VALID = [:open, :message, :error].freeze
18
+ def on(type, &block)
19
+ unless VALID.include? type
20
+ raise ArgumentError.new "Client#on accept one of #{VALID.inspect}"
21
+ end
22
+
23
+ callbacks[type] = block
24
+ end
25
+
26
+ # This init has been delayed because the SSL handshake is a blocking and
27
+ # expensive call
28
+ def connect_to(url)
29
+ raise "Already been init" if @has_been_init
30
+ url = URI(url)
31
+ raise ArgumentError.new ":url must be a valid websocket secure url!" unless url.scheme == "wss"
32
+
33
+ @socket = OpenSSL::SSL::SSLSocket.new(TCPSocket.new url.host, 443)
34
+ socket.connect # costly and blocking !
35
+
36
+ internalWrapper = (Struct.new :url, :socket do
37
+ def write(*args)
38
+ self.socket.write(*args)
39
+ end
40
+ end).new url.to_s, socket
41
+
42
+ # this, also, is costly and blocking
43
+ @driver = WebSocket::Driver.client internalWrapper
44
+ driver.on :open do
45
+ @connected = true
46
+ unless callbacks[:open].nil?
47
+ callbacks[:open].call
48
+ end
49
+ end
50
+
51
+ driver.on :error do |event|
52
+ @connected = false
53
+ unless callbacks[:error].nil?
54
+ callbacks[:error].call event
55
+ end
56
+ end
57
+
58
+ driver.on :message do |event|
59
+ data = MultiJson.load event.data
60
+ unless callbacks[:message].nil?
61
+ callbacks[:message].call data
62
+ end
63
+ end
64
+
65
+ driver.start
66
+ @has_been_init = true
67
+ end
68
+
69
+ def connected?
70
+ @connected || false
71
+ end
72
+
73
+ # All the polling work is done here
74
+ def inner_loop
75
+ return if @stop
76
+
77
+ data = @socket.readpartial 4096
78
+ driver.parse data unless data.nil? or data.empty?
79
+ end
80
+
81
+ def main_loop
82
+ loop do
83
+ inner_loop
84
+ end
85
+ end
86
+
87
+ private
88
+ attr_reader :url, :socket, :driver, :callbacks
89
+
90
+ end
91
+ end
@@ -0,0 +1,33 @@
1
+ module Slacks
2
+ class MigrationInProgress < RuntimeError
3
+ def initialize
4
+ super "Team is being migrated between servers. Try the request again in a few seconds."
5
+ end
6
+ end
7
+
8
+ class ResponseError < RuntimeError
9
+ def initialize(response, message)
10
+ super message
11
+ additional_information[:response] = response
12
+ end
13
+ end
14
+
15
+ class ConnectionError < RuntimeError
16
+ def initialize(event)
17
+ super "There was a connection error in the WebSocket"
18
+ additional_information[:event] = event
19
+ end
20
+ end
21
+
22
+ class AlreadyRespondedError < RuntimeError
23
+ def initialize(message=nil)
24
+ super message || "You have already replied to this Slash Command; you can only reply once"
25
+ end
26
+ end
27
+
28
+ class NotInChannelError < RuntimeError
29
+ def initialize(channel)
30
+ super "The bot is not in the channel #{channel} and cannot reply"
31
+ end
32
+ end
33
+ end