chat_sdk-slack 1.1.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d93aacf823c427212c01efbdf76255caf3cd4a534b033b5557a9bbff9aaa8b97
4
- data.tar.gz: a82ac7a769d42943060042cfaf16179b1a7c3482d9bfd9b268631b59a9723fa8
3
+ metadata.gz: d10492534a772bad66a756eabd3e3a89ef35f63ed61cdb84431979be6b844906
4
+ data.tar.gz: 720e5ea6b813a557335e59d2c2b42c9f5194a7f2dc8dfca0c6a7e937f36cbe2c
5
5
  SHA512:
6
- metadata.gz: 49b4f95d68bb451922802284fae8ef36f9dffbc72ae33c44943618e5ca4141410c9f58cc885d353b96a2004ee12544cab4739cfeb0c3ef8fc2557c8bb731f3d1
7
- data.tar.gz: f1ad880423c30b689eff5476070d3227310752b3122fd97c17a3fe34d8b52a3b0e05f7c67688b1bb47511dc839c0a1704fc3cfb210395d6654a966e119ff199e
6
+ metadata.gz: 4e4bd3615d525bf00f61885c3ff8830553e99644f4a2ba319ab3c547f611b87bb6035edb2eb1bc717b11102fa1454f6fe60d1c9c8f4f824156de09f516f20858
7
+ data.tar.gz: 6b33b090da4111d30602550197797a2169125caa3982e7ce22b44091b9cb5de38b4a22caf6090ca4aecf448314cb6017bcbe2115182ea67dec64c7ea2141be90
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "stringio"
4
+
3
5
  module ChatSDK
4
6
  module Slack
5
7
  class Adapter < ChatSDK::Adapter::Base
@@ -9,33 +11,86 @@ module ChatSDK
9
11
  :scheduled_messages
10
12
 
11
13
  def initialize(bot_token: nil, signing_secret: nil,
12
- client_id: nil, client_secret: nil, proxy: nil)
13
- @bot_token = bot_token || ENV["SLACK_BOT_TOKEN"]
14
+ client_id: nil, client_secret: nil, proxy: nil, bot_user_id: nil,
15
+ installation_provider: nil, encryption_key: nil, webhook_verifier: nil,
16
+ native_streaming: true, stream_segment_max_age: 240)
17
+ @bot_token = bot_token.nil? ? ENV["SLACK_BOT_TOKEN"] : bot_token
14
18
  @signing_secret = signing_secret || ENV["SLACK_SIGNING_SECRET"]
15
19
  @client_id = client_id || ENV["SLACK_CLIENT_ID"]
16
20
  @client_secret = client_secret || ENV["SLACK_CLIENT_SECRET"]
21
+ @bot_user_id = bot_user_id || ENV["SLACK_BOT_USER_ID"]
22
+ @installation_provider = installation_provider
23
+ @webhook_verifier = webhook_verifier
24
+ encryption_key ||= ENV["SLACK_ENCRYPTION_KEY"]
25
+ @encryption_key = TokenEncryption.decode_key(encryption_key) if encryption_key
26
+ valid_segment_max_age = stream_segment_max_age.is_a?(Numeric) &&
27
+ !stream_segment_max_age.is_a?(Complex) &&
28
+ !(stream_segment_max_age.respond_to?(:nan?) && stream_segment_max_age.nan?) &&
29
+ (stream_segment_max_age.positive? || stream_segment_max_age == Float::INFINITY)
30
+ unless valid_segment_max_age
31
+ raise ChatSDK::ConfigurationError, "stream_segment_max_age must be a positive number"
32
+ end
33
+ @native_streaming = native_streaming
34
+ @native_streaming_broken = false
35
+ @stream_segment_max_age = stream_segment_max_age
36
+ @bot_user_ids = {}
37
+ @bot_user_id_mutex = Mutex.new
38
+ @client_cache = {}
39
+ @client_cache_mutex = Mutex.new
40
+ @context_key = :"chat_sdk_slack_context_#{object_id}"
17
41
  @proxy = proxy || ENV["HTTPS_PROXY"] || ENV["https_proxy"]
