slack-notifier 1.5.1 → 2.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +5 -5
  2. data/lib/slack-notifier/config.rb +43 -0
  3. data/lib/slack-notifier/payload_middleware/at.rb +36 -0
  4. data/lib/slack-notifier/payload_middleware/base.rb +34 -0
  5. data/lib/slack-notifier/payload_middleware/channels.rb +21 -0
  6. data/lib/slack-notifier/payload_middleware/format_attachments.rb +44 -0
  7. data/lib/slack-notifier/payload_middleware/format_message.rb +20 -0
  8. data/lib/slack-notifier/payload_middleware/stack.rb +50 -0
  9. data/lib/slack-notifier/payload_middleware.rb +24 -0
  10. data/lib/slack-notifier/util/escape.rb +16 -0
  11. data/lib/slack-notifier/util/http_client.rb +64 -0
  12. data/lib/slack-notifier/util/link_formatter.rb +80 -0
  13. data/lib/slack-notifier/version.rb +3 -1
  14. data/lib/slack-notifier.rb +38 -60
  15. data/spec/end_to_end_spec.rb +95 -0
  16. data/spec/integration/ping_integration_test.rb +14 -5
  17. data/spec/lib/slack-notifier/config_spec.rb +71 -0
  18. data/spec/lib/slack-notifier/payload_middleware/at_spec.rb +25 -0
  19. data/spec/lib/slack-notifier/payload_middleware/base_spec.rb +76 -0
  20. data/spec/lib/slack-notifier/payload_middleware/channels_spec.rb +20 -0
  21. data/spec/lib/slack-notifier/payload_middleware/format_attachments_spec.rb +48 -0
  22. data/spec/lib/slack-notifier/payload_middleware/format_message_spec.rb +27 -0
  23. data/spec/lib/slack-notifier/payload_middleware/stack_spec.rb +119 -0
  24. data/spec/lib/slack-notifier/payload_middleware_spec.rb +33 -0
  25. data/spec/lib/slack-notifier/util/http_client_spec.rb +55 -0
  26. data/spec/lib/slack-notifier/util/link_formatter_spec.rb +163 -0
  27. data/spec/lib/slack-notifier_spec.rb +63 -128
  28. data/spec/spec_helper.rb +20 -5
  29. metadata +39 -13
  30. data/lib/slack-notifier/default_http_client.rb +0 -51
  31. data/lib/slack-notifier/link_formatter.rb +0 -62
  32. data/spec/lib/slack-notifier/default_http_client_spec.rb +0 -37
  33. data/spec/lib/slack-notifier/link_formatter_spec.rb +0 -78
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: e74413c9d125dd2a973d6035bdc86e6f28549839
4
- data.tar.gz: 6081e63972a12dc07acc866e13f8e6bae8a1992d
2
+ SHA256:
3
+ metadata.gz: 2b0deceb91f70d0812f01b82db70eacb8dbf01d4d1985ca02375ac13b36d829e
4
+ data.tar.gz: 5ec7d0eb36e91bbee6f510411646b4c57935aa661706b791e1dc0941fa66dbd9
5
5
  SHA512:
