chat_sdk-slack 1.2.0 → 1.4.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: 49b9759d50173e826b41f885fcba2f2c276d28574f187ac70bd1932d3da28ab4
4
- data.tar.gz: 80c4806743ab979c9a6f024c5f7d8081152cdf8eaacb63607728e78518b88a5c
3
+ metadata.gz: 6241d4c2d31e93f23988f0454bdc618d527d03542b1fad95fdb0326024d4268c
4
+ data.tar.gz: 720e5ea6b813a557335e59d2c2b42c9f5194a7f2dc8dfca0c6a7e937f36cbe2c
5
5
  SHA512:
6
- metadata.gz: c79dccb6431aa33e0c9aa756287b7ec4342e97d4733b42e91f94e5ecfe44267e73dd404890dc13d600700985c6d958f03acad36f52121ec62ba010eb14145019
7
- data.tar.gz: cebaf0452264d3b98802a1774fd834d77fa3be5b83a9f38848890c0b8ec742f976a0e510edf520f97f06e0375ca33c5fdce5af845043381cdb8a0ba1ee3b351d
6
+ metadata.gz: '08bdf2fc0b16165e5de33015cf87c40aea4b5ace2934c3cf503a0488f43ba22cce49a81aaa2aa06078a4d8dc414a0be2ac958a5d9b8c4e66701ebf62048e74e6'
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,38 +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, bot_user_id: 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"]
17
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
18
36
  @bot_user_ids = {}
19
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}"
20
41
  @proxy = proxy || ENV["HTTPS_PROXY"] || ENV["https_proxy"]
21
42
 
22
- unless @bot_token || @client_id
23
- 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"
24
57
  end
25
- raise ChatSDK::ConfigurationError, "Slack signing_secret required" unless @signing_secret
26
58
 
27
- if @bot_token
59
+ if @bot_token.is_a?(String)
28
60
  ::Slack.configure do |config|
29
61
  config.token = @bot_token
30
62
  end
31
- @client = build_client(token: @bot_token)
63
+ @client = client_for_token(@bot_token)
32
64
  end
33
65
 
34
66
  @renderer = BlockKitRenderer.new
35
67
  @modal_renderer = ModalRenderer.new
36
- @team_clients = {}
37
68
  end
38
69
 
39
70
  # Returns the per-request client (multi-workspace) or the static client (single-workspace).
40
71
  def client
41
- return @client unless @client_id
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
42
83
 
43
- ::Thread.current[:chat_sdk_slack_client] || @client
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)
44
94
  end
45
95
 
46
96
  # Inject state store after initialization (e.g., from Chat instance).
@@ -50,26 +100,38 @@ module ChatSDK
50
100
 
51
101
  # --- Multi-workspace installation management ---
52
102
 
53
- 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)
54
105
  raise ChatSDK::ConfigurationError, "Multi-workspace mode requires state" unless @state
55
106
 
56
- @team_clients.delete(team_id)
57
- @state.set(installation_key(team_id), {
58
- "bot_token" => bot_token,
107
+ installation = {
108
+ "bot_token" => @encryption_key ? TokenEncryption.encrypt(bot_token, @encryption_key) : bot_token,
59
109
  "bot_user_id" => bot_user_id,
60
110
  "team_name" => team_name
61
- })
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)
62
115
  invalidate_bot_user_id(team_id)
63
116
  end
64
117
 
65
118
  def get_installation(team_id)
66
119
  return nil unless @state
67
120
 
68
- @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))
69
132
  end
70
133
 
71
134
  def delete_installation(team_id)
72
- @team_clients.delete(team_id)
73
135
  invalidate_bot_user_id(team_id)
74
136
  return unless @state
75
137
 
@@ -89,19 +151,39 @@ module ChatSDK
89
151
 
90
152
  result = temp_client.oauth_v2_access(**params)
91
153
 
92
- 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
+
93
166
  installation = {
94
- "bot_token" => result["access_token"],
95
- "bot_user_id" => result["bot_user_id"],
96
- "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")
97
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")
98
173
 