18
42
 
19
- unless @bot_token || @client_id
20
- raise ChatSDK::ConfigurationError, "Slack bot_token or client_id required"
43
+ if !@bot_token.nil? && !@bot_token.is_a?(String) && !@bot_token.respond_to?(:call)
44
+ raise ChatSDK::ConfigurationError, "Slack bot_token must be a String or callable"
45
+ end
46
+ if !@installation_provider.nil? && !@installation_provider.respond_to?(:get_installation) && !@installation_provider.respond_to?(:call)
47
+ raise ChatSDK::ConfigurationError, "Slack installation_provider must respond to get_installation or call"
48
+ end
49
+ if !@webhook_verifier.nil? && !@webhook_verifier.respond_to?(:call)
50
+ raise ChatSDK::ConfigurationError, "Slack webhook_verifier must be callable"
51
+ end
52
+ unless @bot_token || @client_id || @installation_provider
53
+ raise ChatSDK::ConfigurationError, "Slack bot_token, client_id or installation_provider required"
54
+ end
55
+ if @webhook_verifier.nil? && !@signing_secret
56
+ raise ChatSDK::ConfigurationError, "Slack signing_secret or webhook_verifier required"
21
57
  end
22
- raise ChatSDK::ConfigurationError, "Slack signing_secret required" unless @signing_secret
23
58
 
24
- if @bot_token
59
+ if @bot_token.is_a?(String)
25
60
  ::Slack.configure do |config|
26
61
  config.token = @bot_token
27
62
  end
28
- @client = build_client(token: @bot_token)
63
+ @client = client_for_token(@bot_token)
29
64
  end
30
65
 
31
66
  @renderer = BlockKitRenderer.new
32
67
  @modal_renderer = ModalRenderer.new
33
- @team_clients = {}
34
68
  end
35
69
 
36
70
  # Returns the per-request client (multi-workspace) or the static client (single-workspace).
37
71
  def client
38
- ::Thread.current[:chat_sdk_slack_client] || @client
72
+ request_context = context
73
+ return client_for_token(request_context[:token]) if request_context && !request_context[:token].nil?
74
+ return @client if @bot_token.is_a?(String)
75
+ return unless @bot_token.respond_to?(:call)
76
+
77
+ token = @bot_token.call
78
+ if token.nil? || token == false || (token.respond_to?(:empty?) && token.empty?)
79
+ raise ChatSDK::ConfigurationError, "Slack bot_token resolver returned no token"
80
+ end
81
+ client_for_token(token)
82
+ end
83
+
84
+ def multi_workspace?
85
+ @bot_token.nil? || !@client_id.nil?
86
+ end
87
+
88
+ def with_bot_token(token, installation_id: nil)
89
+ previous_context = context
90
+ set_context(token: token, installation_id: installation_id)
91
+ yield
92
+ ensure
93
+ set_context(previous_context)
39
94
  end
40
95
 
41
96
  # Inject state store after initialization (e.g., from Chat instance).
@@ -45,25 +100,39 @@ module ChatSDK
45
100
 
46
101
  # --- Multi-workspace installation management ---
47
102
 
48
- def set_installation(team_id, bot_token:, bot_user_id: nil, team_name: nil)
103
+ def set_installation(team_id, bot_token:, bot_user_id: nil, team_name: nil,
104
+ enterprise_id: nil, enterprise_install: nil)
49
105
  raise ChatSDK::ConfigurationError, "Multi-workspace mode requires state" unless @state
50
106
 
51
- @team_clients.delete(team_id)
52
- @state.set(installation_key(team_id), {
53
- "bot_token" => bot_token,
107
+ installation = {
108
+ "bot_token" => @encryption_key ? TokenEncryption.encrypt(bot_token, @encryption_key) : bot_token,
54
109
  "bot_user_id" => bot_user_id,
55
110
  "team_name" => team_name
56
- })
111
+ }
112
+ installation["enterprise_id"] = enterprise_id unless enterprise_id.nil?
113
+ installation["is_enterprise_install"] = enterprise_install unless enterprise_install.nil?
114
+ @state.set(installation_key(team_id), installation)
115
+ invalidate_bot_user_id(team_id)
57
116
  end
