chat_sdk-slack 0.1.0

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
+ SHA256:
3
+ metadata.gz: d6e4e9dd2243b38d51bcdd10278e9f59ac540e01f7340ece7585d63475c59e06
4
+ data.tar.gz: 2f7a0a47486dcfa63cd3be0c4784ba70f5d8f973beed2b276b4ce10aebb5a242
5
+ SHA512:
6
+ metadata.gz: c1d6bc38dc2635d2e737c915f2eb24c32c16f0c9ed7792af675769ddebbe3e14de8b679c28566ca9e5655b4332860a58424aa5bff6901eaa3120baf17051c80b
7
+ data.tar.gz: 9f3af6fb31887b727e0ff1b9bbfcabdb57e9dc1008b915f4b25ae13a7ef22c794ea5042a42d7117c31b990233dfdc69de819e642d55b581f57dfa2d3b7fb80f4
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Slack
5
+ class Adapter < ChatSDK::Adapter::Base
6
+ capabilities :edit_messages, :delete_messages, :ephemeral_messages,
7
+ :file_uploads, :reactions, :modals, :typing_indicator,
8
+ :streaming_edit, :threads, :direct_messages, :message_history
9
+
10
+ attr_reader :client
11
+
12
+ def initialize(bot_token: nil, signing_secret: nil)
13
+ @bot_token = bot_token || ENV["SLACK_BOT_TOKEN"]
14
+ @signing_secret = signing_secret || ENV["SLACK_SIGNING_SECRET"]
15
+
16
+ raise ChatSDK::ConfigurationError, "Slack bot_token required" unless @bot_token
17
+ raise ChatSDK::ConfigurationError, "Slack signing_secret required" unless @signing_secret
18
+
19
+ ::Slack.configure do |config|
20
+ config.token = @bot_token
21
+ end
22
+
23
+ @client = ::Slack::Web::Client.new(token: @bot_token)
24
+ @renderer = BlockKitRenderer.new
25
+ @modal_renderer = ModalRenderer.new
26
+ end
27
+
28
+ def name
29
+ :slack
30
+ end
31
+
32
+ # Inbound
33
+ def verify_request!(rack_request)
34
+ body = rack_request.body.read
35
+ rack_request.body.rewind
36
+ timestamp = rack_request.env["HTTP_X_SLACK_REQUEST_TIMESTAMP"]
37
+ signature = rack_request.env["HTTP_X_SLACK_SIGNATURE"]
38
+
39
+ raise ChatSDK::SignatureVerificationError, "Missing Slack signature headers" unless timestamp && signature
40
+
41
+ sig_basestring = "v0:#{timestamp}:#{body}"
42
+ hex_digest = OpenSSL::HMAC.hexdigest("SHA256", @signing_secret, sig_basestring)
43
+ computed = "v0=#{hex_digest}"
44
+
45
+ unless Rack::Utils.secure_compare(computed, signature)
46
+ raise ChatSDK::SignatureVerificationError, "Invalid Slack signature"
47
+ end
48
+
49
+ age = Time.now.to_i - timestamp.to_i
50
+ if age.abs > 300
51
+ raise ChatSDK::SignatureVerificationError, "Slack request too old (#{age}s)"
52
+ end
53
+
54
+ true
55
+ end
56
+
57
+ def ack_response(rack_request)
58
+ body = rack_request.body.read
59
+ rack_request.body.rewind
60
+
61
+ parsed = parse_body(body, rack_request.content_type)
62
+ return nil unless parsed
63
+
64
+ if parsed["type"] == "url_verification"
65
+ [200, {"content-type" => "text/plain"}, [parsed["challenge"]]]
66
+ end
67
+ end
68
+
69
+ def parse_events(rack_request)
70
+ body = rack_request.body.read
71
+ rack_request.body.rewind
72
+
73
+ parsed = parse_body(body, rack_request.content_type)
74
+ return [] unless parsed
75
+
76
+ EventParser.parse(parsed)
77
+ end
78
+
79
+ # Outbound
80
+ def post_message(channel_id:, message:, thread_id: nil)
81
+ msg = ChatSDK::PostableMessage.from(message)
82
+ params = {channel: channel_id}
83
+ params[:thread_ts] = thread_id if thread_id
84
+
85
+ if msg.card?
86
+ rendered = @renderer.render(msg.card)
87
+ params[:blocks] = rendered
88
+ params[:text] = msg.text || msg.card.fallback_text
89
+ else
90
+ params[:text] = msg.text
91
+ end
92
+
93
+ result = @client.chat_postMessage(**params)
94
+
95
+ ChatSDK::Message.new(
96
+ id: result["ts"],
97
+ text: msg.text || "",
98
+ author: ChatSDK::Author.new(id: "bot", name: "bot", platform: :slack, bot: true),
99
+ thread_id: thread_id || result["ts"],
100
+ channel_id: channel_id,
101
+ platform: :slack,
102
+ raw: result
103
+ )
104
+ end
105
+
106
+ def edit_message(channel_id:, message_id:, message:)
107
+ msg = ChatSDK::PostableMessage.from(message)
108
+ params = {channel: channel_id, ts: message_id}
109
+
110
+ if msg.card?
111
+ params[:blocks] = @renderer.render(msg.card)
112
+ params[:text] = msg.text || msg.card.fallback_text
113
+ else
114
+ params[:text] = msg.text
115
+ end
116
+
117
+ @client.chat_update(**params)
118
+ end
119
+
120
+ def delete_message(channel_id:, message_id:)
121
+ @client.chat_delete(channel: channel_id, ts: message_id)
122
+ end
123
+
124
+ def post_ephemeral(channel_id:, user_id:, message:, thread_id: nil)
125
+ msg = ChatSDK::PostableMessage.from(message)
126
+ params = {channel: channel_id, user: user_id}
127
+ params[:thread_ts] = thread_id if thread_id
128
+
129
+ if msg.card?
130
+ params[:blocks] = @renderer.render(msg.card)
131
+ params[:text] = msg.text || msg.card.fallback_text
132
+ else
133
+ params[:text] = msg.text
134
+ end
135
+
136
+ @client.chat_postEphemeral(**params)
137
+ end
138
+
139
+ def upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil)
140
+ params = {channels: channel_id, file: Faraday::Multipart::FilePart.new(io, nil, filename)}
141
+ params[:thread_ts] = thread_id if thread_id
142
+ params[:initial_comment] = comment if comment
143
+ @client.files_upload(**params)
144
+ end
145
+
146
+ def add_reaction(channel_id:, message_id:, emoji:)
147
+ @client.reactions_add(channel: channel_id, timestamp: message_id, name: emoji)
148
+ end
149
+
150
+ def remove_reaction(channel_id:, message_id:, emoji:)
151
+ @client.reactions_remove(channel: channel_id, timestamp: message_id, name: emoji)
152
+ end
153
+
154
+ def open_dm(user_id)
155
+ result = @client.conversations_open(users: user_id)
156
+ result["channel"]["id"]
157
+ end
158
+
159
+ def fetch_messages(channel_id:, thread_id: nil, cursor: nil, limit: 50)
160
+ result = if thread_id
161
+ @client.conversations_replies(channel: channel_id, ts: thread_id, cursor: cursor, limit: limit)
162
+ else
163
+ @client.conversations_history(channel: channel_id, cursor: cursor, limit: limit)
164
+ end
165
+ messages = result["messages"].map { |m| parse_slack_message(m, channel_id) }
166
+ [messages, result["response_metadata"]&.dig("next_cursor")]
167
+ end
168
+
169
+ def open_modal(trigger_id:, modal:)
170
+ view = @modal_renderer.render(modal)
171
+ @client.views_open(trigger_id: trigger_id, view: view)
172
+ end
173
+
174
+ def start_typing(channel_id:, thread_id: nil)
175
+ # Slack doesn't have a native typing indicator API for bots
176
+ # This is a no-op but the capability is declared for streaming support
177
+ end
178
+
179
+ def mention(user_id)
180
+ "<@#{user_id}>"
181
+ end
182
+
183
+ def render(postable_message)
184
+ if postable_message.card?
185
+ @renderer.render(postable_message.card)
186
+ else
187
+ postable_message.text
188
+ end
189
+ end
190
+
191
+ private
192
+
193
+ def parse_body(body, content_type)
194
+ if content_type&.include?("application/json")
195
+ JSON.parse(body)
196
+ elsif content_type&.include?("application/x-www-form-urlencoded")
197
+ params = Rack::Utils.parse_query(body)
198
+ if params["payload"]
199
+ JSON.parse(params["payload"])
200
+ else
201
+ params
202
+ end
203
+ else
204
+ begin
205
+ JSON.parse(body)
206
+ rescue JSON::ParserError
207
+ nil
208
+ end
209
+ end
210
+ end
211
+
212
+ def parse_slack_message(data, channel_id)
213
+ ChatSDK::Message.new(
214
+ id: data["ts"],
215
+ text: data["text"] || "",
216
+ author: ChatSDK::Author.new(
217
+ id: data["user"] || data["bot_id"] || "unknown",
218
+ name: data["username"] || data["user"] || "unknown",
219
+ platform: :slack,
220
+ bot: !!data["bot_id"]
221
+ ),
222
+ thread_id: data["thread_ts"] || data["ts"],
223
+ channel_id: channel_id,
224
+ platform: :slack,
225
+ timestamp: data["ts"],
226
+ raw: data
227
+ )
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Slack
5
+ class BlockKitRenderer
6
+ def render(node)
7
+ case node.type
8
+ when :card then render_card(node)
9
+ else [render_node(node)]
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def render_card(node)
16
+ node.children.map { |child| render_node(child) }.compact
17
+ end
18
+
19
+ def render_node(node)
20
+ case node.type
21
+ when :text then render_text(node)
22
+ when :divider then {type: "divider"}
23
+ when :image then render_image(node)
24
+ when :fields then render_fields(node)
25
+ when :section then render_section(node)
26
+ when :actions then render_actions(node)
27
+ end
28
+ end
29
+
30
+ def render_text(node)
31
+ {
32
+ type: "section",
33
+ text: {type: "mrkdwn", text: node.attributes[:content]}
34
+ }
35
+ end
36
+
37
+ def render_image(node)
38
+ block = {type: "image", image_url: node.attributes[:url]}
39
+ block[:alt_text] = node.attributes[:alt] || " "
40
+ block
41
+ end
42
+
43
+ def render_fields(node)
44
+ {
45
+ type: "section",
46
+ fields: node.children.map do |field|
47
+ {type: "mrkdwn", text: "*#{field.attributes[:label]}*\n#{field.attributes[:value]}"}
48
+ end
49
+ }
50
+ end
51
+
52
+ def render_section(node)
53
+ block = {type: "section"}
54
+ text_children = node.children.select { |c| c.type == :text }
55
+ if text_children.any?
56
+ block[:text] = {type: "mrkdwn", text: text_children.map { |t| t.attributes[:content] }.join("\n")}
57
+ end
58
+ block
59
+ end
60
+
61
+ def render_actions(node)
62
+ {
63
+ type: "actions",
64
+ elements: node.children.map { |child| render_action_element(child) }.compact
65
+ }
66
+ end
67
+
68
+ def render_action_element(node)
69
+ case node.type
70
+ when :button then render_button(node)
71
+ when :link_button then render_link_button(node)
72
+ when :select then render_select(node)
73
+ end
74
+ end
75
+
76
+ def render_button(node)
77
+ btn = {
78
+ type: "button",
79
+ text: {type: "plain_text", text: node.attributes[:text]},
80
+ action_id: node.attributes[:id]
81
+ }
82
+ btn[:value] = node.attributes[:value] if node.attributes[:value]
83
+ if node.attributes[:style] == :primary
84
+ btn[:style] = "primary"
85
+ elsif node.attributes[:style] == :danger
86
+ btn[:style] = "danger"
87
+ end
88
+ btn
89
+ end
90
+
91
+ def render_link_button(node)
92
+ {
93
+ type: "button",
94
+ text: {type: "plain_text", text: node.attributes[:text]},
95
+ url: node.attributes[:url],
96
+ action_id: "link_#{node.attributes[:url].hash.abs}"
97
+ }
98
+ end
99
+
100
+ def render_select(node)
101
+ sel = {
102
+ type: "static_select",
103
+ action_id: node.attributes[:id],
104
+ options: node.children.map { |opt| render_option(opt) }
105
+ }
106
+ if node.attributes[:placeholder]
107
+ sel[:placeholder] = {type: "plain_text", text: node.attributes[:placeholder]}
108
+ end
109
+ sel
110
+ end
111
+
112
+ def render_option(node)
113
+ opt = {
114
+ text: {type: "plain_text", text: node.attributes[:text]},
115
+ value: node.attributes[:value]
116
+ }
117
+ if node.attributes[:description]
118
+ opt[:description] = {type: "plain_text", text: node.attributes[:description]}
119
+ end
120
+ opt
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Slack
5
+ class EventParser
6
+ class << self
7
+ def parse(payload)
8
+ case payload["type"]
9
+ when "event_callback"
10
+ parse_event_callback(payload)
11
+ when "block_actions", "interactive_message"
12
+ parse_block_actions(payload)
13
+ when "view_submission"
14
+ parse_view_submission(payload)
15
+ when "slash_commands"
16
+ parse_slash_command(payload)
17
+ else
18
+ # Slash commands come as form-encoded, not wrapped in type
19
+ if payload["command"]
20
+ parse_slash_command(payload)
21
+ else
22
+ []
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def parse_event_callback(payload)
30
+ event = payload["event"]
31
+ return [] unless event
32
+
33
+ case event["type"]
34
+ when "app_mention"
35
+ parse_mention(event, payload)
36
+ when "message"
37
+ parse_message_event(event, payload)
38
+ when "reaction_added"
39
+ parse_reaction(event, payload, added: true)
40
+ when "reaction_removed"
41
+ parse_reaction(event, payload, added: false)
42
+ else
43
+ []
44
+ end
45
+ end
46
+
47
+ def parse_mention(event, payload)
48
+ author = ChatSDK::Author.new(
49
+ id: event["user"],
50
+ name: event["user"],
51
+ platform: :slack
52
+ )
53
+ message = ChatSDK::Message.new(
54
+ id: event["ts"],
55
+ text: event["text"] || "",
56
+ author: author,
57
+ thread_id: event["thread_ts"] || event["ts"],
58
+ channel_id: event["channel"],
59
+ platform: :slack,
60
+ raw: event
61
+ )
62
+ [ChatSDK::Events::Mention.new(
63
+ message: message,
64
+ thread_id: event["thread_ts"] || event["ts"],
65
+ channel_id: event["channel"],
66
+ platform: :slack,
67
+ adapter_name: :slack,
68
+ raw: payload
69
+ )]
70
+ end
71
+
72
+ def parse_message_event(event, payload)
73
+ return [] if event["subtype"] && event["subtype"] != "file_share"
74
+ return [] if event["bot_id"]
75
+
76
+ author = ChatSDK::Author.new(
77
+ id: event["user"],
78
+ name: event["user"],
79
+ platform: :slack
80
+ )
81
+ message = ChatSDK::Message.new(
82
+ id: event["ts"],
83
+ text: event["text"] || "",
84
+ author: author,
85
+ thread_id: event["thread_ts"] || event["ts"],
86
+ channel_id: event["channel"],
87
+ platform: :slack,
88
+ raw: event
89
+ )
90
+
91
+ channel_type = event["channel_type"]
92
+ if channel_type == "im"
93
+ [ChatSDK::Events::DirectMessage.new(
94
+ message: message,
95
+ thread_id: event["thread_ts"] || event["ts"],
96
+ channel_id: event["channel"],
97
+ platform: :slack,
98
+ adapter_name: :slack,
99
+ raw: payload
100
+ )]
101
+ else
102
+ [ChatSDK::Events::SubscribedMessage.new(
103
+ message: message,
104
+ thread_id: event["thread_ts"] || event["ts"],
105
+ channel_id: event["channel"],
106
+ platform: :slack,
107
+ adapter_name: :slack,
108
+ raw: payload
109
+ )]
110
+ end
111
+ end
112
+
113
+ def parse_reaction(event, payload, added:)
114
+ [ChatSDK::Events::Reaction.new(
115
+ emoji: event["reaction"],
116
+ user_id: event["user"],
117
+ message_id: event.dig("item", "ts"),
118
+ thread_id: event.dig("item", "ts"),
119
+ channel_id: event.dig("item", "channel"),
120
+ added: added,
121
+ platform: :slack,
122
+ adapter_name: :slack,
123
+ raw: payload
124
+ )]
125
+ end
126
+
127
+ def parse_block_actions(payload)
128
+ actions = payload["actions"] || []
129
+ user = payload["user"]
130
+ channel = payload.dig("channel", "id")
131
+ message_ts = payload.dig("message", "ts")
132
+ thread_ts = payload.dig("message", "thread_ts") || message_ts
133
+ trigger_id = payload["trigger_id"]
134
+
135
+ actions.map do |action|
136
+ ChatSDK::Events::Action.new(
137
+ action_id: action["action_id"],
138
+ value: action["value"] || action.dig("selected_option", "value"),
139
+ user: ChatSDK::Author.new(id: user["id"], name: user["name"] || user["id"], platform: :slack),
140
+ thread_id: thread_ts,
141
+ channel_id: channel,
142
+ trigger_id: trigger_id,
143
+ platform: :slack,
144
+ adapter_name: :slack,
145
+ raw: payload
146
+ )
147
+ end
148
+ end
149
+
150
+ def parse_view_submission(payload)
151
+ # View submissions are handled differently - return empty for now
152
+ # They'll be handled via on_modal_submit in a future version
153
+ []
154
+ end
155
+
156
+ def parse_slash_command(payload)
157
+ [ChatSDK::Events::SlashCommand.new(
158
+ command: payload["command"],
159
+ text: payload["text"] || "",
160
+ user_id: payload["user_id"],
161
+ channel_id: payload["channel_id"],
162
+ trigger_id: payload["trigger_id"],
163
+ platform: :slack,
164
+ adapter_name: :slack,
165
+ raw: payload
166
+ )]
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Slack
5
+ class ModalRenderer
6
+ def render(node)
7
+ view = {
8
+ type: "modal",
9
+ title: {type: "plain_text", text: node.attributes[:title]}
10
+ }
11
+ view[:callback_id] = node.attributes[:callback_id] if node.attributes[:callback_id]
12
+ if node.attributes[:submit_label]
13
+ view[:submit] = {type: "plain_text", text: node.attributes[:submit_label]}
14
+ end
15
+ view[:blocks] = node.children.map { |child| render_block(child) }.compact
16
+ view
17
+ end
18
+
19
+ private
20
+
21
+ def render_block(node)
22
+ case node.type
23
+ when :input then render_input(node)
24
+ when :text then render_text(node)
25
+ end
26
+ end
27
+
28
+ def render_input(node)
29
+ block = {
30
+ type: "input",
31
+ block_id: node.attributes[:id],
32
+ label: {type: "plain_text", text: node.attributes[:label]},
33
+ optional: node.attributes[:optional] || false
34
+ }
35
+ block[:element] = case node.attributes[:input_type]
36
+ when :text
37
+ el = {
38
+ type: "plain_text_input",
39
+ action_id: node.attributes[:id],
40
+ multiline: !!node.attributes[:multiline]
41
+ }
42
+ el[:placeholder] = {type: "plain_text", text: node.attributes[:placeholder]} if node.attributes[:placeholder]
43
+ el
44
+ when :select
45
+ el = {
46
+ type: "static_select",
47
+ action_id: node.attributes[:id],
48
+ options: node.children.map { |opt| render_option(opt) }
49
+ }
50
+ el[:placeholder] = {type: "plain_text", text: node.attributes[:placeholder]} if node.attributes[:placeholder]
51
+ el
52
+ end
53
+ block
54
+ end
55
+
56
+ def render_text(node)
57
+ {
58
+ type: "section",
59
+ text: {type: "mrkdwn", text: node.attributes[:content]}
60
+ }
61
+ end
62
+
63
+ def render_option(node)
64
+ opt = {
65
+ text: {type: "plain_text", text: node.attributes[:text]},
66
+ value: node.attributes[:value]
67
+ }
68
+ opt[:description] = {type: "plain_text", text: node.attributes[:description]} if node.attributes[:description]
69
+ opt
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "chat_sdk"
4
+ require "slack-ruby-client"
5
+ require "zeitwerk"
6
+
7
+ module ChatSDK
8
+ module Slack
9
+ class << self
10
+ def loader
11
+ @loader ||= begin
12
+ loader = Zeitwerk::Loader.new
13
+ loader.tag = "chat_sdk-slack"
14
+ loader.inflector.inflect("chat_sdk" => "ChatSDK")
15
+ loader.push_dir("#{__dir__}/slack", namespace: ChatSDK::Slack)
16
+ loader.setup
17
+ loader
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ ChatSDK::Slack.loader
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: chat_sdk-slack
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rootly
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: chat_sdk
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: slack-ruby-client
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ description: Slack bot adapter for the ChatSDK framework using slack-ruby-client
41
+ email:
42
+ - eng@rootly.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/chat_sdk/slack.rb
48
+ - lib/chat_sdk/slack/adapter.rb
49
+ - lib/chat_sdk/slack/block_kit_renderer.rb
50
+ - lib/chat_sdk/slack/event_parser.rb
51
+ - lib/chat_sdk/slack/modal_renderer.rb
52
+ homepage: https://github.com/rootlyhq/rootly-chat-sdk
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ rubygems_mfa_required: 'true'
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.2.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 4.0.16
72
+ specification_version: 4
73
+ summary: Slack adapter for ChatSDK
74
+ test_files: []