99
- set_installation(team_id,
174
+ set_installation(installation_id,
100
175
  bot_token: installation["bot_token"],
101
176
  bot_user_id: installation["bot_user_id"],
102
- team_name: installation["team_name"])
103
-
104
- {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
+ }
105
187
  end
106
188
 
107
189
  def name
@@ -112,6 +194,18 @@ module ChatSDK
112
194
  def verify_request!(rack_request)
113
195
  body = rack_request.body.read
114
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
+
115
209
  timestamp = rack_request.env["HTTP_X_SLACK_REQUEST_TIMESTAMP"]
116
210
  signature = rack_request.env["HTTP_X_SLACK_SIGNATURE"]
117
211
 
@@ -146,19 +240,14 @@ module ChatSDK
146
240
  end
147
241
 
148
242
  def parse_events(rack_request)
243
+ set_context(nil)
149
244
  body = rack_request.body.read
150
245
  rack_request.body.rewind
151
246
 
152
247
  parsed = parse_body(body, rack_request.content_type)
153
248
  return [] unless parsed
154
249
 
155
- # Multi-workspace: resolve per-team client from payload
156
- # Clear any stale client from a previous request on this thread (Puma thread pool safety)
157
- if @client_id
158
- ::Thread.current[:chat_sdk_slack_client] = nil
159
- team_id = parsed["team_id"] || parsed.dig("team", "id")
160
- resolve_team_client(team_id) if team_id
161
- end
250
+ return [] unless prepare_payload_context(parsed)
162
251
 
163
252
  events = EventParser.parse(parsed)
164
253
  classify_message_events(events, parsed)
@@ -185,6 +274,26 @@ module ChatSDK
185
274
  )
186
275
  end
187
276
 
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
+
188
297
  def edit_message(channel_id:, message_id:, message:, **_options)
189
298
  msg = ChatSDK::PostableMessage.from(message)
190
299
  params = {channel: channel_id, ts: message_id}
@@ -331,13 +440,16 @@ module ChatSDK
331
440
  end
332
441
 
333
442
  def concurrency_scope(event)
334
- return unless @client_id
443
+ return context[:installation_id] if context && context[:installation_id]
444
+ return unless multi_workspace?
335
445
 
336
446
  raw = event.raw.is_a?(Hash) ? event.raw : {}
337
447
  raw_event = raw.is_a?(Hash) ? raw["event"] || raw[:event] || raw : {}
338
448
  raw_team = raw.is_a?(Hash) ? raw["team"] || raw[:team] : nil
339
449
  raw_team_id = raw_team.is_a?(Hash) ? raw_team["id"] || raw_team[:id] : raw_team
340
- raw_team_id || raw["team_id"] || raw[:team_id] || raw_event["team_id"] || raw_event[:team_id] || raw_event["team"] || raw_event[: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]
341
453
  end
342
454
 
343
455
  # Slack-specific: receive real-time events via Socket Mode WebSocket.
@@ -354,13 +466,12 @@ module ChatSDK
354
466
  raise ArgumentError, "start requires a block" unless block
355
467
 
356
468
  @socket_mode = SocketMode.new(app_token: app_token, bot_client: client)
357
- @socket_mode.start do |event|
358
- if @client_id
359
- ::Thread.current[:chat_sdk_slack_client] = nil
360
- team_id = concurrency_scope(event)
361
- resolve_team_client(team_id) if team_id
362
- end
363
- classify_message_events([event], event.raw)
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)
364
475
  block.call(event)
365
476
  end
366
477
  end
@@ -383,9 +494,13 @@ module ChatSDK
383
494
 
384
495
  private
385
496
 
497
+ def monotonic_now
498
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
499
+ end
500
+
386
501
  def build_client(token: nil)
387
502
  options = {}
388
- options[:token] = token if token
503
+ options[:token] = token unless token.nil?
389
504
  options[:proxy] = @proxy if @proxy