58
117
 
59
118
  def get_installation(team_id)
60
119
  return nil unless @state
61
120
 
62
- @state.get(installation_key(team_id))
121
+ installation = @state.get(installation_key(team_id))
122
+ return installation unless installation.is_a?(Hash)
123
+
124
+ token_key = installation.key?("bot_token") ? "bot_token" : :bot_token
125
+ token = installation[token_key]
126
+ return installation unless TokenEncryption.encrypted?(token)
127
+ unless @encryption_key
128
+ raise ChatSDK::ConfigurationError, "Slack installation token is encrypted; configure encryption_key"
129
+ end
130
+
131
+ installation.merge(token_key => TokenEncryption.decrypt(token, @encryption_key))
63
132
  end
64
133
 
65
134
  def delete_installation(team_id)
66
- @team_clients.delete(team_id)
135
+ invalidate_bot_user_id(team_id)
67
136
  return unless @state
68
137
 
69
138
  @state.delete(installation_key(team_id))
@@ -82,19 +151,39 @@ module ChatSDK
82
151
 
83
152
  result = temp_client.oauth_v2_access(**params)
84
153
 
85
- team_id = result["team"]["id"]
154
+ team = hash_value(result, "team")
155
+ enterprise = hash_value(result, "enterprise")
156
+ enterprise_install = truthy_slack_value?(hash_value(result, "is_enterprise_install"))
157
+ enterprise_id = hash_value(enterprise, "id") || hash_value(result, "enterprise_id")
158
+ team_id = hash_value(team, "id")
159
+ installation_id = enterprise_install ? enterprise_id : team_id
160
+ access_token = hash_value(result, "access_token")
161
+ unless access_token && !access_token.empty? && installation_id && !installation_id.empty?
162
+ missing = enterprise_install ? "access_token or enterprise.id" : "access_token or team.id"
163
+ raise ChatSDK::ConfigurationError, "Slack OAuth response missing #{missing}"
164
+ end
165
+
86
166
  installation = {
87
- "bot_token" => result["access_token"],
88
- "bot_user_id" => result["bot_user_id"],
89
- "team_name" => result["team"]["name"]
167
+ "bot_token" => access_token,
168
+ "bot_user_id" => hash_value(result, "bot_user_id"),
169
+ "team_name" => hash_value(team, "name") || hash_value(enterprise, "name")
90
170
  }
171
+ installation["enterprise_id"] = enterprise_id unless enterprise_id.nil?
172
+ installation["is_enterprise_install"] = enterprise_install if hash_key?(result, "is_enterprise_install")
91
173
 
92
- set_installation(team_id,
174
+ set_installation(installation_id,
93
175
  bot_token: installation["bot_token"],
94
176
  bot_user_id: installation["bot_user_id"],
95
- team_name: installation["team_name"])
96
-
97
- {team_id: team_id, installation: installation}
177
+ team_name: installation["team_name"],
178
+ enterprise_id: enterprise_id,
179
+ enterprise_install: hash_key?(result, "is_enterprise_install") ? enterprise_install : nil)
180
+
181
+ {
182
+ team_id: installation_id,
183
+ enterprise_id: enterprise_id,
184
+ enterprise_install: enterprise_install,
185
+ installation: installation
186
+ }
98
187
  end
99
188
 
100
189
  def name
@@ -105,6 +194,18 @@ module ChatSDK
105
194
  def verify_request!(rack_request)
106
195
  body = rack_request.body.read
107
196
  rack_request.body.rewind
197
+ if @webhook_verifier
198
+ begin
199
+ result = @webhook_verifier.call(rack_request, body)
200
+ rescue => e
201
+ raise ChatSDK::SignatureVerificationError, "Slack webhook verifier failed: #{e.message}"
202
+ end
203
+ raise ChatSDK::SignatureVerificationError, "Slack webhook verifier rejected the request" unless result
204
+
205
+ rack_request.env["rack.input"] = StringIO.new(result) if result.is_a?(String)
206
+ return true
207
+ end
208
+
108
209
  timestamp = rack_request.env["HTTP_X_SLACK_REQUEST_TIMESTAMP"]
