chat_sdk-slack 1.0.1 → 1.2.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: f3267f830fd79846f929e7e61be15c2b65d1e6183f6f5d56271f88fbb6fdf85b
4
- data.tar.gz: 9fe0cd67184ef4561a2e6ccb6d5678f5a639f2b53add2be5ef6436b239a1a7c3
3
+ metadata.gz: 49b9759d50173e826b41f885fcba2f2c276d28574f187ac70bd1932d3da28ab4
4
+ data.tar.gz: 80c4806743ab979c9a6f024c5f7d8081152cdf8eaacb63607728e78518b88a5c
5
5
  SHA512:
6
- metadata.gz: c686e9ac2a237c82166200783289f2310cb57cc3760a4d55b11447fa0f7a746b10ffb40b713d18334f277f3efb91663733977f082cf0152ee96ed387ddbd2ed1
7
- data.tar.gz: 1e897869f36b3571e31dea33be0e78f9bd46bcbc5e3c839da11453337242378933dcfec8eec289ceb4c6cbae4c339bce0a1c4399f39fe74a7980703f5e1e4c40
6
+ metadata.gz: c79dccb6431aa33e0c9aa756287b7ec4342e97d4733b42e91f94e5ecfe44267e73dd404890dc13d600700985c6d958f03acad36f52121ec62ba010eb14145019
7
+ data.tar.gz: cebaf0452264d3b98802a1774fd834d77fa3be5b83a9f38848890c0b8ec742f976a0e510edf520f97f06e0375ca33c5fdce5af845043381cdb8a0ba1ee3b351d
@@ -9,11 +9,15 @@ module ChatSDK
9
9
  :scheduled_messages
10
10
 
11
11
  def initialize(bot_token: nil, signing_secret: nil,
12
- client_id: nil, client_secret: nil)
12
+ client_id: nil, client_secret: nil, proxy: nil, bot_user_id: nil)
13
13
  @bot_token = bot_token || ENV["SLACK_BOT_TOKEN"]
14
14
  @signing_secret = signing_secret || ENV["SLACK_SIGNING_SECRET"]
15
15
  @client_id = client_id || ENV["SLACK_CLIENT_ID"]
16
16
  @client_secret = client_secret || ENV["SLACK_CLIENT_SECRET"]
17
+ @bot_user_id = bot_user_id || ENV["SLACK_BOT_USER_ID"]
18
+ @bot_user_ids = {}
19
+ @bot_user_id_mutex = Mutex.new
20
+ @proxy = proxy || ENV["HTTPS_PROXY"] || ENV["https_proxy"]
17
21
 
18
22
  unless @bot_token || @client_id
19
23
  raise ChatSDK::ConfigurationError, "Slack bot_token or client_id required"
@@ -24,7 +28,7 @@ module ChatSDK
24
28
  ::Slack.configure do |config|
25
29
  config.token = @bot_token
26
30
  end
27
- @client = ::Slack::Web::Client.new(token: @bot_token)
31
+ @client = build_client(token: @bot_token)
28
32
  end
29
33
 
30
34
  @renderer = BlockKitRenderer.new
@@ -34,6 +38,8 @@ module ChatSDK
34
38
 
35
39
  # Returns the per-request client (multi-workspace) or the static client (single-workspace).
36
40
  def client
41
+ return @client unless @client_id
42
+
37
43
  ::Thread.current[:chat_sdk_slack_client] || @client
38
44
  end
39
45
 
@@ -53,6 +59,7 @@ module ChatSDK
53
59
  "bot_user_id" => bot_user_id,
54
60
  "team_name" => team_name
55
61
  })
62
+ invalidate_bot_user_id(team_id)
56
63
  end
57
64
 
58
65
  def get_installation(team_id)
@@ -63,6 +70,7 @@ module ChatSDK
63
70
 
64
71
  def delete_installation(team_id)
65
72
  @team_clients.delete(team_id)
