chat_sdk-slack 1.1.0 → 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: d93aacf823c427212c01efbdf76255caf3cd4a534b033b5557a9bbff9aaa8b97
4
- data.tar.gz: a82ac7a769d42943060042cfaf16179b1a7c3482d9bfd9b268631b59a9723fa8
3
+ metadata.gz: 49b9759d50173e826b41f885fcba2f2c276d28574f187ac70bd1932d3da28ab4
4
+ data.tar.gz: 80c4806743ab979c9a6f024c5f7d8081152cdf8eaacb63607728e78518b88a5c
5
5
  SHA512:
6
- metadata.gz: 49b4f95d68bb451922802284fae8ef36f9dffbc72ae33c44943618e5ca4141410c9f58cc885d353b96a2004ee12544cab4739cfeb0c3ef8fc2557c8bb731f3d1
7
- data.tar.gz: f1ad880423c30b689eff5476070d3227310752b3122fd97c17a3fe34d8b52a3b0e05f7c67688b1bb47511dc839c0a1704fc3cfb210395d6654a966e119ff199e
6
+ metadata.gz: c79dccb6431aa33e0c9aa756287b7ec4342e97d4733b42e91f94e5ecfe44267e73dd404890dc13d600700985c6d958f03acad36f52121ec62ba010eb14145019
7
+ data.tar.gz: cebaf0452264d3b98802a1774fd834d77fa3be5b83a9f38848890c0b8ec742f976a0e510edf520f97f06e0375ca33c5fdce5af845043381cdb8a0ba1ee3b351d
@@ -9,11 +9,14 @@ 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, proxy: 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
17
20
  @proxy = proxy || ENV["HTTPS_PROXY"] || ENV["https_proxy"]
18
21
 
19
22
  unless @bot_token || @client_id
@@ -35,6 +38,8 @@ module ChatSDK
35
38
 
36
39
  # Returns the per-request client (multi-workspace) or the static client (single-workspace).
37
40
  def client
41
+ return @client unless @client_id
42
+
38
43
  ::Thread.current[:chat_sdk_slack_client] || @client
39
44
  end
40
45
 
@@ -54,6 +59,7 @@ module ChatSDK
54
59
  "bot_user_id" => bot_user_id,
55
60
  "team_name" => team_name
56
61
  })
62
+ invalidate_bot_user_id(team_id)
57
63
  end
58
64
 
59
65
  def get_installation(team_id)
@@ -64,6 +70,7 @@ module ChatSDK
64
70
 
65
71
  def delete_installation(team_id)
66
72
  @team_clients.delete(team_id)
73
+ invalidate_bot_user_id(team_id)
67
74
  return unless @state
68
75
 
69
76
  @state.delete(installation_key(team_id))
@@ -153,7 +160,8 @@ module ChatSDK
153
160
  resolve_team_client(team_id) if team_id
154
161
  end
155
162
 
156
- EventParser.parse(parsed)
163
+ events = EventParser.parse(parsed)
164
+ classify_message_events(events, parsed)
157
165
  end
158
166
 
159
167
  # Outbound
@@ -177,7 +185,7 @@ module ChatSDK
177
185
  )
178
186
  end
179
187
 
180
- def edit_message(channel_id:, message_id:, message:)
188
+ def edit_message(channel_id:, message_id:, message:, **_options)
181
189
  msg = ChatSDK::PostableMessage.from(message)
182
190
  params = {channel: channel_id, ts: message_id}
183
191
 
@@ -186,7 +194,7 @@ module ChatSDK
186
194
  client.chat_update(**params)
187
195
  end
188
196
 
189
- def delete_message(channel_id:, message_id:)
197
+ def delete_message(channel_id:, message_id:, **_options)
190
198
  client.chat_delete(channel: channel_id, ts: message_id)
191
199
  end
192
200
 
@@ -201,17 +209,19 @@ module ChatSDK
201
209
  end
202
210
 
203
211
  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)}
212
+ content = io.read
213
+ io.rewind if io.respond_to?(:rewind)
214
+ params = {channel_id: channel_id, filename: filename, content: content}
205
215
  params[:thread_ts] = thread_id if thread_id