109
210
  signature = rack_request.env["HTTP_X_SLACK_SIGNATURE"]
110
211
 
@@ -139,21 +240,17 @@ module ChatSDK
139
240
  end
140
241
 
141
242
  def parse_events(rack_request)
243
+ set_context(nil)
142
244
  body = rack_request.body.read
143
245
  rack_request.body.rewind
144
246
 
145
247
  parsed = parse_body(body, rack_request.content_type)
146
248
  return [] unless parsed
147
249
 
148
- # Multi-workspace: resolve per-team client from payload
149
- # Clear any stale client from a previous request on this thread (Puma thread pool safety)
150
- if @client_id
151
- ::Thread.current[:chat_sdk_slack_client] = nil
152
- team_id = parsed["team_id"] || parsed.dig("team", "id")
153
- resolve_team_client(team_id) if team_id
154
- end
250
+ return [] unless prepare_payload_context(parsed)
155
251
 
156
- EventParser.parse(parsed)
252
+ events = EventParser.parse(parsed)
253
+ classify_message_events(events, parsed)
157
254
  end
158
255
 
159
256
  # Outbound
@@ -177,7 +274,27 @@ module ChatSDK
177
274
  )
178
275
  end
179
276
 
180
- def edit_message(channel_id:, message_id:, message:)
277
+ def stream(channel_id:, thread_id:, stream:, options: {})
278
+ return unless thread_id && !thread_id.to_s.empty?
279
+ direct_message = channel_id.to_s.start_with?("D")
280
+ recipient_ids = [options[:recipient_user_id], options[:recipient_team_id]]
281
+ has_recipient_context = recipient_ids.all? { |id| id && !id.to_s.empty? }
282
+ return unless direct_message || has_recipient_context
283
+ return unless @native_streaming && !@native_streaming_broken
284
+
285
+ stream_client = client
286
+ NativeStream.new(
287
+ adapter: self,
288
+ client: stream_client,
289
+ channel_id: channel_id,
290
+ thread_id: thread_id,
291
+ stream: stream,
292
+ options: options,
293
+ max_age: @stream_segment_max_age
294
+ ).run
295
+ end
296
+
297
+ def edit_message(channel_id:, message_id:, message:, **_options)
181
298
  msg = ChatSDK::PostableMessage.from(message)
182
299
  params = {channel: channel_id, ts: message_id}
183
300
 
@@ -186,7 +303,7 @@ module ChatSDK
186
303
  client.chat_update(**params)
187
304
  end
188
305
 
189
- def delete_message(channel_id:, message_id:)
306
+ def delete_message(channel_id:, message_id:, **_options)
190
307
  client.chat_delete(channel: channel_id, ts: message_id)
191
308
  end
192
309
 
@@ -201,17 +318,19 @@ module ChatSDK
201
318
  end
202
319
 
203
320
  def upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil)
204
- params = {channels: channel_id, file: Faraday::Multipart::FilePart.new(io, nil, filename)}
321
+ content = io.read
322
+ io.rewind if io.respond_to?(:rewind)
323
+ params = {channel_id: channel_id, filename: filename, content: content}
205
324
  params[:thread_ts] = thread_id if thread_id
206
325
  params[:initial_comment] = comment if comment
207
- client.files_upload(**params)
326
+ client.files_upload_v2(**params)
208
327
  end
209
328
 
210
- def add_reaction(channel_id:, message_id:, emoji:)
329
+ def add_reaction(channel_id:, message_id:, emoji:, **_options)
211
330
  client.reactions_add(channel: channel_id, timestamp: message_id, name: emoji)
212
331
  end
213
332
 
214
- def remove_reaction(channel_id:, message_id:, emoji:)
333
+ def remove_reaction(channel_id:, message_id:, emoji:, **_options)
215
334
  client.reactions_remove(channel: channel_id, timestamp: message_id, name: emoji)
216
335
  end
217
336
 
@@ -315,11 +434,24 @@ module ChatSDK
315
434
  result["channel"]