6
- metadata.gz: 252d1d23a8bb02cfe9ebee51c8d68436799daa59c95572dd18bc481797069ed2fa6fac96869845ffeae900fbf5d73c9df9b4e910f9190f7ca85937293e8d3e31
7
- data.tar.gz: bda7747d3bd8e34a0ca2798d50c06c4f2b6e426c2552c2d389247f6ed60ce48a82d26e7f77e7e9c179388e831163d36a237dfe139cdddec689fdb154ee3b7d1b
6
+ metadata.gz: 0d42ebede5966444cf3101f9410f442e3fad0cdd3a9a7a25f67ff8c068c474bbd49f08d120070cca8652ffd351d68e99f90f648090b97469e3519761b167f513
7
+ data.tar.gz: 950d6e0ebc557de14b56b8ea5982e6057eb54530ec7b5b406f3c28ca22c3aaf26744c2ce6d69654b39bc75c8525d43204687b8adb97c331cf21c91f9629bd97f
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slack
4
+ class Notifier
5
+ class Config
6
+ def initialize
7
+ @http_client = Util::HTTPClient
8
+ @defaults = {}
9
+ @middleware = %i[
10
+ format_message
11
+ format_attachments
12
+ at
13
+ channels
14
+ ]
15
+ end
16
+
17
+ def http_client client=nil
18
+ return @http_client if client.nil?
19
+ raise ArgumentError, "the http client must respond to ::post" unless client.respond_to?(:post)
20
+
21
+ @http_client = client
22
+ end
23
+
24
+ def defaults new_defaults=nil
25
+ return @defaults if new_defaults.nil?
26
+ raise ArgumentError, "the defaults must be a Hash" unless new_defaults.is_a?(Hash)
27
+
28
+ @defaults = new_defaults
29
+ end
30
+
31
+ def middleware *args
32
+ return @middleware if args.empty?
33
+
34
+ @middleware =
35
+ if args.length == 1 && args.first.is_a?(Array) || args.first.is_a?(Hash)
36
+ args.first
37
+ else
38
+ args
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slack
4
+ class Notifier
5
+ class PayloadMiddleware
6
+ class At < Base
7
+ middleware_name :at
8
+
9
+ options at: []
10
+
11
+ def call payload={}
12
+ return payload unless payload[:at]
13
+
14
+ payload[:text] = "#{format_ats(payload.delete(:at))}#{payload[:text]}"
15
+ payload
16
+ end
17
+
18
+ private
19
+
20
+ def format_ats ats
21
+ Array(ats).map { |at| "<#{at_cmd_char(at)}#{at}> " }
22
+ .join("")
23
+ end
24
+
25
+ def at_cmd_char at
26
+ case at
27
+ when :here, :channel, :everyone, :group
28
+ "!"
29
+ else
30
+ "@"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slack
4
+ class Notifier
5
+ class PayloadMiddleware
6
+ class Base
7
+ class << self
8
+ def middleware_name name
9
+ PayloadMiddleware.register self, name.to_sym
10
+ end
11
+
12
+ def options default_opts
13
+ @default_opts = default_opts
14
+ end
15
+
16
+ def default_opts
17
+ @default_opts ||= {}
18
+ end
19
+ end
20
+
21
+ attr_reader :notifier, :options
22
+
23
+ def initialize notifier, opts={}
24
+ @notifier = notifier
25
+ @options = self.class.default_opts.merge opts
26
+ end
27
+
28
+ def call _payload={}
29
+ raise NoMethodError, "method `call` not defined for class #{self.class}"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slack
4
+ class Notifier
5
+ class PayloadMiddleware
6
+ class Channels < Base
7
+ middleware_name :channels
8
+
9
+ def call payload={}
10
+ return payload unless payload[:channel].respond_to?(:to_ary)
11
+
12
+ payload[:channel].to_ary.map do |channel|
13
+ pld = payload.dup
14
+ pld[:channel] = channel
15
+ pld
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slack
4
+ class Notifier
5
+ class PayloadMiddleware
6
+ class FormatAttachments < Base
7
+ middleware_name :format_attachments
8
+
9
+ options formats: %i[html markdown]
10
+
11
+ def call payload={}
12
+ payload = payload.dup
13
+ attachments = payload.delete(:attachments)
14
+ attachments ||= payload.delete("attachments")
15
+
16
+ attachments = wrap_array(attachments).map do |attachment|
17
+ ["text", :text].each do |key|
18
+ if attachment.key?(key)
19
+ attachment[key] = Util::LinkFormatter.format(attachment[key], options)
20
+ end
21
+ end
22
+
23
+ attachment
24
+ end
25
+
26
+ payload[:attachments] = attachments if attachments && !attachments.empty?
27
+ payload
28
+ end
29
+
30
+ private
31
+
32
+ def wrap_array object
33
+ if object.nil?
34
+ []
35
+ elsif object.respond_to?(:to_ary)
36
+ object.to_ary || [object]
37
+ else
38
+ [object]
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slack
4
+ class Notifier
5
+ class PayloadMiddleware
6
+ class FormatMessage < Base
7
+ middleware_name :format_message
8
+
9
+ options formats: %i[html markdown]
10
+
11
+ def call payload={}
12
+ return payload unless payload[:text]
13
+ payload[:text] = Util::LinkFormatter.format(payload[:text], options)
14
+
15
+ payload
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slack
4
+ class Notifier
5
+ class PayloadMiddleware
6
+ class Stack
7
+ attr_reader :notifier,
8
+ :stack
9
+
10
+ def initialize notifier
11
+ @notifier = notifier
12
+ @stack = []
13
+ end
14
+
15
+ def set *middlewares
16
+ middlewares =
17
+ if middlewares.length == 1 && middlewares.first.is_a?(Hash)
18
+ middlewares.first
19
+ else
20
+ middlewares.flatten
21
+ end
22
+
23
+ @stack = middlewares.map do |key, opts|
24
+ PayloadMiddleware.registry.fetch(key).new(*[notifier, opts].compact)
25
+ end
26
+ end
27
+
28
+ def call payload={}
29
+ result = stack.inject payload do |pld, middleware|
30
+ as_array(pld).flat_map do |p|
31
+ middleware.call(p)
32
+ end
33
+ end
34
+
35
+ as_array(result)
36
+ end
37
+
38
+ private
39
+
40
+ def as_array args
41
+ if args.respond_to?(:to_ary)
42
+ args.to_ary
43
+ else
44
+ [args]
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slack
4
+ class Notifier
5
+ class PayloadMiddleware
6
+ class << self
7
+ def registry
8
+ @registry ||= {}
9
+ end
10
+
11
+ def register middleware, name
12
+ registry[name] = middleware
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ require_relative "payload_middleware/stack"
20
+ require_relative "payload_middleware/base"
21
+ require_relative "payload_middleware/format_message"
22
+ require_relative "payload_middleware/format_attachments"
23
+ require_relative "payload_middleware/at"
24
+ require_relative "payload_middleware/channels"
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slack
4
+ class Notifier
5
+ module Util
6
+ module Escape
7
+ HTML_REGEXP = /[&><]/
8
+ HTML_REPLACE = { "&" => "&amp;", ">" => "&gt;", "<" => "&lt;" }.freeze
9
+
10
+ def self.html string
11
+ string.gsub(HTML_REGEXP, HTML_REPLACE)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module Slack
6
+ class Notifier
7
+ class APIError < StandardError; end
8
+
9
+ module Util
10
+ class HTTPClient
11
+ class << self
12
+ def post uri, params
13
+ HTTPClient.new(uri, params).call
14
+ end
15
+ end
16
+
17
+ attr_reader :uri, :params, :http_options
18
+
19
+ def initialize uri, params
20
+ @uri = uri
21
+ @http_options = params.delete(:http_options) || {}
22
+ @params = params
23
+ end
24
+
25
+ # rubocop:disable Layout/IndentHeredoc
26
+ def call
27
+ http_obj.request(request_obj).tap do |response|
28
+ unless response.is_a?(Net::HTTPSuccess)
29
+ raise Slack::Notifier::APIError, <<-MSG
30
+ The slack API returned an error: #{response.body} (HTTP Code #{response.code})
31
+ Check the "Handling Errors" section on https://api.slack.com/incoming-webhooks for more information
32
+ MSG
33
+ end
34
+ end
35
+ end
36
+ # rubocop:enable Layout/IndentHeredoc
37
+
38
+ private
39
+
40
+ def request_obj
41
+ req = Net::HTTP::Post.new uri.request_uri
42
+ req.set_form_data params
43
+
44
+ req
45
+ end
46
+
47
+ def http_obj
48
+ http = Net::HTTP.new uri.host, uri.port
49
+ http.use_ssl = (uri.scheme == "https")
50
+
51
+ http_options.each do |opt, val|
52
+ if http.respond_to? "#{opt}="
53
+ http.send "#{opt}=", val
54
+ else
55
+ warn "Net::HTTP doesn't respond to `#{opt}=`, ignoring that option"
56
+ end
57
+ end
58
+
59
+ http
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slack
4
+ class Notifier
5
+ module Util
6
+ class LinkFormatter
7
+ # http://rubular.com/r/19cNXW5qbH
8
+ HTML_PATTERN = %r{
9
+ <a
10
+ (?:.*?)
11
+ href=['"](.+?)['"]
12
+ (?:.*?)>
13
+ (.+?)
14
+ </a>
15
+ }x
16
+
17
+ # the path portion of a url can contain these characters
18
+ VALID_PATH_CHARS = '\w\-\.\~\/\?\#\='
19
+
20
+ # Attempt at only matching pairs of parens per
21
+ # the markdown spec http://spec.commonmark.org/0.27/#links
22
+ #
23
+ # http://rubular.com/r/y107aevxqT
24
+ MARKDOWN_PATTERN = %r{
25
+ \[ ([^\[\]]*?) \]
26
+ \( ((https?://.*?) | (mailto:.*?)) \)
27
+ (?! [#{VALID_PATH_CHARS}]* \) )
28
+ }x
29
+
30
+ class << self
31
+ def format string, opts={}
32
+ LinkFormatter.new(string, **opts).formatted
33
+ end
34
+ end
35
+
36
+ attr_reader :formats
37
+
38
+ def initialize string, formats: %i[html markdown]
39
+ @formats = formats
40
+ @orig = string.respond_to?(:scrub) ? string.scrub : string
41
+ end
42
+
43
+ # rubocop:disable Lint/RescueWithoutErrorClass
44
+ def formatted
45
+ return @orig unless @orig.respond_to?(:gsub)
46
+
47
+ sub_markdown_links(sub_html_links(@orig))
48
+ rescue => e
49
+ raise e unless RUBY_VERSION < "2.1" && e.message.include?("invalid byte sequence")
50
+ raise e, "#{e.message}. Consider including the 'string-scrub' gem to strip invalid characters"
51
+ end
52
+ # rubocop:enable Lint/RescueWithoutErrorClass
53
+
54
+ private
55
+
56
+ def sub_html_links string
57
+ return string unless formats.include?(:html)
58
+
59
+ string.gsub(HTML_PATTERN) do
60
+ slack_link Regexp.last_match[1], Regexp.last_match[2]
61
+ end
62
+ end
63
+
64
+ def sub_markdown_links string
65
+ return string unless formats.include?(:markdown)
66
+
67
+ string.gsub(MARKDOWN_PATTERN) do
68
+ slack_link Regexp.last_match[2], Regexp.last_match[1]
69
+ end
70
+ end
71
+
72
+ def slack_link link, text=nil
73
+ "<#{link}" \
74
+ "#{text && !text.empty? ? "|#{text}" : ''}" \
75
+ ">"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Slack
2
4
  class Notifier
3
- VERSION = "1.5.1"
5
+ VERSION = "2.4.0".freeze # rubocop:disable Style/RedundantFreeze
4
6
  end
5
7
  end
@@ -1,81 +1,59 @@
1
- require 'net/http'
2
- require 'uri'
3
- require 'json'
1
+ # frozen_string_literal: true
4
2
 
5
- require_relative 'slack-notifier/default_http_client'
6
- require_relative 'slack-notifier/link_formatter'
3
+ require "uri"
4
+ require "json"
5
+
6
+ require_relative "slack-notifier/util/http_client"
7
+ require_relative "slack-notifier/util/link_formatter"
8
+ require_relative "slack-notifier/util/escape"
9
+ require_relative "slack-notifier/payload_middleware"
10
+ require_relative "slack-notifier/config"
7
11
 
8
12
  module Slack
9
13
  class Notifier
10
- attr_reader :endpoint, :default_payload
11
-
12
- def initialize webhook_url, options={}
13
- @endpoint = URI.parse webhook_url
14
- @default_payload = options
15
- end
16
-
17
- def ping message, options={}
18
- if message.is_a?(Hash)
19
- message, options = nil, message
20
- end
21
-
22
- if attachments = options[:attachments] || options["attachments"]
23
- wrap_array(attachments).each do |attachment|
24
- ["text", :text].each do |key|
25
- attachment[key] = LinkFormatter.format(attachment[key]) if attachment.has_key?(key)
26
- end
27
- end
28
- end
14
+ attr_reader :endpoint
29
15
 
30
- payload = default_payload.merge(options)
31
- client = payload.delete(:http_client) || http_client
32
- http_options = payload.delete(:http_options)
16
+ def initialize webhook_url, options={}, &block
17
+ @endpoint = URI.parse webhook_url
33
18
 
34
- unless message.nil?
35
- payload.merge!(text: LinkFormatter.format(message))
36
- end
37
-
38
- params = { payload: payload.to_json }
39
- params[:http_options] = http_options if http_options
19
+ config.http_client(options.delete(:http_client)) if options.key?(:http_client)
20
+ config.defaults options
21
+ config.instance_exec(&block) if block_given?
40
22
 
41
- client.post endpoint, params
23
+ middleware.set config.middleware
42
24
  end
43
25
 
44
- def http_client
45
- default_payload.fetch :http_client, DefaultHTTPClient
26
+ def config
27
+ @_config ||= Config.new
46
28
  end
47
29
 
48
- def channel
49
- default_payload[:channel]
50
- end
30
+ def ping message, options={}
31
+ if message.is_a?(Hash)
32
+ options = message
33
+ else
34
+ options[:text] = message
35
+ end
51
36
 
52
- def channel= channel
53
- default_payload[:channel] = channel
37
+ post options
54
38
  end
55
39
 
56
- def username
57
- default_payload[:username]
58
- end
40
+ def post payload={}
41
+ params = {}
42
+ client = payload.delete(:http_client) || config.http_client
43
+ payload = config.defaults.merge(payload)
59
44
 
60
- def username= username
61
- default_payload[:username] = username
62
- end
45
+ params[:http_options] = payload.delete(:http_options) if payload.key?(:http_options)
63
46
 
64
- HTML_ESCAPE_REGEXP = /[&><]/
65
- HTML_ESCAPE = { '&' => '&amp;', '>' => '&gt;', '<' => '&lt;' }
66
-
67
- def escape(text)
68
- text.gsub(HTML_ESCAPE_REGEXP, HTML_ESCAPE)
47
+ middleware.call(payload).map do |pld|
48
+ params[:payload] = pld.to_json
49
+ client.post endpoint, params
50
+ end
69
51
  end
70
52
 
71
- def wrap_array(object)
72
- if object.nil?
73
- []
74
- elsif object.respond_to?(:to_ary)
75
- object.to_ary || [object]
76
- else
77
- [object]
53
+ private
54
+
55
+ def middleware
56
+ @middleware ||= PayloadMiddleware::Stack.new(self)
78
57
  end
79
- end
80
58
  end
81
59
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+ # encoding: utf-8
3
+
4
+ require "spec_helper"
5
+
6
+ RSpec.describe Slack::Notifier do
7
+ {
8
+ { text: "hello" } =>
9
+ { payload: { text: "hello" } },
10
+
11
+ { text: "[hello](http://example.com/world)" } =>
12
+ { payload: { text: "<http://example.com/world|hello>" } },
13
+
14
+ { text: '<a href="http://example.com">example</a>' } =>
15
+ { payload: { text: "<http://example.com|example>" } },
16
+
17
+ { text: "hello/こんにちは from notifier test" } =>
18
+ { payload: { text: "hello/こんにちは from notifier test" } },
19
+
20
+ { text: "Hello World, enjoy [](http://example.com)." } =>
21
+ { payload: { text: "Hello World, enjoy <http://example.com>." } },
22
+
23
+ { text: "Hello World, enjoy [this](http://example.com)[this2](http://example2.com)" } =>
24
+ { payload: { text: "Hello World, enjoy <http://example.com|this><http://example2.com|this2>" } },
25
+
26
+ { text: "[John](mailto:john@example.com)" } =>
27
+ { payload: { text: "<mailto:john@example.com|John>" } },
28
+
29
+ { text: '<a href="mailto:john@example.com">John</a>' } =>
30
+ { payload: { text: "<mailto:john@example.com|John>" } },
31
+
32
+ { text: "hello", channel: "hodor" } =>
33
+ { payload: { text: "hello", channel: "hodor" } },
34
+
35
+ { text: nil, attachments: [{ text: "attachment message" }] } =>
36
+ { payload: { text: nil, attachments: [{ text: "attachment message" }] } },
37
+
38
+ { text: "the message", channel: "foo", attachments: [{ color: "#000",
39
+ text: "attachment message",
40
+ fallback: "fallback message" }] } =>
41
+ { payload: { text: "the message",
42
+ channel: "foo",
43
+ attachments: [{ color: "#000",
44
+ text: "attachment message",
45
+ fallback: "fallback message" }] } },
46
+
47
+ { attachments: [{ color: "#000",
48
+ text: "attachment message",
49
+ fallback: "fallback message" }] } =>
50
+ { payload: { attachments: [{ color: "#000",
51
+ text: "attachment message",
52
+ fallback: "fallback message" }] } },
53
+
54
+ { attachments: { color: "#000",
55
+ text: "attachment message [hodor](http://winterfell.com)",
56
+ fallback: "fallback message" } } =>
57
+ { payload: { attachments: [{ color: "#000",
58
+ text: "attachment message <http://winterfell.com|hodor>",
59
+ fallback: "fallback message" }] } },
60
+
61
+ { attachments: { color: "#000",
62
+ text: nil,
63
+ fallback: "fallback message" } } =>
64
+ { payload: { attachments: [{ color: "#000",
65
+ text: nil,
66
+ fallback: "fallback message" }] } },
67
+
68
+ { text: "hello", http_options: { timeout: 5 } } =>
69
+ { http_options: { timeout: 5 }, payload: { text: "hello" } }
70
+ }.each do |args, payload|
71
+ it "sends correct payload for #post(#{args})" do
72
+ http_client = class_double("Slack::Notifier::Util::HTTPClient", post: nil)
73
+ notifier = Slack::Notifier.new "http://example.com", http_client: http_client
74
+ payload[:payload] = payload[:payload].to_json
75
+
76
+ expect(http_client).to receive(:post)
77
+ .with(URI.parse("http://example.com"), payload)
78
+
79
+ notifier.post(args)
80
+ end
81
+ end
82
+
83
+ it "applies options given to middleware" do
84
+ client = class_double("Slack::Notifier::Util::HTTPClient", post: nil)
85
+ notifier = Slack::Notifier.new "http://example.com" do
86
+ http_client client
87
+ middleware format_message: { formats: [] }
88
+ end
89
+
90
+ expect(client).to receive(:post)
91
+ .with(URI.parse("http://example.com"), payload: { text: "Hello [world](http://example.com)!" }.to_json)
92
+
93
+ notifier.post text: "Hello [world](http://example.com)!"
94
+ end
95
+ end