206
216
  params[:initial_comment] = comment if comment
207
- client.files_upload(**params)
217
+ client.files_upload_v2(**params)
208
218
  end
209
219
 
210
- def add_reaction(channel_id:, message_id:, emoji:)
220
+ def add_reaction(channel_id:, message_id:, emoji:, **_options)
211
221
  client.reactions_add(channel: channel_id, timestamp: message_id, name: emoji)
212
222
  end
213
223
 
214
- def remove_reaction(channel_id:, message_id:, emoji:)
224
+ def remove_reaction(channel_id:, message_id:, emoji:, **_options)
215
225
  client.reactions_remove(channel: channel_id, timestamp: message_id, name: emoji)
216
226
  end
217
227
 
@@ -315,11 +325,21 @@ module ChatSDK
315
325
  result["channel"]
316
326
  end
317
327
 
318
- def start_typing(channel_id:, thread_id: nil)
328
+ def start_typing(channel_id:, thread_id: nil, message_id: nil)
319
329
  # Slack doesn't have a native typing indicator API for bots
320
330
  # This is a no-op but the capability is declared for streaming support
321
331
  end
322
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
+
323
343
  # Slack-specific: receive real-time events via Socket Mode WebSocket.
324
344
  # Requires the optional 'faye-websocket' gem and an app-level token (xapp-*).
325
345
  # Not part of the base adapter contract.
@@ -331,9 +351,18 @@ module ChatSDK
331
351
  def start_socket_mode(app_token: nil, &block)
332
352
  app_token ||= ENV["SLACK_APP_TOKEN"]
333
353
  raise ChatSDK::ConfigurationError, "Slack app_token required for socket mode" unless app_token
354
+ raise ArgumentError, "start requires a block" unless block
334
355
 
335
356
  @socket_mode = SocketMode.new(app_token: app_token, bot_client: client)
336
- @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
337
366
  end
338
367
 
339
368
  def stop_socket_mode
@@ -361,13 +390,83 @@ module ChatSDK
361
390
  options.empty? ? ::Slack::Web::Client.new : ::Slack::Web::Client.new(options)
362
391
  end
363
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
+
364
463
  def resolve_team_client(team_id)
365
464
  return unless @client_id && @state
366
465
 
367
466
  cached = @team_clients[team_id]
368
467
  if cached
369
468
  ::Thread.current[:chat_sdk_slack_client] = cached
370
- return
469
+ return cached
371
470
  end
372
471
 
373
472
  installation = get_installation(team_id)
@@ -376,6 +475,7 @@ module ChatSDK
376
475
  new_client = build_client(token: installation["bot_token"])
377
476
  @team_clients[team_id] = new_client
378
477
  ::Thread.current[:chat_sdk_slack_client] = new_client
478
+ new_client
379
479
  end
380
480
 
381
481
  def installation_key(team_id)
@@ -15,7 +15,15 @@ module ChatSDK
15
15
  def render_card(node)
16
16
  @used_native_table = false
17
17
  @chart_count = 0
18
- node.children.map { |child| render_node(child) }.compact
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
19
27
  end
20
28
 
21
29
  def render_node(node)
@@ -28,19 +36,20 @@ module ChatSDK
28
36
  when :actions then render_actions(node)
29
37
  when :table then render_table(node)
30
38
  when :chart then render_chart(node)
39
+ when :text_input then {type: "section", text: {type: "mrkdwn", text: truncate_mrkdwn(node.attributes[:label], 3000)}}
31
40
  end
32
41
  end
33
42
 
34
43
  def render_text(node)
35
44
  {
36
45
  type: "section",
37
- text: {type: "mrkdwn", text: node.attributes[:content]}
46
+ text: {type: "mrkdwn", text: truncate_mrkdwn(node.attributes[:content], 3000)}
38
47
  }
39
48
  end
40
49
 
41
50
  def render_image(node)
42
51
  block = {type: "image", image_url: node.attributes[:url]}
43
- block[:alt_text] = node.attributes[:alt] || " "
52
+ block[:alt_text] = truncate_text(node.attributes[:alt] || " ", 2000)
44
53
  block
45
54
  end
46
55
 