316
435
  end
317
436
 
318
- def start_typing(channel_id:, thread_id: nil)
437
+ def start_typing(channel_id:, thread_id: nil, message_id: nil)
319
438
  # Slack doesn't have a native typing indicator API for bots
320
439
  # This is a no-op but the capability is declared for streaming support
321
440
  end
322
441
 
442
+ def concurrency_scope(event)
443
+ return context[:installation_id] if context && context[:installation_id]
444
+ return unless multi_workspace?
445
+
446
+ raw = event.raw.is_a?(Hash) ? event.raw : {}
447
+ raw_event = raw.is_a?(Hash) ? raw["event"] || raw[:event] || raw : {}
448
+ raw_team = raw.is_a?(Hash) ? raw["team"] || raw[:team] : nil
449
+ raw_team_id = raw_team.is_a?(Hash) ? raw_team["id"] || raw_team[:id] : raw_team
450
+ raw_team_id || raw["team_id"] || raw[:team_id] ||
451
+ raw_event["team_id"] || raw_event[:team_id] ||
452
+ raw_event["team"] || raw_event[:team]
453
+ end
454
+
323
455
  # Slack-specific: receive real-time events via Socket Mode WebSocket.
324
456
  # Requires the optional 'faye-websocket' gem and an app-level token (xapp-*).
325
457
  # Not part of the base adapter contract.
@@ -331,9 +463,17 @@ module ChatSDK
331
463
  def start_socket_mode(app_token: nil, &block)
332
464
  app_token ||= ENV["SLACK_APP_TOKEN"]
333
465
  raise ChatSDK::ConfigurationError, "Slack app_token required for socket mode" unless app_token
466
+ raise ArgumentError, "start requires a block" unless block
334
467
 
335
468
  @socket_mode = SocketMode.new(app_token: app_token, bot_client: client)
336
- @socket_mode.start(&block)
469
+ @socket_mode.start do |event, payload|
470
+ payload ||= event.raw
471
+ set_context(nil)
472
+ next unless prepare_payload_context(payload)
473
+
474
+ classify_message_events([event], payload)
475
+ block.call(event)
476
+ end
337
477
  end
338
478
 
339
479
  def stop_socket_mode
@@ -354,28 +494,224 @@ module ChatSDK
354
494
 
355
495
  private
356
496
 
497
+ def monotonic_now
498
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
499
+ end
500
+
357
501
  def build_client(token: nil)
358
502
  options = {}
359
- options[:token] = token if token
503
+ options[:token] = token unless token.nil?
360
504
  options[:proxy] = @proxy if @proxy
361
505
  options.empty? ? ::Slack::Web::Client.new : ::Slack::Web::Client.new(options)
362
506
  end
363
507
 
