slack_message 1.7.1 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3438c93abd1488bafc0ee9f33df94674290b68bd94110e8f2e601d95d3254bc
4
- data.tar.gz: 5eee7e715e66042ab6c3ccbc47eedba3e2ddddacd327e1096e3a2d3476b076b0
3
+ metadata.gz: e92b2faa8e8b8118a0e168cef488d64b94255c841d774909647ede3d9a9faf6b
4
+ data.tar.gz: d494af801908aa9f5cf25141b012df26e23a69f8ef9389c3c8bb580c46641133
5
5
  SHA512:
6
- metadata.gz: ddc5ace3b66e6977b164accfeec16c67450ed8fbd182939e85aa1d56c251319f203c4527d75d410c2858bbfa7540889a83ed08aed12890488944077edf1867ed
7
- data.tar.gz: 16601f240ee826b8cadbd03429b5fd431ba21a5b159c1ca3c70932da362b54388c32115544bf782c45f9e2b1c603801d82e2fd6827e7020abfcaed18669601ee
6
+ metadata.gz: 6fceee6aeb36f7fc6b855c1ce90f647b67ce480c78e62ec2fe45bbfc347e6c9efa6ca910804c51b0edb12321953d96014ac137572d2140328109444f68512ca4
7
+ data.tar.gz: a9fe4697d85ac2f91197a39dacc46d1740c251f8472dcb91e8ba2132caac5c9f029cb5f88894f39542e1436ce554562d87108661863c2574655e64c866c5115e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.1.0] - 2021-11-01
6
+ - Change to use Slack Apps for all profiles. This should allow growth toward
7
+ updating messages, working with interactive messages etc.
8
+ - As a result, allow custom icons per profile / message.
9
+ - When sending a message, the first `text` block is used for the notification
10
+ content. Should resolve "this content cannot be displayed".
11
+ - Significant restructuring of README.
12
+
13
+ ## [2.0.0] - 2021-11-01
14
+ - Yeah that was all broken.
15
+
16
+ ## [1.9.0] - 2021-10-27
17
+ - Add many validations so that trying to add e.g. empty text won't succeed.
18
+ Previously that would be accepted but return `invalid_blocks` from the API.
19
+
20
+ ## [1.8.1] - 2021-10-08
21
+ - Cleaned that rspec code a bit, added more matchers for real world use.
22
+
23
+ ## [1.8.0] - 2021-10-07
24
+ - Added the ability to test in RSpec.
25
+
5
26
  ## [1.7.1] - 2021-10-06
6
27
  - Fixed literally a syntax issue.
7
28
  - Fixed specs.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- slack_message (1.7.1)