73
+ invalidate_bot_user_id(team_id)
66
74
  return unless @state
67
75
 
68
76
  @state.delete(installation_key(team_id))
@@ -71,7 +79,7 @@ module ChatSDK
71
79
  def handle_oauth_callback(code:, redirect_uri: nil)
72
80
  raise ChatSDK::ConfigurationError, "client_id required for OAuth" unless @client_id
73
81
 
74
- temp_client = ::Slack::Web::Client.new
82
+ temp_client = build_client
75
83
  params = {
76
84
  client_id: @client_id,
77
85
  client_secret: @client_secret,
@@ -152,7 +160,8 @@ module ChatSDK
152
160
  resolve_team_client(team_id) if team_id
153
161
  end
154
162
 
155
- EventParser.parse(parsed)
163
+ events = EventParser.parse(parsed)
164
+ classify_message_events(events, parsed)
156
165
  end
157
166
 
158
167
  # Outbound
@@ -176,7 +185,7 @@ module ChatSDK
176
185
  )
177
186
  end
178
187
 
179
- def edit_message(channel_id:, message_id:, message:)
188
+ def edit_message(channel_id:, message_id:, message:, **_options)
180
189
  msg = ChatSDK::PostableMessage.from(message)
181
190
  params = {channel: channel_id, ts: message_id}
182
191
 
@@ -185,7 +194,7 @@ module ChatSDK
185
194
  client.chat_update(**params)
186
195
  end
187
196
 
188
- def delete_message(channel_id:, message_id:)
197
+ def delete_message(channel_id:, message_id:, **_options)
189
198
  client.chat_delete(channel: channel_id, ts: message_id)
190
199
  end
191
200
 
@@ -200,17 +209,19 @@ module ChatSDK
200
209
  end
201
210
 
202
211
  def upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil)
203
- params = {channels: channel_id, file: Faraday::Multipart::FilePart.new(io, nil, filename)}
212
+ content = io.read
213
+ io.rewind if io.respond_to?(:rewind)
214
+ params = {channel_id: channel_id, filename: filename, content: content}
204
215
  params[:thread_ts] = thread_id if thread_id
205
216
  params[:initial_comment] = comment if comment
206
- client.files_upload(**params)
217
+ client.files_upload_v2(**params)
207
218
  end
208
219
 
209
- def add_reaction(channel_id:, message_id:, emoji:)
220
+ def add_reaction(channel_id:, message_id:, emoji:, **_options)
210
221
  client.reactions_add(channel: channel_id, timestamp: message_id, name: emoji)
211
222
  end
212
223
 
213
- def remove_reaction(channel_id:, message_id:, emoji:)
224
+ def remove_reaction(channel_id:, message_id:, emoji:, **_options)
214
225
  client.reactions_remove(channel: channel_id, timestamp: message_id, name: emoji)
215
226
  end
216
227
 
@@ -314,11 +325,21 @@ module ChatSDK
314
325
  result["channel"]
315
326
  end
316
327
 
317
- def start_typing(channel_id:, thread_id: nil)
328
+ def start_typing(channel_id:, thread_id: nil, message_id: nil)
318
329
  # Slack doesn't have a native typing indicator API for bots
319
330
  # This is a no-op but the capability is declared for streaming support
320
331
  end
321
332
 
333
+ def concurrency_scope(event)
334
+ return unless @client_id
335
+
336
+ raw = event.raw.is_a?(Hash) ? event.raw : {}
337
+ raw_event = raw.is_a?(Hash) ? raw["event"] || raw[:event] || raw : {}
338
+ raw_team = raw.is_a?(Hash) ? raw["team"] || raw[:team] : nil
339
+ 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]
341
+ end
342
+
322
343
  # Slack-specific: receive real-time events via Socket Mode WebSocket.
323
344
  # Requires the optional 'faye-websocket' gem and an app-level token (xapp-*).
324
345
  # Not part of the base adapter contract.