364
- def resolve_team_client(team_id)
365
- return unless @client_id && @state
508
+ def classify_message_events(events, payload)
509
+ events.each do |event|
510
+ next unless %i[mention subscribed_message direct_message].include?(event.type)
511
+
512
+ event.message.is_mention = classify_mention(event, payload)
513
+ end
514
+ events
515
+ end
516
+
517
+ def classify_mention(event, payload)
518
+ raw_event = payload["event"] || {}
519
+ return true if raw_event["type"] == "app_mention"
520
+
521
+ bot_user_id = resolve_bot_user_id(event)
522
+ return true if event.type == :mention && bot_user_id.nil?
523
+ return nil if bot_user_id.nil?
524
+
525
+ text = raw_event["text"].to_s
526
+ masked_text = ChatSDK::Format::Mentions.mask_code_spans(text)
527
+ text_mentions_bot = /<@#{Regexp.escape(bot_user_id)}(?:\|[^>]*)?>/.match?(masked_text)
528
+ text_mentions_bot || rich_text_mentions_bot?(raw_event["blocks"], bot_user_id)
529
+ end
530
+
531
+ def resolve_bot_user_id(event)
532
+ request_context = context
533
+ return request_context[:bot_user_id] if request_context && request_context[:bot_user_id]
534
+
535
+ return @bot_user_id if @bot_user_id
536
+
537
+ installation_id = if multi_workspace?
538
+ request_context&.[](:installation_id) || event_installation_id(event)
539
+ end
540
+ installation = resolve_installation(installation_id, enterprise_install: installation_enterprise_flag(event)) if installation_id && !request_context
541
+ installation_bot_user_id = hash_value(installation, "bot_user_id")
542
+ return installation_bot_user_id if installation_bot_user_id
543
+
544
+ token = request_context&.[](:token) || hash_value(installation, "bot_token")
545
+ current_client = token ? client_for_token(token) : client
546
+ return unless current_client
547
+
548
+ @bot_user_id_mutex.synchronize do
549
+ cache_key = multi_workspace? ? (installation_id || :default) : :default
550
+ return @bot_user_ids[cache_key] if @bot_user_ids.key?(cache_key)
551
+
552
+ bot_user_id = begin
553
+ current_client.auth_test["user_id"]
554
+ rescue ::Slack::Web::Api::Errors::SlackError, Faraday::Error, SocketError, Timeout::Error
555
+ nil
556
+ end
557
+ @bot_user_ids[cache_key] = bot_user_id if bot_user_id
558
+ bot_user_id
559
+ end
560
+ end
561
+
562
+ def invalidate_bot_user_id(team_id)
563
+ @bot_user_id_mutex.synchronize { @bot_user_ids.delete(team_id) }
564
+ end
565
+
566
+ def rich_text_mentions_bot?(blocks, bot_user_id)
567
+ Array(blocks).any? { |block| rich_text_element_mentions_bot?(block, bot_user_id) }
568
+ end
569
+
570
+ def rich_text_element_mentions_bot?(element, bot_user_id)
571
+ return false unless element.is_a?(Hash)
572
+ return false if %w[rich_text_preformatted preformatted code].include?(element["type"])
573
+ return false if element.dig("style", "code")
574
+ return true if element["type"] == "user" && element["user_id"] == bot_user_id
575
+
576
+ Array(element["elements"]).any? { |child| rich_text_element_mentions_bot?(child, bot_user_id) }
577
+ end
578
+
579
+ def client_for_token(token)
580
+ @client_cache_mutex.synchronize do
581
+ return @client_cache[token] if @client_cache.key?(token)
582
+
583
+ @client_cache[token] = build_client(token: token)
584
+ @client_cache.shift while @client_cache.size > 256
585
+ @client_cache[token]
586
+ end
587
+ end
588
+
589
+ def context
590
+ ::Thread.current[@context_key]
591
+ end
592
+
593
+ def set_context(value)
594
+ ::Thread.current[@context_key] = value
595
+ end
596
+
597
+ def prepare_payload_context(payload)
598
+ return true unless multi_workspace?
599
+
600
+ identity = extract_installation(payload)
601
+ return true unless identity
602
+
603
+ installation = resolve_installation(
604
+ identity[:installation_id],
605
+ enterprise_install: identity[:enterprise_install]
606
+ )
607
+ token = hash_value(installation, "bot_token")
608
+ unless installation && token && token != false && !(token.respond_to?(:empty?) && token.empty?)
609
+ return true unless @bot_token.nil?
610
+
611
+ ChatSDK::Log.warn("Dropping Slack event without a resolved installation for #{identity[:installation_id]}")
612
+ return false
613
+ end
614
+
615
+ set_context({
616
+ token: token,
617
+ bot_user_id: hash_value(installation, "bot_user_id"),
618
+ installation_id: identity[:installation_id],
619
+ enterprise_install: identity[:enterprise_install],
620
+ team_id: identity[:team_id],
621
+ enterprise_id: identity[:enterprise_id]
622
+ })
623
+ true
624
+ end
625
+
626
+ def resolve_installation(installation_id, enterprise_install:)
627
+ return unless installation_id
366
628
 
