chat_sdk-slack 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,12 +15,28 @@ 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
+ 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
32
+ blocks.concat(node.children.map { |child| render_node(child) }.compact)
33
+ blocks
19
34
  end
20
35
 
21
36
  def render_node(node)
22
37
  case node.type
23
38
  when :text then render_text(node)
39
+ when :link then render_link(node)
24
40
  when :divider then {type: "divider"}
25
41
  when :image then render_image(node)
26
42
  when :fields then render_fields(node)
@@ -28,19 +44,31 @@ module ChatSDK
28
44
  when :actions then render_actions(node)
29
45
  when :table then render_table(node)
30
46
  when :chart then render_chart(node)
47
+ when :text_input then {type: "section", text: {type: "mrkdwn", text: truncate_mrkdwn(node.attributes[:label], 3000)}}
31
48
  end
32
49
  end
33
50
 
34
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
+
35
56
  {
36
57
  type: "section",
37
- text: {type: "mrkdwn", text: node.attributes[:content]}
58
+ text: {type: "mrkdwn", text: truncate_mrkdwn(content, 3000)}
38
59
  }
39
60
  end
40
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
+
41
69
  def render_image(node)
42
70
  block = {type: "image", image_url: node.attributes[:url]}
43
- block[:alt_text] = node.attributes[:alt] || " "
71
+ block[:alt_text] = truncate_text(node.attributes[:alt] || " ", 2000)
44
72
  block
45
73
  end
46
74
 
@@ -48,16 +76,21 @@ module ChatSDK
48
76
  {
49
77
  type: "section",
50
78
  fields: node.children.map do |field|
51
- {type: "mrkdwn", text: "*#{field.attributes[:label]}*\n#{field.attributes[:value]}"}
79
+ {type: "mrkdwn", text: truncate_mrkdwn("*#{field.attributes[:label]}*\n#{field.attributes[:value]}", 2000)}
52
80
  end
53
81
  }
54
82
  end
55
83
 
56
84
  def render_section(node)
57
85
  block = {type: "section"}
58
- text_children = node.children.select { |c| c.type == :text }
59
- if text_children.any?
60
- block[:text] = {type: "mrkdwn", text: text_children.map { |t| t.attributes[:content] }.join("\n")}
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)}
61
94
  end
62
95
  block
63
96
  end
@@ -74,13 +107,14 @@ module ChatSDK
74
107
  when :button then render_button(node)
75
108
  when :link_button then render_link_button(node)
76
109
  when :select then render_select(node)
110
+ when :radio_select then render_radio_select(node)
77
111
  end
78
112
  end
79
113
 
80
114
  def render_button(node)
81
115
  btn = {
82
116
  type: "button",
83
- text: {type: "plain_text", text: node.attributes[:text]},
117
+ text: {type: "plain_text", text: truncate_text(node.attributes[:text], 75)},
84
118
  action_id: node.attributes[:id]
85
119
  }
86
120
  btn[:value] = node.attributes[:value] if node.attributes[:value]
@@ -89,16 +123,24 @@ module ChatSDK
89
123
  elsif node.attributes[:style] == :danger
90
124
  btn[:style] = "danger"
91
125
  end
126
+ btn[:visible_to_user_ids] = node.attributes[:visible_to] if node.attributes[:visible_to]&.any?
92
127
  btn
93
128
  end
94
129
 
95
130
  def render_link_button(node)
96
- {
131
+ button = {
97
132
  type: "button",
98
- text: {type: "plain_text", text: node.attributes[:text]},
133
+ text: {type: "plain_text", text: truncate_text(node.attributes[:text], 75)},
99
134
  url: node.attributes[:url],
100
135
  action_id: node.attributes[:id] || "link_#{node.attributes[:url].hash.abs}"
101
136
  }
137
+ if node.attributes[:style] == :primary
138
+ button[:style] = "primary"
139
+ elsif node.attributes[:style] == :danger
140
+ button[:style] = "danger"
141
+ end
142
+ button[:visible_to_user_ids] = node.attributes[:visible_to] if node.attributes[:visible_to]&.any?
143
+ button
102
144
  end
103
145
 
104
146
  def render_select(node)
@@ -108,18 +150,41 @@ module ChatSDK
108
150
  options: node.children.map { |opt| render_option(opt) }
109
151
  }
