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 +4 -4
- data/lib/chat_sdk/slack/adapter.rb +122 -14
- data/lib/chat_sdk/slack/block_kit_renderer.rb +136 -12
- data/lib/chat_sdk/slack/event_parser.rb +101 -2
- data/lib/chat_sdk/slack/format_converter.rb +17 -0
- data/lib/chat_sdk/slack/modal_renderer.rb +29 -0
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 49b9759d50173e826b41f885fcba2f2c276d28574f187ac70bd1932d3da28ab4
|
|
4
|
+
data.tar.gz: 80c4806743ab979c9a6f024c5f7d8081152cdf8eaacb63607728e78518b88a5c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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: '
|
|
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: '
|
|
25
|
+
version: '1.2'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: slack-ruby-client
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|