367
- cached = @team_clients[team_id]
368
- if cached
369
- ::Thread.current[:chat_sdk_slack_client] = cached
629
+ installation = if @installation_provider
630
+ if @installation_provider.respond_to?(:get_installation)
631
+ @installation_provider.get_installation(installation_id, enterprise_install: enterprise_install)
632
+ else
633
+ @installation_provider.call(installation_id, enterprise_install: enterprise_install)
634
+ end
635
+ else
636
+ get_installation(installation_id)
637
+ end
638
+ unless installation
639
+ ChatSDK::Log.warn("Slack installation not found for #{installation_id}")
640
+ return
641
+ end
642
+ token = hash_value(installation, "bot_token")
643
+ unless installation.is_a?(Hash) && token && token != false && !(token.respond_to?(:empty?) && token.empty?)
644
+ ChatSDK::Log.warn("Slack installation for #{installation_id} has no bot_token")
370
645
  return
371
646
  end
372
647
 
373
- installation = get_installation(team_id)
374
- return unless installation
648
+ installation
649
+ rescue => e
650
+ ChatSDK::Log.error("Could not resolve Slack installation #{installation_id}: #{e.message}")
651
+ nil
652
+ end
653
+
654
+ def extract_installation(payload)
655
+ return unless payload.is_a?(Hash)
656
+
657
+ if hash_value(payload, "type") == "event_callback"
658
+ authorization = Array(hash_value(payload, "authorizations")).first
659
+ enterprise_flag = if hash_key?(authorization, "is_enterprise_install")
660
+ hash_value(authorization, "is_enterprise_install")
661
+ else
662
+ hash_value(payload, "is_enterprise_install")
663
+ end
664
+ enterprise_install = truthy_slack_value?(enterprise_flag)
665
+ team_id = hash_value(authorization, "team_id") || hash_value(payload, "team_id")
666
+ enterprise_id = hash_value(authorization, "enterprise_id") || hash_value(payload, "enterprise_id")
667
+ elsif hash_key?(payload, "command")
668
+ enterprise_install = hash_value(payload, "is_enterprise_install") == "true"
669
+ team_id = hash_value(payload, "team_id")
670
+ enterprise_id = hash_value(payload, "enterprise_id")
671
+ else
672
+ enterprise_install = truthy_slack_value?(hash_value(payload, "is_enterprise_install"))
673
+ team = hash_value(payload, "team")
674
+ enterprise = hash_value(payload, "enterprise")
675
+ team_id = hash_value(team, "id") || hash_value(payload, "team_id")
676
+ enterprise_id = hash_value(enterprise, "id") || hash_value(payload, "enterprise_id")
677
+ end
678
+
679
+ installation_id = enterprise_install ? enterprise_id : team_id
680
+ return unless installation_id
681
+
682
+ {
683
+ installation_id: installation_id,
684
+ enterprise_install: enterprise_install,
685
+ team_id: team_id,
686
+ enterprise_id: enterprise_id
687
+ }
688
+ end
689
+
690
+ def event_installation_id(event)
691
+ raw = event.raw.is_a?(Hash) ? event.raw : {}
692
+ extract_installation(raw)&.[](:installation_id) ||
693
+ hash_value(raw, "team_id") ||
694
+ hash_value(hash_value(raw, "team"), "id")
695
+ end
696
+
697
+ def installation_enterprise_flag(event)
698
+ raw = event.raw.is_a?(Hash) ? event.raw : {}
699
+ identity = extract_installation(raw)
700
+ identity ? identity[:enterprise_install] : truthy_slack_value?(hash_value(raw, "is_enterprise_install"))
701
+ end
702
+
703
+ def hash_value(hash, key)
704
+ return unless hash.is_a?(Hash)
705
+
706
+ hash.key?(key) ? hash[key] : hash[key.to_sym]
707
+ end
708
+
709
+ def hash_key?(hash, key)
710
+ hash.is_a?(Hash) && (hash.key?(key) || hash.key?(key.to_sym))
711
+ end
375
712
 
376
- new_client = build_client(token: installation["bot_token"])
377
- @team_clients[team_id] = new_client
378
- ::Thread.current[:chat_sdk_slack_client] = new_client
713
+ def truthy_slack_value?(value)
714
+ value == true || value == "true"
379
715
  end
380
716
 
381
717
  def installation_key(team_id)