4
+ slack_message (2.1.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -3,8 +3,7 @@ SlackMessage: a Friendly DSL for Slack
3
3
 
4
4
  SlackMessage is a wrapper over the [Block Kit
5
5
  API](https://app.slack.com/block-kit-builder/) to make it easy to read and
6
- write messages to slack in your ruby application. It has zero dependencies and
7
- is built to be opinionated to keep your configuration needs low.
6
+ write messages to slack in your ruby application.
8
7
 
9
8
  Posting a message to Slack should be this easy:
10
9
 
@@ -16,80 +15,105 @@ end
16
15
 
17
16
  To install, just add `slack_message` to your bundle and you're ready to go.
18
17
 
18
+ Opinionated Stances
19
+ ------------
20
+
21
+ Slack's API has a lot of options available to you! But this gem takes some
22
+ opinionated stances on how to make use of that API. For instance:
23
+
24
+ * No dependencies. Your lockfile is enough of a mess already.
25
+ * Webhooks are passé. Only Slack Apps are supported now.
26
+ * Unless you request otherwise, text is always rendered using `mrkdwn`. If you
27
+ want plaintext, you'll need to ask for it. Same for the `emoji` flag.
28
+ * As many API semantics as possible are hidden. For instance, if you post to
29
+ something that looks like an email address, `slack_message` is going to try to
30
+ look it up as an email address.
31
+ * A few little hacks on the block syntax, such as adding a `blank_line` (which
32
+ doesn't exist in the API), or leading spaces.
33
+ * Configuration should be as simple as possible. But as much work as possible
34
+ should be moved from callers into configuration.
19
35
 
20
36
  Usage
21
37
  ------------
22
38
 
23
39
  ### Configuration
24
40
 
25
- To get started, you'll need to configure at least one profile to use to post
26
- to slack. Get a [Webhook URL](https://slack.com/help/articles/115005265063-Incoming-webhooks-for-Slack)
27
- from Slack and configure it like this:
41
+ To get started, you'll need to create a Slack App with some appropriate
42
+ permissions. It used to be possible to use the Webhook API, but that's long
43
+ since been deprecated, and apps are pretty [straightforward to
44
+ create](https://api.slack.com/tutorials/tracks/getting-a-token).
45
+
46
+ Generally, make sure your token has permissions for `users:read` and `chat:write`.
28
47
 
29
48
  ```ruby
30
49
  SlackMessage.configure do |config|
31
- webhook_url = 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
50
+ api_token = 'xoxb-11111111111-2222222222-33333333333333333'
32
51
 
33
- config.add_profile(name: 'Slack Notifier', url: webhook_url)
52
+ config.add_profile(api_token: api_token)
34
53
  end
35
54
  ```
36
55
 
37
- You should probably keep that webhook in a safe place like `ENV`. If using this
38
- gem with Rails, place this code in somewhere like
39
- `config/initializers/slack_message.rb`.
56
+ You should keep your token in a safe place like `ENV`. If using this gem with
57
+ Rails, place this code in somewhere like `config/initializers/slack_message.rb`.
40
58
 
41
59
  #### Additional Profiles
42
60
 
43
- If you want to post to multiple different webhook addresses (say, if you have
44
- several different bots that post to different channels as different identities),
45
- you can configure those profiles as well, by giving each of them a name:
61
+ If your app uses slack messages for several different purposes, it's common to
62
+ want to post to different channels as different names / icons / etc. To do that
63
+ more easily and consistently, you can specify multiple profiles:
46
64
 
47
65
  ```ruby
48
66
  SlackMessage.configure do |config|
67
+ api_token = 'xoxb-11111111111-2222222222-33333333333333333'
68
+
49
69
  # default profile
50
- config.add_profile(name: 'Slack Notifier', url: ENV['SLACK_WEBHOOK_URL'])
70
+ config.add_profile(api_token: api_token, name: 'Slack Notifier')
51
71
 
52
72
  # additional profiles (see below for usage)
53
- config.add_profile(:prod_alert_bot, name: 'Prod Alert Bot', url: ENV['SLACK_PROD_ALERT_WEBHOOK_URL'])
54
- config.add_profile(:sidekiq_bot, name: 'Sidekiq Bot', url: ENV['SLACK_SIDEKIQ_WEBHOOK_URL'])
73
+ config.add_profile(:prod_alert_bot,
74
+ name: 'Prod Alert Bot'
75
+ icon: ':mooseandsquirrel:'
76
+ )
77
+ config.add_profile(:sidekiq_bot,
78
+ api_token: ENV.fetch('SIDEKIQ_SLACK_APP_API_TOKEN'),
79
+ name: 'Sidekiq Bot',
80
+ )
55
81
  end
56
82
  ```
57
83
 
58
- If you frequently ping the same channel with the same bot, and don't want to
59
- continually specify the channel name, you can specify a default channel and
60
- post using the `post_as` method. It is otherwise identical to `post_to`, but
61
- allows you to omit the channel argument:
84
+ A number of parameters are available to make it simpler to use a profile without
85
+ specifying repetitive information. Most all have corresponding options when
86
+ composing a message:
87
+
88
+ | Config | Default | Value |
89
+ |-----------------|-----------------|-----------------------------------------------------------------|
90
+ | api_token | None | Your Slack App API Key. |
91
+ | name | From Slack App | The bot name for your message. |
92
+ | icon | From Slack App | Profile icon for your message. Specify as :emoji: or image URL. |
93
+ | default_channel | None (optional) | Channel / user to post to by default. |
94
+
95
+
96
+ Setting a `default_channel` specifically will allow you to use `post_as`, which
97
+ is a convenient shortcut for bots that repeatedly post to one channel as a
98
+ consistent identity:
62
99
 
63
100
  ```ruby
64
101
  SlackMessage.configure do |config|
65
- config.add_profile(:prod_alert_bot,
66
- name: 'Prod Alert Bot',
67
- url: ENV['SLACK_PROD_ALERT_WEBHOOK_URL'],
102
+ config.add_profile(:red_alert_bot,
103
+ api_token: ENV.fetch('SLACK_API_TOKEN'),
104
+ name: 'Red Alerts',
105
+ icon: ':klaxon:',
68
106
  default_channel: '#red_alerts'
69
107
  )
70
108
  end
71
109
 
72
- SlackMessage.post_as(:prod_alert_bot) do
110
+ SlackMessage.post_as(:red_alert_bot) do
73
111
  text ":ambulance: weeooo weeooo something went wrong"
74
112
  end
75
113
  ```
76
114
 
77
- Note that `post_as` does not allow you to choose a channel (because that's just
78
- the same as using `post_to`), so you really do have to specify `default_channel`.
79
-
80
- #### Configuring User Search
81
-
82
- Slack's API no longer allows you to send DMs to users by username. You need to
83
- look up a user's internal ID and send to that ID. Thankfully, there is a lookup
84
- by email endpoint for this. If you'd like to post messages to users by their
85
- email address, you'll need a
86
- [separate API Token](https://api.slack.com/tutorials/tracks/getting-a-token):
87
-
88
- ```ruby
89
- SlackMessage.configure do |config|
90
- config.api_token = 'xoxb-11111111111-2222222222-33333333333333333'
91
- end
92
- ```
115
+ There's no reason you can't use the same API key for several profiles. Profiles
116
+ are most useful to create consistent name / icon setups for apps with many bots.
93
117
 
94
118
  ### Posting Messages
95
119
 
@@ -104,14 +128,12 @@ end
104
128
  That's it! SlackMessage will automatically serialize for the API like this:
105
129
 
106
130
  ```json
107
- [{"type":"section","text":{"type":"mrkdwn","text":"We did it! :thumbsup:"}}]
131
+ [{"type":"section","text":{"type":"mrkdwn","text":"We did it @here! :thumbsup:"}}]
108
132
  ```
109
133
 
110
134
  Details like remembering that Slack made a mystifying decision to force you to
111
135
  request "mrkdwn", or requiring your text to be wrapped into a section are handled
112
- for you.
113
-
114
- Building up messages is meant to be as user-friendly as possible:
136
+ for you. Building up messages is meant to be as user-friendly as possible:
115
137
 
116
138
  ```ruby
117
139
  SlackMessage.build do
@@ -147,20 +169,21 @@ automatically:
147
169
  ]
148
170
  ```
149
171
 
150
- If you've configured an API key for user search (see above in configuration),
151
- it's just as easy to send messages directly to users:
172
+ It's just as easy to send messages directly to users. SlackMessage will look for
173
+ targets that are email-addressish, and look them up for you automatically:
152
174
 
153
175
  ```ruby
154
- SlackMessage.post_to('hello@joemastey.com') do
155
- text "We did it! :thumbsup:"
176
+ user_email = 'hello@joemastey.com'
177
+
178
+ SlackMessage.post_to(user_email) do
179
+ text "You specifically did it! :thumbsup:"
156
180
  end
157
181
  ```
158
182
 
159
183
  SlackMessage is able to build all kinds of rich messages for you, and has been
160
184
  a real joy to use for the author at least. To understand a bit more about the
161
- possibilities of blocks, see Slack's [Block Kit
162
- Builder](https://app.slack.com/block-kit-builder/) to understand the structure
163
- better. There are lots of options:
185
+ possibilities of blocks, you should play around with Slack's [Block Kit
186
+ Builder](https://app.slack.com/block-kit-builder/). There are lots of options:
164
187
 
165
188
  ```ruby
166
189
  SlackMessage.post_to('#general') do
@@ -195,38 +218,75 @@ For now you'll need to read a bit of the source code to get the entire API. Sorr
195
218
  working on it.
196
219
 
197
220
  If you've defined multiple profiles in configuration, you can specify which to
198
- use for your message by specifying their name:
221
+ use for your message by specifying its name:
199
222
 
200
223
  ```ruby
201
224
  SlackMessage.post_to('#general', as: :sidekiq_bot) do
202
225
  text ":octagonal_sign: A job has failed permanently and needs to be rescued."
203
- link_button "Sidekiq Dashboard", "https://yoursite.com/sidekiq", style: :danger
226
+ link_button "Sidekiq Dashboard", sidekiq_dashboard_url, style: :danger
204
227
  end
205
228
  ```
206
229
 
207
- You can also use a custom name when sending a message:
230
+ You can also override profile bot details when sending a message:
208
231
 
209
232
  ```ruby
210
233
  SlackMessage.post_to('#general') do
211
234
  bot_name "CoffeeBot"
235
+ bot_icon ":coffee:"
212
236
 
213
237
  text ":coffee::clock: Time to take a break!"
214
238
  end
215
239
  ```
216
240
 
217
- Opinionated Stances
218
- ------------
241
+ ### Testing
219
242
 
220
- Slack's API has a lot of options available to you! But this gem takes some
221
- opinionated stances on how to make use of that API. For instance:
243
+ You can do some basic testing against SlackMessage, at least if you use RSpec!
244
+ You'll need to require and include the testing behavior like this, in your
245
+ spec_helper file:
222
246
 
223
- * Unless you request otherwise, text is always rendered using `mrkdwn`. If you
224
- want plaintext, you'll need to ask for it.
225
- * Generally, same goes for the `emoji` flag on almost every text element.
226
- * It's possible to ask for a `blank_line` in sections, even though that concept
227
- isn't real. In this case, a text line containing only an emspace is rendered.
228
- * It's easy to configure a bot for consistent name / channel use. My previous
229
- use of SlackNotifier led to frequently inconsistent names.
247
+ ```ruby
248
+ require 'slack_message/rspec'
249
+
250
+ RSpec.configure do |config|
251
+ include SlackMessage::RSpec
252
+
253
+ # your other config
254
+ end
255
+ ```
256
+
257
+ This will stop API calls for posting messages, and will allow you access to
258
+ some custom matchers:
259
+
260
+ ```ruby
261
+ expect {
262
+ SlackMessage.post_to('#general') { text "foo" }
263
+ }.to post_slack_message_to('#general').with_content_matching(/foo/)
264
+
265
+ expect {
266
+ SlackMessage.post_as(:schmoebot) { text "foo" }
267
+ }.to post_slack_message_as(:schmoebot)
268
+
269
+ expect {
270
+ SlackMessage.post_as(:schmoebot) { text "foo" }
271
+ }.to post_slack_message_as('Schmoe Bot')
272
+
273
+ expect {
274
+ SlackMessage.post_as(:schmoebot) { text "foo" }
275
+ }.to post_slack_message_with_icon(':schmoebot:')
276
+
277
+ expect {
278
+ SlackMessage.post_as(:schmoebot) { text "foo" }
279
+ }.to post_slack_message_with_icon_matching(/gravatar/)
280
+
281
+ expect {
282
+ SlackMessage.post_to('#general') { text "foo" }
283
+ }.to post_to_slack
284
+ ```
285
+
286
+ Be forewarned, I'm frankly not that great at more complicated RSpec matchers,
287
+ so I'm guessing there are some bugs. Also, because the content of a message
288
+ gets turned into a complex JSON object, matching against content isn't capable
289
+ of very complicated regexes.
230
290
 
231
291
  What it Doesn't Do
232
292
  ------------
@@ -234,25 +294,25 @@ What it Doesn't Do
234
294
  This gem is intended to stay fairly simple. Other gems have lots of config
235
295
  options and abilities, which is wonderful, but overall complicates usage. If
236
296
  you want to add a feature, open an issue on Github first to see if it's likely
237
- to be merged.
238
-
239
- Since this gem was built out of an existing need that _didn't_ include most of
240
- the block API, I'd be inclined to merge features that sustainably expand the
241
- DSL to include more of the block API itself.
297
+ to be merged. This gem was built out of an existing need that _didn't_ include
298
+ most of the block API, but I'd be inclined to merge features that sustainably
299
+ expand the DSL to include more useful features.
242
300
 
243
- Also, some behaviors that are still planned but not yet added:
301
+ Some behaviors that are still planned but not yet added:
244
302
 
245
303
  * some API documentation amirite?
246
304
  * allow custom http_options in configuration
247
305
  * more of BlockKit's options
248
306
  * any interactive elements at all (I don't understand them yet)
249
- * more interesting return types for your message
250
- * richer text formatting (ul is currently a hack)
307
+ * editing / updating messages
308
+ * multiple recipients
309
+ * more interesting return types for your message (probably related to the above)
310
+ * richer text formatting (for instance, `ul` is currently a hack)
251
311
 
252
312
  Contributing
253
313
  ------------
254
314
 
255
- Contributions are very welcome. Fork, fix, submit pulls.
315
+ Contributions are very welcome. Fork, fix, submit pull.
256
316
 
257
317
  Contribution is expected to conform to the [Contributor Covenant](https://github.com/jmmastey/slack_message/blob/master/CODE_OF_CONDUCT.md).
258
318
 
@@ -3,13 +3,11 @@ require 'net/https'
3
3
  require 'json'
4
4
 
5
5
  class SlackMessage::Api
6
- def self.user_id_for(email)
7
- token = SlackMessage.configuration.api_token
8
-
6
+ def self.user_id_for(email, profile)
9
7
  uri = URI("https://slack.com/api/users.lookupByEmail?email=#{email}")
10
8
  request = Net::HTTP::Get.new(uri).tap do |req|
11
- req['Authorization'] = "Bearer #{token}"
12
- req['Content-type'] = "application/json"
9
+ req['Authorization'] = "Bearer #{profile[:api_token]}"
10
+ req['Content-type'] = "application/json; charset=utf-8"
13
11
  end
14
12
 
15
13
  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
@@ -38,30 +36,57 @@ class SlackMessage::Api
38
36
  end
39
37
 
40
38
  def self.post(payload, target, profile)
41
- profile[:url] = profile[:url]
42
-
43
- uri = URI.parse(profile[:url])
44
39
  params = {
45
40
  channel: target,
46
- username: profile[:name],
47
- blocks: payload
48
- }.to_json
41
+ username: payload.custom_bot_name || profile[:name],
42
+ blocks: payload.render,
43
+ text: payload.notification_text,
44
+ }
45
+
46
+ icon = payload.custom_bot_icon || profile[:icon]
47
+ if icon =~ /^:\w+:$/
48
+ params[:icon_emoji] = icon
49
+ elsif icon =~ /^(https?:\/\/)?[0-9a-z]+\.[-_0-9a-z]+/ # very naive regex, I know. it'll be fine.
50
+ params[:icon_url] = icon
51
+ elsif !(icon.nil? || icon == '')
52
+ raise ArgumentError, "Couldn't figure out icon '#{icon}'. Try :emoji: or a URL."
53
+ end
49
54
 
50
- response = Net::HTTP.post_form uri, { payload: params }
55
+ response = post_message(profile, params)
56
+ body = JSON.parse(response.body)
57
+ error = body.fetch("error", "")
51
58
 
52
59
  # let's try to be helpful about error messages
53
- if response.body == "invalid_token"
54
- raise "Couldn't send slack message because the URL for profile '#{profile[:handle]}' is wrong."
55
- elsif response.body == "channel_not_found"
60
+ if ["token_revoked", "token_expired", "invalid_auth", "not_authed"].include?(error)
61
+ raise "Couldn't send slack message because the API key for profile '#{profile[:handle]}' is wrong."
62
+ elsif ["no_permission", "ekm_access_denied"].include?(error)
63
+ raise "Couldn't send slack message because the API key for profile '#{profile[:handle]}' isn't allowed to post messages."
64
+ elsif error == "channel_not_found"
56
65
  raise "Tried to send Slack message to non-existent channel or user '#{target}'"
57
- elsif response.body == "missing_text_or_fallback_or_attachments"
66
+ elsif error == "invalid_arguments"
58
67
  raise "Tried to send Slack message with invalid payload."
59
68
  elsif response.code == "302"
60
- raise "Got 302 response while posting to Slack. Check your webhook URL for '#{profile[:handle]}'."
69
+ raise "Got 302 response while posting to Slack. Check your API key for profile '#{profile[:handle]}'."
61
70
  elsif response.code != "200"
62
71
  raise "Got an error back from the Slack API (HTTP #{response.code}):\n#{response.body}"
63
72
  end
64
73
 
65
74
  response
66
75
  end
76
+
77
+ # mostly test harness
78
+ def self.post_message(profile, params)
79
+ uri = URI("https://slack.com/api/chat.postMessage")
80
+ request = Net::HTTP::Post.new(uri).tap do |req|
81
+ req['Authorization'] = "Bearer #{profile[:api_token]}"
82
+ req['Content-type'] = "application/json; charset=utf-8"
83
+ req.body = params.to_json
84
+ end
85
+
86
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
87
+ http.request(request)
88
+ end
89
+ end
90
+
91
+ private_class_method :post_message
67
92
  end
@@ -1,9 +1,7 @@
1
1
  module SlackMessage::Configuration
2
- @@api_token = nil
3
2
  @@profiles = {}
4
3
 
5
4
  def self.reset
6
- @@api_token = nil
7
5
  @@profiles = {}
8
6
  end
9
7
 
@@ -13,35 +11,29 @@ module SlackMessage::Configuration
13
11
 
14
12
  ###
15
13
 
16
- def self.api_token=(token)
17
- @@api_token = token
14
+ def self.clear_profiles! # test harness, mainly
15
+ @@profiles = {}
18
16
  end
19
17
 
20
- def self.api_token
21
- unless @@api_token.is_a? String
22
- raise ArgumentError, "Please set an API token to use API features."
23
- end
24
-
25
- @@api_token
26
- end
27
-
28
- ###
29
-
30
- def self.add_profile(handle = :default, name:, url:, default_channel: nil)
18
+ def self.add_profile(handle = :default, api_token:, name: nil, icon: nil, default_channel: nil)
31
19
  if @@profiles.include?(handle)
32
20
  warn "WARNING: Overriding profile '#{handle}' in SlackMessage config"
33
21
  end
34
22
 
35
- @@profiles[handle] = { name: name, url: url, handle: handle, default_channel: default_channel }
23
+ @@profiles[handle] = {
24
+ handle: handle,
25
+ api_token: api_token,
26
+ name: name,
27
+ icon: icon,
28
+ default_channel: default_channel
29
+ }
36
30
  end
37
31
 
38
- def self.profile(handle, custom_name: nil)
32
+ def self.profile(handle)
39
33
  unless @@profiles.include?(handle)
40
34
  raise ArgumentError, "Unknown SlackMessage profile '#{handle}'."
41
35
  end
42
36
 
43
- @@profiles[handle].tap do |profile|
44
- profile[:name] = custom_name if !custom_name.nil?
45
- end
37
+ @@profiles[handle]
46
38
  end
47
39
  end
@@ -1,5 +1,6 @@
1
1
  class SlackMessage::Dsl
2
- attr_reader :body, :default_section, :custom_bot_name
2
+ attr_reader :body, :default_section, :custom_bot_name, :custom_bot_icon
3
+ attr_accessor :notification_text
3
4
 
4
5
  EMSPACE = " " # unicode emspace
5
6
 
@@ -9,8 +10,10 @@ class SlackMessage::Dsl
9
10
  @caller_self = eval("self", block.binding)
10
11
 
11
12
  @body = []
12
- @default_section = Section.new
13
+ @default_section = Section.new(self)
13
14
  @custom_bot_name = nil
15
+ @custom_bot_icon = nil
16
+ @notification_text = nil
14
17
  end
15
18
 
16
19
  # allowable top-level entities within a block
@@ -18,7 +21,7 @@ class SlackMessage::Dsl
18
21
  def section(&block)
19
22
  finalize_default_section
20
23
 
21
- section = Section.new.tap do |s|
24
+ section = Section.new(self).tap do |s|
22
25
  s.instance_eval(&block)
23
26
  end
24
27
 
@@ -52,6 +55,10 @@ class SlackMessage::Dsl
52
55
  def context(text)
53
56
  finalize_default_section
54
57
 
58
+ if text == "" || text.nil?
59
+ raise ArgumentError, "tried to create a context block without a value"
60
+ end
61
+
55
62
  @body.push({ type: "context", elements: [{
56
63
  type: "mrkdwn", text: text
57
64
  }]})
@@ -78,6 +85,10 @@ class SlackMessage::Dsl
78
85
  @custom_bot_name = name
79
86
  end
80
87
 
88
+ def bot_icon(icon)
89
+ @custom_bot_icon = icon
90
+ end
91
+
81
92
  # end bot name
82
93
 
83
94
  def render
@@ -98,18 +109,23 @@ class SlackMessage::Dsl
98
109
  @body.push(default_section.render)
99
110
  end
100
111
 
101
- @default_section = Section.new
112
+ @default_section = Section.new(self)
102
113
  end
103
114
 
104
115
  class Section
105
116
  attr_reader :body
106
117
 
107
- def initialize
118
+ def initialize(parent)
119
+ @parent = parent
108
120
  @body = { type: "section" }
109
121
  @list = List.new
110
122
  end
111
123
 
112
124
  def text(msg)
125
+ if msg == "" || msg.nil?
126
+ raise ArgumentError, "tried to create text node without a value"
127
+ end
128
+
113
129
  if @body.include?(:text)
114
130
  @body[:text][:text] << "\n#{msg}"
115
131
 
@@ -119,14 +135,16 @@ class SlackMessage::Dsl
119
135
  end
120
136
 
121
137
  def ul(elements)
122
- raise Arguments, "please pass an array" unless elements.respond_to?(:map)
138
+ raise ArgumentError, "please pass an array" unless elements.respond_to?(:map)
139
+
123
140
  text(
124
141
  elements.map { |text| "#{EMSPACE}• #{text}" }.join("\n")
125
142
  )
126
143
  end
127
144
 
128
145
  def ol(elements)
129
- raise Arguments, "please pass an array" unless elements.respond_to?(:map)
146
+ raise ArgumentError, "please pass an array" unless elements.respond_to?(:map)
147
+
130
148
  text(
131
149
  elements.map.with_index(1) { |text, idx| "#{EMSPACE}#{idx}. #{text}" }.join("\n")
132
150
  )
@@ -186,6 +204,10 @@ class SlackMessage::Dsl
186
204
  end
187
205
 
188
206
  def list_item(title, value)
207
+ if value == "" || value.nil?
208
+ raise ArgumentError, "can't create a list item for '#{title}' without a value"
209
+ end
210
+
189
211
  @list.add(title, value)
190
212
  end
191
213
 
@@ -199,6 +221,11 @@ class SlackMessage::Dsl
199
221
 
200
222
  def render
201
223
  body[:fields] = @list.render if @list.any?
224
+
225
+ if body[:text] && body[:text][:text] && !@parent.notification_text
226
+ @parent.notification_text = body[:text][:text]
227
+ end
228
+
202
229
  body
203
230
  end
204
231
  end
@@ -0,0 +1,241 @@
1
+ require 'rspec/expectations'
2
+ require 'rspec/mocks'
3
+
4
+ # Honestly, this code is what happens when you do not understand the RSpec
5
+ # custom expectation API really at all, but you really want to create your
6
+ # matcher. This code is soo baaad.
7
+ #
8
+ # We override API calls by entirely replacing the low-level API method. Then we
9
+ # use our overridden version to capture and record calls. When someone creates
10
+ # a new expectation, an object is created, so we allow that object to register
11
+ # itself to receive notification when a slack message _would have_ been posted.
12
+ #
13
+ # Then once the expectation is fulfilled, that class unregisters itself so that
14
+ # it can be cleaned up properly.
15
+ #
16
+
17
+ module SlackMessage::RSpec
18
+ extend RSpec::Matchers::DSL
19
+
20
+ @@listeners = []
21
+
22
+ def self.register_expectation_listener(expectation_instance)
23
+ @@listeners << expectation_instance
24
+ end
25
+
26
+ def self.unregister_expectation_listener(expectation_instance)
27
+ @@listeners.delete(expectation_instance)
28
+ end
29
+
30
+ FauxResponse = Struct.new(:code, :body)
31
+
32
+ def self.included(_)
33
+ SlackMessage::Api.singleton_class.undef_method(:post_message)
34
+ SlackMessage::Api.define_singleton_method(:post_message) do |profile, params|
35
+ @@listeners.each do |listener|
36
+ listener.record_call(params.merge(profile: profile))
37
+ end
38
+
39
+ response = {"ok"=>true,
40
+ "channel"=>"D12345678",
41
+ "ts"=>"1635863996.002300",
42
+ "message"=>
43
+ {"type"=>"message", "subtype"=>"bot_message",
44
+ "text"=>"foo",
45
+ "ts"=>"1635863996.002300",
46
+ "username"=>"SlackMessage",
47
+ "icons"=>{"emoji"=>":successkid:"},
48
+ "bot_id"=>"B1234567890",
49
+ "blocks"=>
50
+ [{"type"=>"section",
51
+ "block_id"=>"hAh7",
52
+ "text"=>{"type"=>"mrkdwn", "text"=>"foo", "verbatim"=>false}}]}}
53
+
54
+ return FauxResponse.new('200', response.to_json)
55
+ end
56
+ end
57
+
58
+ # w/ channel
59
+ matcher :post_slack_message_to do |expected|
60
+ match do |actual|
61
+ @instance ||= PostTo.new
62
+ @instance.with_channel(expected)
63
+
64
+ actual.call
65
+ @instance.enforce_expectations
66
+ end
67
+
68
+ chain :with_content_matching do |content|
69
+ @instance ||= PostTo.new
70
+ @instance.with_content_matching(content)
71
+ end
72
+
73
+ failure_message { @instance.failure_message }
74
+ failure_message_when_negated { @instance.failure_message_when_negated }
75
+
76
+ supports_block_expectations
77
+ end
78
+
79
+ # no channel
80
+ matcher :post_to_slack do |expected|
81
+ match do |actual|
82
+ @instance ||= PostTo.new
83
+
84
+ actual.call
85
+ @instance.enforce_expectations
86
+ end
87
+
88
+ chain :with_content_matching do |content|
89
+ @instance ||= PostTo.new
90
+ @instance.with_content_matching(content)
91
+ end
92
+
93
+ failure_message { @instance.failure_message }
94
+ failure_message_when_negated { @instance.failure_message_when_negated }
95
+
96
+ supports_block_expectations
97
+ end
98
+
99
+ # name / profile matcher
100
+ matcher :post_slack_message_as do |expected|
101
+ match do |actual|
102
+ @instance ||= PostTo.new
103
+ @instance.with_profile(expected)
104
+
105
+ actual.call
106
+ @instance.enforce_expectations
107
+ end
108
+
109
+ chain :with_content_matching do |content|
110
+ @instance ||= PostTo.new
111
+ @instance.with_content_matching(content)
112
+ end
113
+
114
+ failure_message { @instance.failure_message }
115
+ failure_message_when_negated { @instance.failure_message_when_negated }
116
+
117
+ supports_block_expectations
118
+ end
119
+
120
+ # icon matcher
121
+ matcher :post_slack_message_with_icon do |expected|
122
+ match do |actual|
123
+ @instance ||= PostTo.new
124
+ @instance.with_icon(expected)
125
+
126
+ actual.call
127
+ @instance.enforce_expectations
128
+ end
129
+
130
+ chain :with_content_matching do |content|
131
+ @instance ||= PostTo.new
132
+ @instance.with_content_matching(content)
133
+ end
134
+
135
+ failure_message { @instance.failure_message }
136
+ failure_message_when_negated { @instance.failure_message_when_negated }
137
+
138
+ supports_block_expectations
139
+ end
140
+
141
+ matcher :post_slack_message_with_icon_matching do |expected|
142
+ match do |actual|
143
+ @instance ||= PostTo.new
144
+ @instance.with_icon_matching(expected)
145
+
146
+ actual.call
147
+ @instance.enforce_expectations
148
+ end
149
+
150
+ chain :with_content_matching do |content|
151
+ @instance ||= PostTo.new
152
+ @instance.with_content_matching(content)
153
+ end
154
+
155
+ failure_message { @instance.failure_message }
156
+ failure_message_when_negated { @instance.failure_message_when_negated }
157
+
158
+ supports_block_expectations
159
+ end
160
+
161
+ class PostTo
162
+ def initialize
163
+ @captured_calls = []
164
+ @content = nil
165
+ @channel = nil
166
+ @profile = nil
167
+ @icon = nil
168
+ @icon_matching = nil
169
+
170
+ SlackMessage::RSpec.register_expectation_listener(self)
171
+ end
172
+
173
+ def record_call(deets)
174
+ @captured_calls.push(deets)
175
+ end
176
+
177
+ def with_channel(channel)
178
+ @channel = channel
179
+ end
180
+
181
+ def with_icon(icon)
182
+ @icon = icon
183
+ end
184
+
185
+ def with_icon_matching(icon)
186
+ raise ArgumentError unless icon.is_a? Regexp
187
+ @icon_matching = icon
188
+ end
189
+
190
+ def with_content_matching(content)
191
+ raise ArgumentError unless content.is_a? Regexp
192
+ @content = content
193
+ end
194
+
195
+ def with_profile(profile)
196
+ @profile = profile
197
+ end
198
+
199
+ def enforce_expectations
200
+ SlackMessage::RSpec.unregister_expectation_listener(self)
201
+
202
+ @captured_calls
203
+ .filter { |call| !@channel || call[:channel] == @channel }
204
+ .filter { |call| !@profile || [call[:profile][:handle], call[:username]].include?(@profile) }
205
+ .filter { |call| !@content || call.fetch(:blocks).to_s =~ @content }
206
+ .filter { |call| !@icon || call.fetch(:icon_emoji, call.fetch(:icon_url, '')) == @icon }
207
+ .filter { |call| !@icon_matching || call.fetch(:icon_emoji, call.fetch(:icon_url, '')) =~ @icon_matching }
208
+ .any?
209
+ end
210
+
211
+ def failure_message
212
+ "expected block to #{failure_expression}"
213
+ end
214
+
215
+ def failure_message_when_negated
216
+ "expected block not to #{failure_expression}"
217
+ end
218
+
219
+ def failure_expression
220
+ concat = []
221
+
222
+ if @channel
223
+ concat << "post a slack message to '#{@channel}'"
224
+ elsif @profile
225
+ concat << "post a slack message as '#{@profile}'"
226
+ elsif @icon
227
+ concat << "post a slack message with icon '#{@icon}'"
228
+ elsif @icon_matching
229
+ concat << "post a slack message with icon matching '#{@icon_matching.inspect}'"
230
+ else
231
+ concat << "post a slack message"
232
+ end
233
+
234
+ if @content
235
+ concat << "with content matching #{@content.inspect}"
236
+ end
237
+
238
+ concat.join " "
239
+ end
240
+ end
241
+ end
data/lib/slack_message.rb CHANGED
@@ -11,19 +11,15 @@ module SlackMessage
11
11
  configuration.configure(&block)
12
12
  end
13
13
 
14
- def self.user_id_for(email) # spooky undocumented public method 👻
15
- Api::user_id_for(email)
16
- end
17
-
18
14
  def self.post_to(target, as: :default, &block)
19
15
  payload = Dsl.new(block).tap do |instance|
20
16
  instance.instance_eval(&block)
21
17
  end
22
18
 
23
- profile = Configuration.profile(as, custom_name: payload.custom_bot_name)
24
- target = user_id_for(target) if target =~ /^\S{1,}@\S{2,}\.\S{2,}$/
19
+ profile = Configuration.profile(as)
20
+ target = Api::user_id_for(target, profile) if target =~ /^\S{1,}@\S{2,}\.\S{2,}$/
25
21
 
26
- Api.post(payload.render, target, profile)
22
+ Api.post(payload, target, profile)
27
23
  end
28
24
 
29
25
  def self.post_as(profile_name, &block)
@@ -31,15 +27,15 @@ module SlackMessage
31
27
  instance.instance_eval(&block)
32
28
  end
33
29
 
34
- profile = Configuration.profile(profile_name, custom_name: payload.custom_bot_name)
30
+ profile = Configuration.profile(profile_name)
35
31
  if profile[:default_channel].nil?
36
32
  raise ArgumentError, "Sorry, you need to specify a default_channel for profile #{profile_name} to use post_as"
37
33
  end
38
34
 
39
35
  target = profile[:default_channel]
40
- target = user_id_for(target) if target =~ /^\S{1,}@\S{2,}\.\S{2,}$/
36
+ target = Api::user_id_for(target, profile) if target =~ /^\S{1,}@\S{2,}\.\S{2,}$/
41
37
 
42
- Api.post(payload.render, target, profile)
38
+ Api.post(payload, target, profile)
43
39
  end
44
40
 
45
41
  def self.build(&block)
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |gem|
2
2
  gem.name = 'slack_message'
3
- gem.version = "1.7.1"
3
+ gem.version = "2.1.0"
4
4
  gem.summary = "A nice DSL for composing rich messages in Slack"
5
5
  gem.authors = ["Joe Mastey"]
6
6
  gem.email = 'hello@joemastey.com'
@@ -1,18 +1,24 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  RSpec.describe SlackMessage do
4
- it "includes a bunch of stuff" do
5
- expect(SlackMessage).to respond_to(:post_to)
6
- end
7
-
8
4
  describe "API convenience" do
5
+ before do
6
+ SlackMessage.configure do |config|
7
+ config.add_profile(name: 'default profile', api_token: 'abc123')
8
+ end
9
+ end
10
+
11
+ after do
12
+ SlackMessage.configuration.reset
13
+ end
14
+
9
15
  it "can grab user IDs" do
10
- SlackMessage.configure { |c| c.api_token = "asdf" }
16
+ profile = SlackMessage::Configuration.profile(:default)
11
17
  allow(Net::HTTP).to receive(:start).and_return(
12
18
  double(code: "200", body: '{ "user": { "id": "ABC123" }}')
13
19
  )
14
20
 
15
- result = SlackMessage.user_id_for("hello@joemastey.com")
21
+ result = SlackMessage::Api.user_id_for("hello@joemastey.com", profile)
16
22
  expect(result).to eq("ABC123")
17
23
  end
18
24
  end
@@ -40,36 +46,99 @@ RSpec.describe SlackMessage do
40
46
  SlackMessage.configuration.reset
41
47
  end
42
48
 
43
- it "allows you to set an API key" do
49
+ it "lets you add and fetch profiles" do
44
50
  SlackMessage.configure do |config|
45
- config.api_token = "abc123"
51
+ config.add_profile(name: 'default profile', api_token: 'abc123')
52
+ config.add_profile(:nonstandard, name: 'another profile', api_token: 'abc123')
46
53
  end
47
54
 
48
- expect(SlackMessage.configuration.api_token).to eq("abc123")
55
+ expect(SlackMessage.configuration.profile(:default)[:name]).to eq('default profile')
56
+ expect(SlackMessage.configuration.profile(:nonstandard)[:name]).to eq('another profile')
57
+
58
+ expect {
59
+ SlackMessage.configuration.profile(:missing)
60
+ }.to raise_error(ArgumentError)
49
61
  end
62
+ end
50
63
 
51
- it "raises errors for missing configuration" do
64
+ describe "custom expectations" do
65
+ before do
52
66
  SlackMessage.configure do |config|
53
- #config.api_token = "abc123"
67
+ config.clear_profiles!
68
+ config.add_profile(name: 'default profile', api_token: 'abc123')
54
69
  end
70
+ end
55
71
 
72
+ it "can assert expectations against posts" do
56
73
  expect {
57
- SlackMessage.configuration.api_token
58
- }.to raise_error(ArgumentError)
74
+ SlackMessage.post_to('#lieutenant') { text "foo" }
75
+ }.not_to post_slack_message_to('#general')
76
+
77
+ expect {
78
+ SlackMessage.post_to('#general') { text "foo" }
79
+ }.to post_slack_message_to('#general').with_content_matching(/foo/)
59
80
  end
60
81
 
61
- it "lets you add and fetch profiles" do
82
+ it "resets state properly" do
83
+ expect {
84
+ SlackMessage.post_to('#general') { text "foo" }
85
+ }.to post_slack_message_to('#general')
86
+
87
+ expect { }.not_to post_slack_message_to('#general')
88
+ end
89
+
90
+ it "lets you assert by profile name" do
62
91
  SlackMessage.configure do |config|
63
- config.add_profile(name: 'default profile', url: 'http://hooks.slack.com/1234/')
64
- config.add_profile(:nonstandard, name: 'another profile', url: 'http://hooks.slack.com/1234/')
92
+ config.clear_profiles!
93
+ config.add_profile(:schmoebot, name: 'Schmoe', api_token: 'abc123', icon: ':schmoebot:', default_channel: '#schmoes')
65
94
  end
66
95
 
67
- expect(SlackMessage.configuration.profile(:default)[:name]).to eq('default profile')
68
- expect(SlackMessage.configuration.profile(:nonstandard)[:name]).to eq('another profile')
96
+ expect {
97
+ SlackMessage.post_as(:schmoebot) { text "foo" }
98
+ }.to post_slack_message_to('#schmoes')
69
99
 
70
100
  expect {
71
- SlackMessage.configuration.profile(:missing)
72
- }.to raise_error(ArgumentError)
101
+ SlackMessage.post_as(:schmoebot) { text "foo" }
102
+ }.to post_slack_message_as(:schmoebot)
103
+
104
+ expect {
105
+ SlackMessage.post_as(:schmoebot) { text "foo" }
106
+ }.to post_slack_message_as('Schmoe').with_content_matching(/foo/)
107
+ end
108
+
109
+ it "lets you assert by profile image" do
110
+ SlackMessage.configure do |config|
111
+ config.clear_profiles!
112
+ config.add_profile(:schmoebot, name: 'Schmoe', api_token: 'abc123', icon: ':schmoebot:', default_channel: '#schmoes')
113
+ end
114
+
115
+ expect {
116
+ SlackMessage.post_as(:schmoebot) { text "foo" }
117
+ }.to post_slack_message_with_icon(':schmoebot:')
118
+
119
+ expect {
120
+ SlackMessage.post_as(:schmoebot) do
121
+ bot_icon ':schmalternate:'
122
+ text "foo"
123
+ end
124
+ }.to post_slack_message_with_icon(':schmalternate:')
125
+
126
+ expect {
127
+ SlackMessage.post_as(:schmoebot) do
128
+ bot_icon 'https://thispersondoesnotexist.com/image'
129
+ text "foo"
130
+ end
131
+ }.to post_slack_message_with_icon_matching(/thisperson/)
132
+ end
133
+
134
+ it "lets you assert notification text" do
135
+ # TODO :|
136
+ end
137
+
138
+ it "can assert more generally too tbh" do
139
+ expect {
140
+ SlackMessage.post_to('#general') { text "foo" }
141
+ }.to post_to_slack.with_content_matching(/foo/)
73
142
  end
74
143
  end
75
144
  end
data/spec/spec_helper.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require_relative '../lib/slack_message'
2
+ require_relative '../lib/slack_message/rspec'
2
3
 
3
4
  # This file was generated by the `rspec --init` command. Conventionally, all
4
5
  # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
@@ -49,4 +50,6 @@ RSpec.configure do |config|
49
50
  # test failures related to randomization by passing the same `--seed` value
50
51
  # as the one that triggered the failure.
51
52
  Kernel.srand config.seed
53
+
54
+ include SlackMessage::RSpec
52
55
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slack_message
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.1
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joe Mastey
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-10-06 00:00:00.000000000 Z
11
+ date: 2021-11-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -70,6 +70,7 @@ files:
70
70
  - lib/slack_message/api.rb
71
71
  - lib/slack_message/configuration.rb
72
72
  - lib/slack_message/dsl.rb
73
+ - lib/slack_message/rspec.rb
73
74
  - slack_message.gemspec
74
75
  - spec/slack_message_spec.rb
75
76
  - spec/spec_helper.rb