110
152
  if node.attributes[:placeholder]
111
- sel[:placeholder] = {type: "plain_text", text: node.attributes[:placeholder]}
153
+ sel[:placeholder] = {type: "plain_text", text: truncate_text(node.attributes[:placeholder], 150)}
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
112
158
  end
113
159
  sel
114
160
  end
115
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
+
116
181
  def render_option(node)
117
182
  opt = {
118
- text: {type: "plain_text", text: node.attributes[:text]},
183
+ text: {type: "plain_text", text: truncate_text(node.attributes[:text], 75)},
119
184
  value: node.attributes[:value]
120
185
  }
121
186
  if node.attributes[:description]
122
- opt[:description] = {type: "plain_text", text: node.attributes[:description]}
187
+ opt[:description] = {type: "plain_text", text: truncate_text(node.attributes[:description], 75)}
123
188
  end
124
189
  opt
125
190
  end
@@ -190,7 +255,46 @@ module ChatSDK
190
255
  end
191
256
 
192
257
  def fallback_block(text)
193
- {type: "section", text: {type: "mrkdwn", text: "```#{text}```"}}
258
+ {type: "section", text: {type: "mrkdwn", text: truncate_mrkdwn("```#{text}```", 3000)}}
259
+ end
260
+
261
+ def truncate_text(text, limit)
262
+ text = text.to_s
263
+ return text if text.length <= limit
264
+
265
+ "#{text[0, limit - 1]}…"
266
+ end
267
+
268
+ def truncate_mrkdwn(text, limit)
269
+ text = text.to_s
270
+ return text if text.length <= limit
271
+
272
+ prefix = clean_prefix(text[0, limit - 1])
273
+ 4.times do
274
+ bold_close = prefix.count("*").odd? ? "*" : ""
275
+ fence_close = open_fence?(prefix) ? "\n```" : ""
276
+ available = limit - 1 - bold_close.length - fence_close.length
277
+ next_prefix = clean_prefix(text[0, available])
278
+ break if next_prefix == prefix
279
+
280
+ prefix = next_prefix
281
+ end
282
+
283
+ bold_close = prefix.count("*").odd? ? "*" : ""
284
+ fence_close = open_fence?(prefix) ? "\n```" : ""
285
+ suffix = "#{bold_close}…#{fence_close}"
286
+ prefix = clean_prefix(prefix[0, limit - suffix.length]) while prefix.length + suffix.length > limit
287
+ "#{prefix}#{suffix}"
288
+ end
289
+
290
+ def clean_prefix(text)
291
+ text
292
+ .sub(/<(?:[@#!]|[a-z][a-z\d+.-]*(?::|$))[^>]*\z/i, "")
293
+ .sub(/&(?:#?[a-z\d]*)?\z/i, "")
294
+ end
295
+
296
+ def open_fence?(text)
297
+ text.scan("```").length.odd?
194
298
  end
195
299
  end
196
300
  end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ChatSDK
6
+ module Slack
7
+ class ChatStreamer
8
+ attr_reader :ts
9
+
10
+ def initialize(client, channel:, thread_ts:, recipient_user_id: nil,
11
+ recipient_team_id: nil, task_display_mode: nil, buffer_size: 256)
12
+ @client = client
13
+ @stream_args = {channel: channel, thread_ts: thread_ts}
14
+ @stream_args[:recipient_user_id] = recipient_user_id if recipient_user_id
15
+ @stream_args[:recipient_team_id] = recipient_team_id if recipient_team_id
16
+ @stream_args[:task_display_mode] = task_display_mode if task_display_mode
17
+ @buffer_size = buffer_size
18
+ @buffer = +""
19
+ @state = :starting
20
+ @token = nil
21
+ @ts = nil
22
+ end
23
+
24
+ def append(markdown_text: nil, chunks: nil, **options)
25
+ ensure_open!(:append)
26
+ @token = options.delete(:token) if options[:token]
27
+ @buffer << markdown_text if markdown_text && !markdown_text.empty?
28
+ return unless @buffer.length >= @buffer_size || !chunks.nil?
29
+
30
+ flush_buffer(chunks, options)
31
+ end
32
+
33
+ def stop(markdown_text: nil, chunks: nil, blocks: nil, **options)
34
+ ensure_open!(:stop)
35
+ @token = options.delete(:token) if options[:token]
36
+ @buffer << markdown_text if markdown_text && !markdown_text.empty?
37
+
38
+ start_stream unless ts
39
+
40
+ stop_chunks = chunks_for_flush(chunks)
41
+ params = {
42
+ token: @token,
43
+ channel: @stream_args[:channel],
44
+ ts: ts,
45
+ chunks: JSON.generate(stop_chunks)
46
+ }
47
+ params[:blocks] = JSON.generate(blocks) if blocks
48
+ params.merge!(options)
49
+ response = call_api(:chat_stopStream, "chat.stopStream", params)
50
+ @buffer.clear
51
+ @state = :completed
52
+ response
53
+ end
54
+
55
+ private
56
+
57
+ def flush_buffer(chunks, options)
58
+ payload = chunks_for_flush(chunks)
59
+ params = {
60
+ token: @token,
61
+ chunks: JSON.generate(payload)
62
+ }.merge(options)
63
+
64
+ if ts
65
+ params[:channel] = @stream_args[:channel]
66
+ params[:ts] = ts
67
+ response = call_api(:chat_appendStream, "chat.appendStream", params)
68
+ else
69
+ response = call_api(
70
+ :chat_startStream,
71
+ "chat.startStream",
72
+ @stream_args.merge(params)
73
+ )
74
+ @ts = response_value(response, :ts)
75
+ @state = :in_progress
76
+ end
77
+
78
+ @buffer.clear
79
+ response
80
+ end
81
+
82
+ def start_stream
83
+ response = call_api(
84
+ :chat_startStream,
85
+ "chat.startStream",
86
+ @stream_args.merge(token: @token)
87
+ )
88
+ @ts = response_value(response, :ts)
89
+ raise ChatSDK::Error, "failed to stop stream: stream not started" unless ts
90
+
91
+ @state = :in_progress
92
+ end
93
+
94
+ def chunks_for_flush(chunks)
95
+ payload = []
96
+ unless @buffer.empty?
97
+ payload << {type: "markdown_text", text: @buffer}
98
+ end
99
+ Array(chunks).each do |chunk|
100
+ payload << (chunk.respond_to?(:to_h) ? chunk.to_h : chunk)
101
+ end
102
+ payload
103
+ end
104
+
105
+ def call_api(method, endpoint, params)
106
+ if @client.respond_to?(method)
107
+ @client.public_send(method, **params)
108
+ else
109
+ @client.post(endpoint, params)
110
+ end
111
+ end
112
+
113
+ def response_value(response, key)
114
+ response[key.to_s] || response[key]
115
+ end
116
+
117
+ def ensure_open!(action)
118
+ return unless @state == :completed
119
+
120
+ raise ChatSDK::Error, "failed to #{action} stream: stream state is #{@state}"
121
+ end
122
+ end
123
+ end
124
+ end
@@ -37,11 +37,64 @@ module ChatSDK
37
37
  parse_reaction(event, payload, added: true)
38
38
  when "reaction_removed"
39
39
  parse_reaction(event, payload, added: false)
40
+ when "assistant_thread_started"
41
+ parse_assistant_thread(event, payload, ChatSDK::Events::AssistantThreadStarted)
42
+ when "assistant_thread_context_changed"
43
+ parse_assistant_thread(event, payload, ChatSDK::Events::AssistantContextChanged)
44
+ when "app_home_opened"
45
+ parse_app_home_opened(event, payload)
46
+ when "member_joined_channel"
47
+ parse_member_joined_channel(event, payload)
40
48
  else
41
49
  []
42
50
  end
43
51
  end
44
52
 
53
+ def parse_assistant_thread(event, payload, event_class)
54
+ assistant_thread = event["assistant_thread"]
55
+ return [] unless assistant_thread
56
+
57
+ thread_ts = assistant_thread["thread_ts"]
58
+ context = assistant_thread["context"] || {}
59
+ [event_class.new(
60
+ thread_id: thread_ts,
61
+ channel_id: assistant_thread["channel_id"],
62
+ thread_ts: thread_ts,
63
+ user_id: assistant_thread["user_id"],
64
+ context: context,
65
+ platform: :slack,
66
+ adapter_name: :slack,
67
+ raw: payload,
68
+ timestamp: slack_time(event["event_ts"])
69
+ )]
70
+ end
71
+
72
+ def parse_app_home_opened(event, payload)
73
+ return [] unless event["tab"] == "home"
74
+
75
+ [ChatSDK::Events::AppHomeOpened.new(
76
+ user_id: event["user"],
77
+ channel_id: event["channel"],
78
+ tab: event["tab"],
79
+ platform: :slack,
80
+ adapter_name: :slack,
81
+ raw: payload,
82
+ timestamp: slack_time(event["event_ts"])
83
+ )]
84
+ end
85
+
86
+ def parse_member_joined_channel(event, payload)
87
+ [ChatSDK::Events::MemberJoinedChannel.new(
88
+ user_id: event["user"],
89
+ channel_id: event["channel"],
90
+ inviter_id: event["inviter"],
91
+ platform: :slack,
92
+ adapter_name: :slack,
93
+ raw: payload,
94
+ timestamp: slack_time(event["event_ts"])
95
+ )]
96
+ end
97
+
45
98
  def parse_mention(event, payload)
46
99
  author = ChatSDK::Author.new(
47
100
  id: event["user"],
@@ -50,7 +103,7 @@ module ChatSDK
50
103
  )
51
104
  message = ChatSDK::Message.new(
52
105
  id: event["ts"],
53
- text: event["text"] || "",
106
+ text: message_text(event),
54
107
  author: author,
55
108
  thread_id: event["thread_ts"] || event["ts"],
56
109
  channel_id: event["channel"],
@@ -80,7 +133,7 @@ module ChatSDK
80
133
  )
81
134
  message = ChatSDK::Message.new(
82
135
  id: event["ts"],
83
- text: event["text"] || "",
136
+ text: message_text(event),
84
137
  author: author,
85
138
  thread_id: event["thread_ts"] || event["ts"],
86
139
  channel_id: event["channel"],
@@ -130,6 +183,37 @@ module ChatSDK
130
183
  )]
131
184
  end
132
185
 
186
+ def message_text(event)
187
+ blocks = event["blocks"]
188
+ text = if blocks.is_a?(Array) && !blocks.empty? && blocks.all? { |block| %w[section header context image divider actions table].include?(block["type"]) }
189
+ blocks.filter_map { |block| block_text(block) }.join("\n")
190
+ else
191
+ event["text"] || ""
192
+ end
193
+ FormatConverter.new.normalize_inbound_text(text)
194
+ end
195
+
196
+ def block_text(block)
197
+ case block["type"]
198
+ when "section", "header"
199
+ texts = [block.dig("text", "text")]
200
+ texts.concat(Array(block["fields"]).map { |field| field["text"] })
201
+ texts.compact.reject(&:empty?).join("\n")
202
+ when "context"
203
+ Array(block["elements"]).filter_map { |element| element.dig("text") }.reject(&:empty?).join("\n")
204
+ when "image"
205
+ block["alt_text"]
206
+ when "actions"
207
+ Array(block["elements"]).filter_map do |element|
208
+ element.dig("text", "text") || element.dig("placeholder", "text")
209
+ end.reject(&:empty?).join(" ")
210
+ when "table"
211
+ Array(block["rows"]).map do |row|
212
+ Array(row).map { |cell| cell.dig("text") || cell.to_s }.join(" | ")
213
+ end.join("\n")
214
+ end
215
+ end
216
+
133
217
  def parse_message_deleted(event, payload)
134
218
  previous = event["previous_message"]
135
219
  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 ──────────────────────────────────────────
@@ -11,6 +11,9 @@ module ChatSDK
11
11
  title: {type: "plain_text", text: node.attributes[:title]}
12
12
  }
13
13
  view[:callback_id] = node.attributes[:callback_id] if node.attributes[:callback_id]
14
+ if node.attributes[:close_label] && !node.attributes[:close_label].empty?
15
+ view[:close] = {type: "plain_text", text: node.attributes[:close_label]}
16
+ end
14
17
  if node.attributes[:submit_label]
15
18
  view[:submit] = {type: "plain_text", text: node.attributes[:submit_label]}
16
19
  end
@@ -24,6 +27,7 @@ module ChatSDK
24
27
  case node.type
25
28
  when :input then render_input(node)
26
29
  when :text then render_text(node)
30
+ when :fields then render_fields(node)
27
31
  end
28
32
  end
29
33
 
@@ -42,6 +46,10 @@ module ChatSDK
42
46
  multiline: !!node.attributes[:multiline]
43
47
  }
44
48
  el[:placeholder] = {type: "plain_text", text: node.attributes[:placeholder]} if node.attributes[:placeholder]
49
+ if node.attributes[:initial_value] && !node.attributes[:initial_value].empty?
50
+ el[:initial_value] = node.attributes[:initial_value]
51
+ end
52
+ el[:max_length] = node.attributes[:max_length] if node.attributes[:max_length] && !node.attributes[:max_length].zero?
45
53
  el
46
54
  when :select
47
55
  el = {
@@ -50,6 +58,26 @@ module ChatSDK
50
58
  options: node.children.map { |opt| render_option(opt) }
51
59
  }
52
60
  el[:placeholder] = {type: "plain_text", text: node.attributes[:placeholder]} if node.attributes[:placeholder]
61
+ if node.attributes[:initial_option] && !node.attributes[:initial_option].empty?
62
+ initial_option = el[:options].find { |option| option[:value] == node.attributes[:initial_option] }
63
+ el[:initial_option] = initial_option if initial_option
64
+ end
65
+ el
66
+ when :radio_select
67
+ options = node.children.first(10).map { |option| render_radio_option(option) }
68
+ el = {type: "radio_buttons", action_id: node.attributes[:id], options: options}
69
+ if node.attributes[:initial_option] && !node.attributes[:initial_option].empty?
70
+ initial_option = options.find { |option| option[:value] == node.attributes[:initial_option] }
71
+ el[:initial_option] = initial_option if initial_option
72
+ end
73
+ el
74
+ when :external_select
75
+ el = {type: "external_select", action_id: node.attributes[:id]}
76
+ el[:placeholder] = {type: "plain_text", text: node.attributes[:placeholder]} if node.attributes[:placeholder]
77
+ el[:min_query_length] = node.attributes[:min_query_length] unless node.attributes[:min_query_length].nil?
78
+ if node.attributes[:initial_option]
79
+ el[:initial_option] = render_external_option(node.attributes[:initial_option])
80
+ end
53
81
  el
54
82
  when :date
55
83
  el = {type: "datepicker", action_id: node.attributes[:id]}
@@ -69,6 +97,7 @@ module ChatSDK
69
97
  el[:placeholder] = {type: "plain_text", text: node.attributes[:placeholder]} if node.attributes[:placeholder]
70
98
  el
71
99
  end
100
+ block[:dispatch_action] = node.attributes[:dispatch_action] unless node.attributes[:dispatch_action].nil?
72
101
  block
73
102
  end
74
103
 
@@ -97,6 +126,33 @@ module ChatSDK
97
126
  opt[:description] = {type: "plain_text", text: node.attributes[:description]} if node.attributes[:description]
98
127
  opt
99
128
  end
129
+
130
+ def render_radio_option(node)
131
+ option = {
132
+ text: {type: "mrkdwn", text: node.attributes[:text]},
133
+ value: node.attributes[:value]
134
+ }
135
+ option[:description] = {type: "mrkdwn", text: node.attributes[:description]} if node.attributes[:description]
136
+ option
137
+ end
138
+
139
+ def render_external_option(option)
140
+ label = option[:label] || option["label"]
141
+ value = option[:value] || option["value"]
142
+ description = option[:description] || option["description"]
143
+ rendered = {text: {type: "plain_text", text: label}, value: value}
144
+ rendered[:description] = {type: "plain_text", text: description} if description
145
+ rendered
146
+ end
147
+
148
+ def render_fields(node)
149
+ {
150
+ type: "section",
151
+ fields: node.children.map do |field|
152
+ {type: "mrkdwn", text: "*#{field.attributes[:label]}*\n#{field.attributes[:value]}"}
153
+ end
154
+ }
155
+ end
100
156
  end
101
157
  end
102
158
  end