slack-messenger 2.3.3
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 +7 -0
- data/lib/slack-messenger.rb +59 -0
- data/lib/slack-messenger/config.rb +43 -0
- data/lib/slack-messenger/payload_middleware.rb +24 -0
- data/lib/slack-messenger/payload_middleware/at.rb +36 -0
- data/lib/slack-messenger/payload_middleware/base.rb +34 -0
- data/lib/slack-messenger/payload_middleware/channels.rb +21 -0
- data/lib/slack-messenger/payload_middleware/format_attachments.rb +44 -0
- data/lib/slack-messenger/payload_middleware/format_message.rb +20 -0
- data/lib/slack-messenger/payload_middleware/stack.rb +50 -0
- data/lib/slack-messenger/util/escape.rb +16 -0
- data/lib/slack-messenger/util/http_client.rb +64 -0
- data/lib/slack-messenger/util/link_formatter.rb +81 -0
- data/lib/slack-messenger/version.rb +7 -0
- data/spec/end_to_end_spec.rb +95 -0
- data/spec/integration/ping_integration_test.rb +16 -0
- data/spec/lib/slack-messenger/config_spec.rb +71 -0
- data/spec/lib/slack-messenger/payload_middleware/at_spec.rb +25 -0
- data/spec/lib/slack-messenger/payload_middleware/base_spec.rb +76 -0
- data/spec/lib/slack-messenger/payload_middleware/channels_spec.rb +20 -0
- data/spec/lib/slack-messenger/payload_middleware/format_attachments_spec.rb +48 -0
- data/spec/lib/slack-messenger/payload_middleware/format_message_spec.rb +27 -0
- data/spec/lib/slack-messenger/payload_middleware/stack_spec.rb +119 -0
- data/spec/lib/slack-messenger/payload_middleware_spec.rb +33 -0
- data/spec/lib/slack-messenger/util/http_client_spec.rb +55 -0
- data/spec/lib/slack-messenger/util/link_formatter_spec.rb +155 -0
- data/spec/lib/slack-messenger_spec.rb +98 -0
- data/spec/spec_helper.rb +28 -0
- metadata +84 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f07dd2e13c155464bd8d99c0cbba74ff48da2d8e84a19ccbd5026070e9c95a78
|
|
4
|
+
data.tar.gz: 723e2b2a499ce27ab08b1d1920a20e6473802cba49232edcabc936ab5dbdd434
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: db2415864731bdc11c27c691c0d7ee70d6157517234fd6c7aa426b26824198818cfddc093e89851b17efbc9cd2262decfb0b4fba5fe18692a4d2d94b7ad07929
|
|
7
|
+
data.tar.gz: 834a3ba19a3919b8ea1faac84372077f98c5c4a8ba28ca6e6b0a3d310a80099b3184beeb16ec12b70b8690f4dea2542f79c718b82264358dadd839920f8c4c34
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "slack-messenger/util/http_client"
|
|
7
|
+
require_relative "slack-messenger/util/link_formatter"
|
|
8
|
+
require_relative "slack-messenger/util/escape"
|
|
9
|
+
require_relative "slack-messenger/payload_middleware"
|
|
10
|
+
require_relative "slack-messenger/config"
|
|
11
|
+
|
|
12
|
+
module Slack
|
|
13
|
+
class Messenger
|
|
14
|
+
attr_reader :endpoint
|
|
15
|
+
|
|
16
|
+
def initialize webhook_url, options={}, &block
|
|
17
|
+
@endpoint = URI.parse webhook_url
|
|
18
|
+
|
|
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?
|
|
22
|
+
|
|
23
|
+
middleware.set config.middleware
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def config
|
|
27
|
+
@_config ||= Config.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def ping message, options={}
|
|
31
|
+
if message.is_a?(Hash)
|
|
32
|
+
options = message
|
|
33
|
+
else
|
|
34
|
+
options[:text] = message
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
post options
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def post payload={}
|
|
41
|
+
params = {}
|
|
42
|
+
client = payload.delete(:http_client) || config.http_client
|
|
43
|
+
payload = config.defaults.merge(payload)
|
|
44
|
+
|
|
45
|
+
params[:http_options] = payload.delete(:http_options) if payload.key?(:http_options)
|
|
46
|
+
|
|
47
|
+
middleware.call(payload).map do |pld|
|
|
48
|
+
params[:payload] = pld.to_json
|
|
49
|
+
client.post endpoint, params
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def middleware
|
|
56
|
+
@middleware ||= PayloadMiddleware::Stack.new(self)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slack
|
|
4
|
+
class Messenger
|
|
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,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slack
|
|
4
|
+
class Messenger
|
|
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,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slack
|
|
4
|
+
class Messenger
|
|
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 Messenger
|
|
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 :messenger, :options
|
|
22
|
+
|
|
23
|
+
def initialize messenger, opts={}
|
|
24
|
+
@messenger = messenger
|
|
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 Messenger
|
|
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 Messenger
|
|
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 Messenger
|
|
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 Messenger
|
|
5
|
+
class PayloadMiddleware
|
|
6
|
+
class Stack
|
|
7
|
+
attr_reader :messenger,
|
|
8
|
+
:stack
|
|
9
|
+
|
|
10
|
+
def initialize messenger
|
|
11
|
+
@messenger = messenger
|
|
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(*[messenger, 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,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slack
|
|
4
|
+
class Messenger
|
|
5
|
+
module Util
|
|
6
|
+
module Escape
|
|
7
|
+
HTML_REGEXP = /[&><]/
|
|
8
|
+
HTML_REPLACE = { "&" => "&", ">" => ">", "<" => "<" }.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 Messenger
|
|
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::Messenger::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,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slack
|
|
4
|
+
class Messenger
|
|
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
|
+
VALID_URI_CHARS = '\w\-\.\~\:\/\?\#\[\]\@\!\$\&\'\*\+\,\;\='
|
|
18
|
+
|
|
19
|
+
# Attempt at only matching pairs of parens per
|
|
20
|
+
# the markdown spec http://spec.commonmark.org/0.27/#links
|
|
21
|
+
#
|
|
22
|
+
# https://rubular.com/r/WfdZ1arvF6PNWO
|
|
23
|
+
MARKDOWN_PATTERN = %r{
|
|
24
|
+
\[ ([^\[\]]*?) \]
|
|
25
|
+
\(
|
|
26
|
+
( (?:https?:\/\/|mailto:)
|
|
27
|
+
(?:[#{VALID_URI_CHARS}]*?|[#{VALID_URI_CHARS}]*?\([#{VALID_URI_CHARS}]*?\)[#{VALID_URI_CHARS}]*?) )
|
|
28
|
+
\)
|
|
29
|
+
}x
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def format string, opts={}
|
|
33
|
+
LinkFormatter.new(string, opts).formatted
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :formats
|
|
38
|
+
|
|
39
|
+
def initialize string, formats: %i[html markdown]
|
|
40
|
+
@formats = formats
|
|
41
|
+
@orig = string.respond_to?(:scrub) ? string.scrub : string
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# rubocop:disable Lint/RescueWithoutErrorClass
|
|
45
|
+
def formatted
|
|
46
|
+
return @orig unless @orig.respond_to?(:gsub)
|
|
47
|
+
|
|
48
|
+
sub_markdown_links(sub_html_links(@orig))
|
|
49
|
+
rescue => e
|
|
50
|
+
raise e unless RUBY_VERSION < "2.1" && e.message.include?("invalid byte sequence")
|
|
51
|
+
raise e, "#{e.message}. Consider including the 'string-scrub' gem to strip invalid characters"
|
|
52
|
+
end
|
|
53
|
+
# rubocop:enable Lint/RescueWithoutErrorClass
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def sub_html_links string
|
|
58
|
+
return string unless formats.include?(:html)
|
|
59
|
+
|
|
60
|
+
string.gsub(HTML_PATTERN) do
|
|
61
|
+
slack_link Regexp.last_match[1], Regexp.last_match[2]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def sub_markdown_links string
|
|
66
|
+
return string unless formats.include?(:markdown)
|
|
67
|
+
|
|
68
|
+
string.gsub(MARKDOWN_PATTERN) do
|
|
69
|
+
slack_link Regexp.last_match[2], Regexp.last_match[1]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def slack_link link, text=nil
|
|
74
|
+
"<#{link}" \
|
|
75
|
+
"#{text && !text.empty? ? "|#{text}" : ''}" \
|
|
76
|
+
">"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|