@@ -330,9 +351,18 @@ module ChatSDK
330
351
  def start_socket_mode(app_token: nil, &block)
331
352
  app_token ||= ENV["SLACK_APP_TOKEN"]
332
353
  raise ChatSDK::ConfigurationError, "Slack app_token required for socket mode" unless app_token
354
+ raise ArgumentError, "start requires a block" unless block
333
355
 
334
356
  @socket_mode = SocketMode.new(app_token: app_token, bot_client: client)
335
- @socket_mode.start(&block)
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)
364
+ block.call(event)
365
+ end
336
366
  end
337
367
 
338
368
  def stop_socket_mode
@@ -353,21 +383,99 @@ module ChatSDK
353
383
 
354
384
  private
355
385
 
386
+ def build_client(token: nil)
387
+ options = {}
388
+ options[:token] = token if token
389
+ options[:proxy] = @proxy if @proxy
390
+ options.empty? ? ::Slack::Web::Client.new : ::Slack::Web::Client.new(options)
391
+ end
392
+
393
+ def classify_message_events(events, payload)
394
+ events.each do |event|
395
+ next unless %i[mention subscribed_message direct_message].include?(event.type)
396
+
397
+ event.message.is_mention = classify_mention(event, payload)
398
+ end
399
+ events
400
+ end
401
+
402
+ def classify_mention(event, payload)
403
+ raw_event = payload["event"] || {}
404
+ return true if raw_event["type"] == "app_mention"
405
+
406
+ bot_user_id = resolve_bot_user_id(event)
407
+ return true if event.type == :mention && bot_user_id.nil?
408
+ return nil if bot_user_id.nil?
409
+
410
+ text = raw_event["text"].to_s
411
+ masked_text = ChatSDK::Format::Mentions.mask_code_spans(text)
412
+ text_mentions_bot = /<@#{Regexp.escape(bot_user_id)}(?:\|[^>]*)?>/.match?(masked_text)
413
+ text_mentions_bot || rich_text_mentions_bot?(raw_event["blocks"], bot_user_id)
414
+ end
415
+
416
+ 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
422
+
423
+ return @bot_user_id if @bot_user_id
424
+
425
+ current_client = if @client_id
426
+ resolve_team_client(team_id) if team_id
427
+ else
428
+ @client
429
+ end
430
+ return unless current_client
431
+
432
+ @bot_user_id_mutex.synchronize do
433
+ cache_key = @client_id ? (team_id || :default) : :default
434
+ return @bot_user_ids[cache_key] if @bot_user_ids.key?(cache_key)
435
+
436
+ bot_user_id = begin
437
+ current_client.auth_test["user_id"]
438
+ rescue ::Slack::Web::Api::Errors::SlackError, Faraday::Error, SocketError, Timeout::Error
439
+ nil
440
+ end
441
+ @bot_user_ids[cache_key] = bot_user_id if bot_user_id
442
+ bot_user_id
443
+ end
444
+ end
445
+
446
+ def invalidate_bot_user_id(team_id)
447
+ @bot_user_id_mutex.synchronize { @bot_user_ids.delete(team_id) }
448
+ end
449
+
450
+ def rich_text_mentions_bot?(blocks, bot_user_id)
451
+ Array(blocks).any? { |block| rich_text_element_mentions_bot?(block, bot_user_id) }
452
+ end
453
+
454
+ def rich_text_element_mentions_bot?(element, bot_user_id)
455
+ return false unless element.is_a?(Hash)
456
+ return false if %w[rich_text_preformatted preformatted code].include?(element["type"])
457
+ return false if element.dig("style", "code")
458
+ return true if element["type"] == "user" && element["user_id"] == bot_user_id
459
+
460
+ Array(element["elements"]).any? { |child| rich_text_element_mentions_bot?(child, bot_user_id) }
461
+ end
462
+
356
463
  def resolve_team_client(team_id)