390
505
  options.empty? ? ::Slack::Web::Client.new : ::Slack::Web::Client.new(options)
391
506
  end
@@ -414,23 +529,24 @@ module ChatSDK
414
529
  end
415
530
 
416
531
  def resolve_bot_user_id(event)
417
- team_id = concurrency_scope(event) if @client_id
418
- if @client_id
419
- installation = get_installation(team_id) if team_id
420
- return installation["bot_user_id"] if installation && installation["bot_user_id"]
421
- end
532
+ request_context = context
533
+ return request_context[:bot_user_id] if request_context && request_context[:bot_user_id]
422
534
 
423
535
  return @bot_user_id if @bot_user_id
424
536
 
425
- current_client = if @client_id
426
- resolve_team_client(team_id) if team_id
427
- else
428
- @client
537
+ installation_id = if multi_workspace?
538
+ request_context&.[](:installation_id) || event_installation_id(event)
429
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
430
546
  return unless current_client
431
547
 
432
548
  @bot_user_id_mutex.synchronize do
433
- cache_key = @client_id ? (team_id || :default) : :default
549
+ cache_key = multi_workspace? ? (installation_id || :default) : :default
434
550
  return @bot_user_ids[cache_key] if @bot_user_ids.key?(cache_key)
435
551
 
436
552
  bot_user_id = begin
@@ -460,22 +576,142 @@ module ChatSDK
460
576
  Array(element["elements"]).any? { |child| rich_text_element_mentions_bot?(child, bot_user_id) }
461
577
  end
462
578
 
463
- def resolve_team_client(team_id)
464
- return unless @client_id && @state
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
465
596
 
466
- cached = @team_clients[team_id]
467
- if cached
468
- ::Thread.current[:chat_sdk_slack_client] = cached
469
- return cached
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
470
613
  end
471
614
 
472
- installation = get_installation(team_id)
473
- return unless installation
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
628
+
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")
645
+ return
646
+ end
647
+
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
474
712
 
475
- new_client = build_client(token: installation["bot_token"])
476
- @team_clients[team_id] = new_client
477
- ::Thread.current[:chat_sdk_slack_client] = new_client
478
- new_client
713
+ def truthy_slack_value?(value)
714
+ value == true || value == "true"
479
715
  end
480
716
 
481
717
  def installation_key(team_id)
@@ -22,6 +22,13 @@ module ChatSDK
22
22
  if node.attributes[:subtitle]
23
23
  blocks << {type: "context", elements: [{type: "mrkdwn", text: truncate_mrkdwn(node.attributes[:subtitle], 3000)}]}
24
24
  end
25
+ if node.attributes[:image_url] && !node.attributes[:image_url].empty?
26
+ blocks << {
27
+ type: "image",
28
+ image_url: node.attributes[:image_url],
29
+ alt_text: truncate_text(node.attributes[:title] || "Card image", 2000)
30
+ }
31
+ end
25
32
  blocks.concat(node.children.map { |child| render_node(child) }.compact)
26
33
  blocks
27
34
  end
@@ -29,6 +36,7 @@ module ChatSDK
29
36
  def render_node(node)
30
37
  case node.type
31
38
  when :text then render_text(node)
39
+ when :link then render_link(node)
32
40
  when :divider then {type: "divider"}
33
41
  when :image then render_image(node)
34
42
  when :fields then render_fields(node)
@@ -41,12 +49,23 @@ module ChatSDK
41
49
  end
42
50
 
43
51
  def render_text(node)
52
+ content = node.attributes[:content]
53
+ content = "*#{truncate_mrkdwn(content, 2998)}*" if node.attributes[:style] == :bold
54
+ return {type: "context", elements: [{type: "mrkdwn", text: truncate_mrkdwn(content, 3000)}]} if node.attributes[:style] == :muted
55
+
44
56
  {
45
57
  type: "section",
46
- text: {type: "mrkdwn", text: truncate_mrkdwn(node.attributes[:content], 3000)}
58
+ text: {type: "mrkdwn", text: truncate_mrkdwn(content, 3000)}
47
59
  }