@@ -48,7 +57,7 @@ module ChatSDK
48
57
  {
49
58
  type: "section",
50
59
  fields: node.children.map do |field|
51
- {type: "mrkdwn", text: "*#{field.attributes[:label]}*\n#{field.attributes[:value]}"}
60
+ {type: "mrkdwn", text: truncate_mrkdwn("*#{field.attributes[:label]}*\n#{field.attributes[:value]}", 2000)}
52
61
  end
53
62
  }
54
63
  end
@@ -57,7 +66,7 @@ module ChatSDK
57
66
  block = {type: "section"}
58
67
  text_children = node.children.select { |c| c.type == :text }
59
68
  if text_children.any?
60
- 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)}
61
70
  end
62
71
  block
63
72
  end
@@ -80,7 +89,7 @@ module ChatSDK
80
89
  def render_button(node)
81
90
  btn = {
82
91
  type: "button",
83
- text: {type: "plain_text", text: node.attributes[:text]},
92
+ text: {type: "plain_text", text: truncate_text(node.attributes[:text], 75)},
84
93
  action_id: node.attributes[:id]
85
94
  }
86
95
  btn[:value] = node.attributes[:value] if node.attributes[:value]
@@ -89,16 +98,19 @@ module ChatSDK
89
98
  elsif node.attributes[:style] == :danger
90
99
  btn[:style] = "danger"
91
100
  end
101
+ btn[:visible_to_user_ids] = node.attributes[:visible_to] if node.attributes[:visible_to]&.any?
92
102
  btn
93
103
  end
94
104
 
95
105
  def render_link_button(node)
96
- {
106
+ button = {
97
107
  type: "button",
98
- text: {type: "plain_text", text: node.attributes[:text]},
108
+ text: {type: "plain_text", text: truncate_text(node.attributes[:text], 75)},
99
109
  url: node.attributes[:url],
100
110
  action_id: node.attributes[:id] || "link_#{node.attributes[:url].hash.abs}"
101
111
  }
112
+ button[:visible_to_user_ids] = node.attributes[:visible_to] if node.attributes[:visible_to]&.any?
113
+ button
102
114
  end
103
115
 
104
116
  def render_select(node)
@@ -108,18 +120,18 @@ module ChatSDK
108
120
  options: node.children.map { |opt| render_option(opt) }
109
121
  }
110
122
  if node.attributes[:placeholder]
111
- sel[:placeholder] = {type: "plain_text", text: node.attributes[:placeholder]}
123
+ sel[:placeholder] = {type: "plain_text", text: truncate_text(node.attributes[:placeholder], 150)}
112
124
  end
113
125
  sel
114
126
  end
115
127
 
116
128
  def render_option(node)
117
129
  opt = {
118
- text: {type: "plain_text", text: node.attributes[:text]},
130
+ text: {type: "plain_text", text: truncate_text(node.attributes[:text], 75)},
119
131
  value: node.attributes[:value]
120
132
  }
121
133
  if node.attributes[:description]
122
- opt[:description] = {type: "plain_text", text: node.attributes[:description]}
134
+ opt[:description] = {type: "plain_text", text: truncate_text(node.attributes[:description], 75)}
123
135
  end
124
136
  opt
125
137
  end
@@ -190,7 +202,46 @@ module ChatSDK
190
202
  end
191
203
 
192
204
  def fallback_block(text)
193
- {type: "section", text: {type: "mrkdwn", text: "```#{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?
194
245
  end
195
246
  end
196
247
  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"],
@@ -80,7 +80,7 @@ module ChatSDK
80
80
  )
81
81
  message = ChatSDK::Message.new(
82
82
  id: event["ts"],
83
- text: event["text"] || "",
83
+ text: message_text(event),
84
84
  author: author,
85
85
  thread_id: event["thread_ts"] || event["ts"],
86
86
  channel_id: event["channel"],
@@ -130,6 +130,37 @@ module ChatSDK
130
130
  )]
131
131
  end
132
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
+
133
164
  def parse_message_deleted(event, payload)
134
165
  previous = event["previous_message"]
135
166
  message_id = event["deleted_ts"] || previous&.dig("ts")
@@ -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 ──────────────────────────────────────────
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.1.0
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