357
464
  return unless @client_id && @state
358
465
 
359
466
  cached = @team_clients[team_id]
360
467
  if cached
361
468
  ::Thread.current[:chat_sdk_slack_client] = cached
362
- return
469
+ return cached
363
470
  end
364
471
 
365
472
  installation = get_installation(team_id)
366
473
  return unless installation
367
474
 
368
- new_client = ::Slack::Web::Client.new(token: installation["bot_token"])
475
+ new_client = build_client(token: installation["bot_token"])
369
476
  @team_clients[team_id] = new_client
370
477
  ::Thread.current[:chat_sdk_slack_client] = new_client
478
+ new_client
371
479
  end
372
480
 
373
481
  def installation_key(team_id)
@@ -13,7 +13,17 @@ module ChatSDK
13
13
  private
14
14
 
15
15
  def render_card(node)
16
- node.children.map { |child| render_node(child) }.compact
16
+ @used_native_table = false
17
+ @chart_count = 0
18
+ blocks = []
19
+ if node.attributes[:title]
20
+ blocks << {type: "header", text: {type: "plain_text", text: truncate_text(node.attributes[:title], 150)}}
21
+ end
22
+ if node.attributes[:subtitle]
23
+ blocks << {type: "context", elements: [{type: "mrkdwn", text: truncate_mrkdwn(node.attributes[:subtitle], 3000)}]}
24
+ end
25
+ blocks.concat(node.children.map { |child| render_node(child) }.compact)
26
+ blocks
17
27
  end
18
28
 
19
29
  def render_node(node)
@@ -24,19 +34,22 @@ module ChatSDK
24
34
  when :fields then render_fields(node)
25
35
  when :section then render_section(node)
26
36
  when :actions then render_actions(node)
37
+ when :table then render_table(node)
38
+ when :chart then render_chart(node)
39
+ when :text_input then {type: "section", text: {type: "mrkdwn", text: truncate_mrkdwn(node.attributes[:label], 3000)}}
27
40
  end
28
41
  end
29
42
 
30
43
  def render_text(node)
31
44
  {
32
45
  type: "section",
33
- text: {type: "mrkdwn", text: node.attributes[:content]}
46
+ text: {type: "mrkdwn", text: truncate_mrkdwn(node.attributes[:content], 3000)}
34
47
  }
35
48
  end
36
49
 
37
50
  def render_image(node)
38
51
  block = {type: "image", image_url: node.attributes[:url]}
39
- block[:alt_text] = node.attributes[:alt] || " "
52
+ block[:alt_text] = truncate_text(node.attributes[:alt] || " ", 2000)
40
53
  block
41
54
  end
42
55
 
@@ -44,7 +57,7 @@ module ChatSDK
44
57
  {
45
58
  type: "section",
46
59
  fields: node.children.map do |field|
47
- {type: "mrkdwn", text: "*#{field.attributes[:label]}*\n#{field.attributes[:value]}"}
60
+ {type: "mrkdwn", text: truncate_mrkdwn("*#{field.attributes[:label]}*\n#{field.attributes[:value]}", 2000)}
48
61
  end
49
62
  }
50
63
  end
@@ -53,7 +66,7 @@ module ChatSDK
53
66
  block = {type: "section"}
54
67
  text_children = node.children.select { |c| c.type == :text }
55
68
  if text_children.any?
56
- block[:text] = {type: "mrkdwn", text: text_children.map { |t| t.attributes[:content] }.join("\n")}
69
+ block[:text] = {type: "mrkdwn", text: truncate_mrkdwn(text_children.map { |t| t.attributes[:content] }.join("\n"), 3000)}
57
70
  end
58
71
  block
59
72
  end
@@ -76,7 +89,7 @@ module ChatSDK
76
89
  def render_button(node)