48
60
  end
49
61
 
62
+ def render_link(node)
63
+ url = node.attributes[:url]
64
+ label_budget = [2997 - url.to_s.length, 0].max
65
+ label = truncate_mrkdwn(node.attributes[:label], label_budget)
66
+ {type: "section", text: {type: "mrkdwn", text: "<#{url}|#{label}>"}}
67
+ end
68
+
50
69
  def render_image(node)
51
70
  block = {type: "image", image_url: node.attributes[:url]}
52
71
  block[:alt_text] = truncate_text(node.attributes[:alt] || " ", 2000)
@@ -64,9 +83,14 @@ module ChatSDK
64
83
 
65
84
  def render_section(node)
66
85
  block = {type: "section"}
67
- text_children = node.children.select { |c| c.type == :text }
68
- if text_children.any?
69
- block[:text] = {type: "mrkdwn", text: truncate_mrkdwn(text_children.map { |t| t.attributes[:content] }.join("\n"), 3000)}
86
+ lines = node.children.filter_map do |child|
87
+ case child.type
88
+ when :text then child.attributes[:content]
89
+ when :link then render_link(child).dig(:text, :text)
90
+ end
91
+ end
92
+ if lines.any?
93
+ block[:text] = {type: "mrkdwn", text: truncate_mrkdwn(lines.join("\n"), 3000)}
70
94
  end
71
95
  block
72
96
  end
@@ -83,6 +107,7 @@ module ChatSDK
83
107
  when :button then render_button(node)
84
108
  when :link_button then render_link_button(node)
85
109
  when :select then render_select(node)
110
+ when :radio_select then render_radio_select(node)
86
111
  end
87
112
  end
88
113
 
@@ -109,6 +134,11 @@ module ChatSDK
109
134
  url: node.attributes[:url],
110
135
  action_id: node.attributes[:id] || "link_#{node.attributes[:url].hash.abs}"
111
136
  }
137
+ if node.attributes[:style] == :primary
138
+ button[:style] = "primary"
139
+ elsif node.attributes[:style] == :danger
140
+ button[:style] = "danger"
141
+ end
112
142
  button[:visible_to_user_ids] = node.attributes[:visible_to] if node.attributes[:visible_to]&.any?
113
143
  button
114
144
  end
@@ -122,9 +152,32 @@ module ChatSDK
122
152
  if node.attributes[:placeholder]
123
153
  sel[:placeholder] = {type: "plain_text", text: truncate_text(node.attributes[:placeholder], 150)}
124
154
  end
155
+ if node.attributes[:initial_option] && !node.attributes[:initial_option].empty?
156
+ initial_option = sel[:options].find { |option| option[:value] == node.attributes[:initial_option] }
157
+ sel[:initial_option] = initial_option if initial_option
158
+ end
125
159
  sel
126
160
  end
127
161
 
162
+ def render_radio_select(node)
163
+ options = node.children.first(10).map do |option|
164
+ rendered = {
165
+ text: {type: "mrkdwn", text: truncate_mrkdwn(option.attributes[:text], 75)},
166
+ value: option.attributes[:value]
167
+ }
168
+ if option.attributes[:description]
169
+ rendered[:description] = {type: "mrkdwn", text: truncate_mrkdwn(option.attributes[:description], 75)}
170
+ end
171
+ rendered
172
+ end
173
+ element = {type: "radio_buttons", action_id: node.attributes[:id], options: options}
174
+ if node.attributes[:initial_option] && !node.attributes[:initial_option].empty?
175
+ initial_option = options.find { |option| option[:value] == node.attributes[:initial_option] }
176
+ element[:initial_option] = initial_option if initial_option
177
+ end
178
+ element
179
+ end
180
+
128
181
  def render_option(node)
129
182
  opt = {
130
183
  text: {type: "plain_text", text: truncate_text(node.attributes[:text], 75)},