77
90
  btn = {
78
91
  type: "button",
79
- text: {type: "plain_text", text: node.attributes[:text]},
92
+ text: {type: "plain_text", text: truncate_text(node.attributes[:text], 75)},
80
93
  action_id: node.attributes[:id]
81
94
  }
82
95
  btn[:value] = node.attributes[:value] if node.attributes[:value]
@@ -85,16 +98,19 @@ module ChatSDK
85
98
  elsif node.attributes[:style] == :danger
86
99
  btn[:style] = "danger"
87
100
  end
101
+ btn[:visible_to_user_ids] = node.attributes[:visible_to] if node.attributes[:visible_to]&.any?
88
102
  btn
89
103
  end
90
104
 
91
105
  def render_link_button(node)
92
- {
106
+ button = {
93
107
  type: "button",
94
- text: {type: "plain_text", text: node.attributes[:text]},
108
+ text: {type: "plain_text", text: truncate_text(node.attributes[:text], 75)},
95
109
  url: node.attributes[:url],
96
- action_id: "link_#{node.attributes[:url].hash.abs}"
110
+ action_id: node.attributes[:id] || "link_#{node.attributes[:url].hash.abs}"
97
111
  }
112
+ button[:visible_to_user_ids] = node.attributes[:visible_to] if node.attributes[:visible_to]&.any?
113
+ button
98
114
  end
99
115
 
100
116
  def render_select(node)
@@ -104,21 +120,129 @@ module ChatSDK
104
120
  options: node.children.map { |opt| render_option(opt) }
105
121
  }
106
122
  if node.attributes[:placeholder]
107
- sel[:placeholder] = {type: "plain_text", text: node.attributes[:placeholder]}
123
+ sel[:placeholder] = {type: "plain_text", text: truncate_text(node.attributes[:placeholder], 150)}
108
124
  end
109
125
  sel
110
126
  end
111
127
 
112
128
  def render_option(node)
113
129
  opt = {
114
- text: {type: "plain_text", text: node.attributes[:text]},
130
+ text: {type: "plain_text", text: truncate_text(node.attributes[:text], 75)},
115
131
  value: node.attributes[:value]
116
132
  }
117
133
  if node.attributes[:description]
118
- opt[:description] = {type: "plain_text", text: node.attributes[:description]}
134
+ opt[:description] = {type: "plain_text", text: truncate_text(node.attributes[:description], 75)}
119
135
  end
120
136
  opt
121
137
  end
138
+
139
+ def render_table(node)
140
+ headers = Array(node.attributes[:headers])
141
+ rows = Array(node.attributes[:rows])
142
+ char_count = (headers + rows.flatten).sum { |cell| cell.to_s.length }
143
+ if @used_native_table || rows.length > 100 || headers.length > 20 || char_count > 10_000
144
+ return fallback_block(ChatSDK::Cards::Renderer.new.render(node))
145
+ end
146
+
147
+ @used_native_table = true
148
+ rendered_rows = [headers, *rows].map do |row|
149
+ Array(row).map { |cell| {type: "raw_text", text: cell.to_s.empty? ? " " : cell.to_s} }
150
+ end
151
+ return {type: "table", rows: rendered_rows} if rows.empty?
152
+
153
+ block = {
154
+ type: "data_table",
155
+ caption: node.attributes[:caption] || "Table",
156
+ rows: rendered_rows
157
+ }
158
+ block[:page_size] = node.attributes[:page_size].to_i.clamp(1, 100) if node.attributes[:page_size]
159
+ block
160
+ end
161
+
162
+ def render_chart(node)
163
+ return fallback_block(node.fallback_text) if @chart_count >= 2
164
+
165
+ block = chart_block(node)
166
+ return fallback_block(node.fallback_text) unless block
167
+
168
+ @chart_count += 1
169
+ block
170
+ end
171
+
172
+ def chart_block(node)
173
+ title = node.attributes[:title].to_s
174
+ return if title.empty? || title.length > 50
175
+
176
+ type = node.attributes[:chart_type].to_sym
177
+ if type == :pie
178
+ segments = Array(node.attributes[:segments])
179
+ return unless segments.length.between?(1, 12) && segments.all? { |segment| valid_chart_label?(value_for(segment, :label)) && value_for(segment, :value).to_f.positive? }
180
+
181
+ return {type: "data_visualization", title: title, chart: {type: "pie", segments: segments}}
182
+ end
183
+
184
+ categories = Array(node.attributes[:categories])
185
+ series = Array(node.attributes[:series])
186
+ return unless %i[bar area line].include?(type)
187
+ return unless categories.length.between?(1, 20) && categories.all? { |category| valid_chart_label?(category) }
188
+ return unless series.length.between?(1, 12) && series.all? { |item| valid_chart_label?(value_for(item, :name)) }
189
+
190
+ axis = {categories: categories}
191
+ axis[:x_label] = node.attributes[:x_label] if node.attributes[:x_label]
192
+ axis[:y_label] = node.attributes[:y_label] if node.attributes[:y_label]
193
+ {type: "data_visualization", title: title, chart: {type: type.to_s, series: series, axis_config: axis}}
194
+ end
195
+
196
+ def valid_chart_label?(label)
197
+ label.to_s.length.between?(1, 20)
198
+ end
199
+
200
+ def value_for(hash, key)
201
+ hash[key] || hash[key.to_s]
202
+ end
203
+
204
+ def fallback_block(text)
205
+ {type: "section", text: {type: "mrkdwn", text: truncate_mrkdwn("```#{text}```", 3000)}}
206
+ end
207
+
208
+ def truncate_text(text, limit)
209
+ text = text.to_s
210
+ return text if text.length <= limit
211
+
212
+ "#{text[0, limit - 1]}…"
213
+ end
214
+
215
+ def truncate_mrkdwn(text, limit)
216
+ text = text.to_s
217
+ return text if text.length <= limit
218
+
219
+ prefix = clean_prefix(text[0, limit - 1])
220
+ 4.times do
221
+ bold_close = prefix.count("*").odd? ? "*" : ""
222
+ fence_close = open_fence?(prefix) ? "\n```" : ""
223
+ available = limit - 1 - bold_close.length - fence_close.length
224
+ next_prefix = clean_prefix(text[0, available])
225
+ break if next_prefix == prefix
226
+
227
+ prefix = next_prefix
228
+ end
229
+
230
+ bold_close = prefix.count("*").odd? ? "*" : ""
231
+ fence_close = open_fence?(prefix) ? "\n```" : ""
232
+ suffix = "#{bold_close}…#{fence_close}"
233
+ prefix = clean_prefix(prefix[0, limit - suffix.length]) while prefix.length + suffix.length > limit
234
+ "#{prefix}#{suffix}"
235
+ end
236
+
237
+ def clean_prefix(text)
238
+ text
239
+ .sub(/<(?:[@#!]|[a-z][a-z\d+.-]*(?::|$))[^>]*\z/i, "")
240
+ .sub(/&(?:#?[a-z\d]*)?\z/i, "")
241
+ end
242
+
243
+ def open_fence?(text)
244
+ text.scan("```").length.odd?
245
+ end
122
246
  end
123
247
  end
124
248
  end
@@ -50,7 +50,7 @@ module ChatSDK
50
50
  )
51
51
  message = ChatSDK::Message.new(
52
52
  id: event["ts"],
53
- text: event["text"] || "",
53
+ text: message_text(event),
54
54
  author: author,
55
55
  thread_id: event["thread_ts"] || event["ts"],
56
56
  channel_id: event["channel"],
@@ -68,6 +68,8 @@ module ChatSDK
68
68
  end
69
69
 
70
70
  def parse_message_event(event, payload)
71
+ return parse_message_updated(event, payload) if event["subtype"] == "message_changed"
72
+ return parse_message_deleted(event, payload) if event["subtype"] == "message_deleted"
71
73
  return [] if event["subtype"] && event["subtype"] != "file_share"
72
74
  return [] if event["bot_id"]
73
75
 
@@ -78,7 +80,7 @@ module ChatSDK
78
80
  )
79
81
  message = ChatSDK::Message.new(
80
82
  id: event["ts"],
81
- text: event["text"] || "",
83
+ text: message_text(event),
82
84
  author: author,
83
85
  thread_id: event["thread_ts"] || event["ts"],
84
86
  channel_id: event["channel"],
@@ -108,6 +110,103 @@ module ChatSDK
108
110
  end
109
111
  end
110
112
 
113
+ def parse_message_updated(event, payload)
114
+ return [] if event["hidden"]
115
+
116
+ message_data = event["message"]
117
+ return [] unless message_data
118
+
119
+ channel_id = event["channel"] || message_data["channel"]
120
+ message = slack_message(message_data, channel_id)
121
+ previous = slack_message(event["previous_message"], channel_id) if event["previous_message"]
122
+ [ChatSDK::Events::MessageUpdated.new(
123
+ message: message,
124
+ previous_message: previous,
125
+ thread_id: message.thread_id,
126
+ channel_id: channel_id,
127
+ platform: :slack,
128
+ adapter_name: :slack,
129
+ raw: payload
130
+ )]
131
+ end
132
+
133
+ def message_text(event)
134
+ blocks = event["blocks"]
135
+ text = if blocks.is_a?(Array) && !blocks.empty? && blocks.all? { |block| %w[section header context image divider actions table].include?(block["type"]) }
136
+ blocks.filter_map { |block| block_text(block) }.join("\n")
137
+ else
138
+ event["text"] || ""
139
+ end
140
+ FormatConverter.new.normalize_inbound_text(text)
141
+ end
142
+
143
+ def block_text(block)
144
+ case block["type"]
145
+ when "section", "header"
146
+ texts = [block.dig("text", "text")]
147
+ texts.concat(Array(block["fields"]).map { |field| field["text"] })
148
+ texts.compact.reject(&:empty?).join("\n")
149
+ when "context"
150
+ Array(block["elements"]).filter_map { |element| element.dig("text") }.reject(&:empty?).join("\n")
151
+ when "image"
152
+ block["alt_text"]
153
+ when "actions"
154
+ Array(block["elements"]).filter_map do |element|
155
+ element.dig("text", "text") || element.dig("placeholder", "text")
156
+ end.reject(&:empty?).join(" ")
157
+ when "table"
158
+ Array(block["rows"]).map do |row|
159
+ Array(row).map { |cell| cell.dig("text") || cell.to_s }.join(" | ")
160
+ end.join("\n")
161
+ end
162
+ end
163
+
164
+ def parse_message_deleted(event, payload)
165
+ previous = event["previous_message"]
166
+ message_id = event["deleted_ts"] || previous&.dig("ts")
167
+ return [] unless message_id
168
+
169
+ channel_id = event["channel"] || previous&.dig("channel")
170
+ previous_message = slack_message(previous, channel_id) if previous
171
+ thread_id = previous&.dig("thread_ts") || message_id
172
+ [ChatSDK::Events::MessageDeleted.new(
173
+ message_id: message_id,
174
+ previous_message: previous_message,
175
+ thread_id: thread_id,
176
+ channel_id: channel_id,
177
+ deleted_at: slack_time(event["event_ts"] || event["ts"]),
178
+ platform: :slack,
179
+ adapter_name: :slack,
180
+ raw: payload
181
+ )]
182
+ end
183
+
184
+ def slack_message(data, channel_id)
185
+ user_id = data["user"] || data.dig("bot_profile", "user_id") || "unknown"
186
+ ChatSDK::Message.new(
187
+ id: data["ts"],
188
+ text: data["text"] || "",
189
+ author: ChatSDK::Author.new(
190
+ id: user_id,
191
+ name: data["username"] || user_id,
192
+ platform: :slack,
193
+ bot: !!data["bot_id"],
194
+ system: user_id == "USLACK"
195
+ ),
196
+ thread_id: data["thread_ts"] || data["ts"],
197
+ channel_id: channel_id,
198
+ platform: :slack,
199
+ timestamp: slack_time(data["ts"]),
200
+ raw: data
201
+ )
202
+ end
203
+
204
+ def slack_time(value)
205
+ Time.at(Float(value)) if value
206
+ rescue ArgumentError, TypeError
207
+ nil
208
+ end
209
+
111
210
  def parse_reaction(event, payload, added:)
112
211
  [ChatSDK::Events::Reaction.new(
113
212
  emoji: event["reaction"],
@@ -19,6 +19,23 @@ module ChatSDK
19
19
  parts.map.with_index { |part, i| i.odd? ? part : convert_md_to_mrkdwn(part) }.join
20
20
  end
21
21
 
22
+ def normalize_inbound_text(text)
23
+ return "" if text.nil? || text.empty?
24
+
25
+ split_code_blocks(text).map.with_index do |part, index|
26
+ if index.odd?
27
+ part
28
+ else
29
+ part
30
+ .gsub("<!here>", "@here")
31
+ .gsub("<!channel>", "@channel")
32
+ .gsub("<!everyone>", "@everyone")
33
+ .gsub(/<!subteam\^[^|>]+\|(@[^>]+)>/, '\1')
34
+ .gsub(/<(?:mailto|tel):[^>|]+\|([^>]+)>/, '\1')
35
+ end
36
+ end.join
37
+ end
38
+
22
39
  private
23
40
 
24
41
  # ── Slack mrkdwn → Markdown ──────────────────────────────────────────
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "date"
4
+
3
5
  module ChatSDK
4
6
  module Slack
5
7
  class ModalRenderer
@@ -49,10 +51,37 @@ module ChatSDK
49
51
  }
50
52
  el[:placeholder] = {type: "plain_text", text: node.attributes[:placeholder]} if node.attributes[:placeholder]
51
53
  el
54
+ when :date
55
+ el = {type: "datepicker", action_id: node.attributes[:id]}
56
+ initial_date = valid_initial_date(node.attributes[:initial_value])
57
+ el[:initial_date] = initial_date if initial_date
58
+ el[:placeholder] = {type: "plain_text", text: node.attributes[:placeholder]} if node.attributes[:placeholder]
59
+ el
60
+ when :number
61
+ el = {
62
+ type: "number_input",
63
+ action_id: node.attributes[:id],
64
+ is_decimal_allowed: !!node.attributes[:decimal]
65
+ }
66
+ el[:min_value] = node.attributes[:min].to_s unless node.attributes[:min].nil?
67
+ el[:max_value] = node.attributes[:max].to_s unless node.attributes[:max].nil?
68
+ el[:initial_value] = node.attributes[:initial_value].to_s unless node.attributes[:initial_value].nil?
69
+ el[:placeholder] = {type: "plain_text", text: node.attributes[:placeholder]} if node.attributes[:placeholder]
70
+ el
52
71
  end
53
72
  block
54
73
  end
55
74
 
75
+ def valid_initial_date(value)
76
+ return unless value
77
+
78
+ parsed = Date.iso8601(value.to_s)
79
+ value.to_s if parsed.iso8601 == value.to_s
80
+ rescue Date::Error
81
+ ChatSDK::Log.warn("Ignoring invalid modal initial date: #{value.inspect}")
82
+ nil
83
+ end
84
+
56
85
  def render_text(node)
57
86
  {
58
87
  type: "section",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chat_sdk-slack
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Quentin Rousseau
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '0.1'
18
+ version: '1.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '0.1'
25
+ version: '1.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: slack-ruby-client
28
28
  requirement: !ruby/object:Gem::Requirement