chat_sdk 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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/lib/chat_sdk/adapter/base.rb +19 -5
  3. data/lib/chat_sdk/adapter/invocation.rb +18 -0
  4. data/lib/chat_sdk/ai/conversation_scope.rb +11 -1
  5. data/lib/chat_sdk/ai/stream_handler.rb +3 -3
  6. data/lib/chat_sdk/ai/tool_builder.rb +2 -1
  7. data/lib/chat_sdk/ai/tool_executor.rb +32 -15
  8. data/lib/chat_sdk/author.rb +6 -1
  9. data/lib/chat_sdk/cards/actions_context.rb +26 -4
  10. data/lib/chat_sdk/cards/builder.rb +30 -4
  11. data/lib/chat_sdk/cards/node.rb +4 -0
  12. data/lib/chat_sdk/cards/renderer.rb +3 -0
  13. data/lib/chat_sdk/chat.rb +80 -5
  14. data/lib/chat_sdk/config.rb +34 -2
  15. data/lib/chat_sdk/dispatcher.rb +107 -28
  16. data/lib/chat_sdk/error_reporter.rb +36 -0
  17. data/lib/chat_sdk/errors.rb +1 -0
  18. data/lib/chat_sdk/event_registry.rb +7 -2
  19. data/lib/chat_sdk/event_serializer.rb +66 -6
  20. data/lib/chat_sdk/events/action.rb +3 -2
  21. data/lib/chat_sdk/events/app_home_opened.rb +16 -0
  22. data/lib/chat_sdk/events/assistant_context.rb +25 -0
  23. data/lib/chat_sdk/events/assistant_context_changed.rb +18 -0
  24. data/lib/chat_sdk/events/assistant_thread_started.rb +18 -0
  25. data/lib/chat_sdk/events/installed.rb +19 -0
  26. data/lib/chat_sdk/events/member_joined_channel.rb +16 -0
  27. data/lib/chat_sdk/events/uninstalled.rb +19 -0
  28. data/lib/chat_sdk/format/mentions.rb +61 -0
  29. data/lib/chat_sdk/message.rb +4 -1
  30. data/lib/chat_sdk/modals/builder.rb +39 -4
  31. data/lib/chat_sdk/postable_message.rb +3 -2
  32. data/lib/chat_sdk/streaming/chunk.rb +35 -0
  33. data/lib/chat_sdk/streaming/markdown_text_chunk.rb +15 -0
  34. data/lib/chat_sdk/streaming/plan_update_chunk.rb +15 -0
  35. data/lib/chat_sdk/streaming/stream.rb +48 -8
  36. data/lib/chat_sdk/streaming/task_update_chunk.rb +26 -0
  37. data/lib/chat_sdk/streaming/writer.rb +16 -0
  38. data/lib/chat_sdk/streaming_plan.rb +32 -0
  39. data/lib/chat_sdk/testing/fake_adapter.rb +6 -6
  40. data/lib/chat_sdk/thread.rb +86 -15
  41. data/lib/chat_sdk/version.rb +1 -1
  42. data/lib/chat_sdk/webhook/endpoint.rb +45 -2
  43. data/lib/chat_sdk.rb +2 -2
  44. metadata +17 -1
@@ -4,9 +4,11 @@ module ChatSDK
4
4
  class Message
5
5
  attr_reader :id, :text, :author, :thread_id, :channel_id,
6
6
  :platform, :attachments, :links, :reply_to, :subject, :raw, :timestamp
7
+ attr_accessor :is_mention
7
8
 
8
9
  def initialize(id:, text:, author:, thread_id:, channel_id:, platform:,
9
- attachments: [], links: [], reply_to: nil, subject: nil, raw: nil, timestamp: nil)
10
+ attachments: [], links: [], reply_to: nil, subject: nil, raw: nil,
11
+ timestamp: nil, is_mention: nil)
10
12
  @id = id
11
13
  @text = text
12
14
  @author = author
@@ -19,6 +21,7 @@ module ChatSDK
19
21
  @subject = subject
20
22
  @raw = raw
21
23
  @timestamp = timestamp
24
+ @is_mention = is_mention
22
25
  end
23
26
 
24
27
  def ==(other)
@@ -3,10 +3,11 @@
3
3
  module ChatSDK
4
4
  module Modals
5
5
  class Builder
6
- def initialize(title:, submit_label: nil, callback_id: nil, &block)
6
+ def initialize(title:, submit_label: nil, callback_id: nil, close_label: nil, &block)
7
7
  @title = title
8
8
  @submit_label = submit_label
9
9
  @callback_id = callback_id
10
+ @close_label = close_label
10
11
  @children = []
11
12
  instance_eval(&block) if block
12
13
  end
@@ -15,23 +16,57 @@ module ChatSDK
15
16
  attrs = {title: @title}
16
17
  attrs[:submit_label] = @submit_label if @submit_label
17
18
  attrs[:callback_id] = @callback_id if @callback_id
19
+ attrs[:close_label] = @close_label if @close_label
18
20
  ChatSDK::Cards::Node.new(:modal, attributes: attrs, children: @children)
19
21
  end
20
22
 
21
- def text_input(id:, label:, placeholder: nil, multiline: false, optional: false)
23
+ def text_input(id:, label:, placeholder: nil, multiline: false, optional: false, initial_value: nil, max_length: nil)
22
24
  attrs = {id: id, label: label, input_type: :text, multiline: multiline, optional: optional}
23
25
  attrs[:placeholder] = placeholder if placeholder
26
+ attrs[:initial_value] = initial_value unless initial_value.nil?
27
+ attrs[:max_length] = max_length unless max_length.nil?
24
28
  @children << ChatSDK::Cards::Node.new(:input, attributes: attrs)
25
29
  end
26
30
 
27
- def select_input(id:, label:, placeholder: nil, optional: false, &block)
31
+ def select_input(id:, label:, placeholder: nil, optional: false, initial_option: nil, dispatch_action: nil, &block)
28
32
  ctx = ChatSDK::Cards::SelectContext.new
29
- ctx.instance_eval(&block)
33
+ ctx.instance_eval(&block) if block
34
+ raise ArgumentError, "Select requires at least one option" if ctx.nodes.empty?
35
+
30
36
  attrs = {id: id, label: label, input_type: :select, optional: optional}
31
37
  attrs[:placeholder] = placeholder if placeholder
38
+ attrs[:initial_option] = initial_option unless initial_option.nil?
39
+ attrs[:dispatch_action] = dispatch_action unless dispatch_action.nil?
40
+ @children << ChatSDK::Cards::Node.new(:input, attributes: attrs, children: ctx.nodes)
41
+ end
42
+
43
+ def radio_select_input(id:, label:, initial_option: nil, optional: nil, dispatch_action: nil, &block)
44
+ ctx = ChatSDK::Cards::SelectContext.new
45
+ ctx.instance_eval(&block) if block
46
+ raise ArgumentError, "RadioSelect requires at least one option" if ctx.nodes.empty?
47
+
48
+ attrs = {id: id, label: label, input_type: :radio_select}
49
+ attrs[:initial_option] = initial_option unless initial_option.nil?
50
+ attrs[:optional] = optional unless optional.nil?
51
+ attrs[:dispatch_action] = dispatch_action unless dispatch_action.nil?
32
52
  @children << ChatSDK::Cards::Node.new(:input, attributes: attrs, children: ctx.nodes)
33
53
  end
34
54
 
55
+ def external_select_input(id:, label:, placeholder: nil, min_query_length: nil, initial_option: nil, optional: nil)
56
+ attrs = {id: id, label: label, input_type: :external_select}
57
+ attrs[:placeholder] = placeholder if placeholder
58
+ attrs[:min_query_length] = min_query_length unless min_query_length.nil?
59
+ attrs[:initial_option] = initial_option unless initial_option.nil?
60
+ attrs[:optional] = optional unless optional.nil?
61
+ @children << ChatSDK::Cards::Node.new(:input, attributes: attrs)
62
+ end
63
+
64
+ def fields(&block)
65
+ ctx = ChatSDK::Cards::FieldsContext.new
66
+ ctx.instance_eval(&block)
67
+ @children << ChatSDK::Cards::Node.new(:fields, children: ctx.nodes)
68
+ end
69
+
35
70
  def date_input(id:, label:, initial_value: nil, placeholder: nil, optional: false)
36
71
  attrs = {id: id, label: label, input_type: :date, optional: optional}
37
72
  attrs[:initial_value] = initial_value if initial_value
@@ -2,14 +2,15 @@
2
2
 
3
3
  module ChatSDK
4
4
  class PostableMessage
5
- attr_reader :text, :card, :attachments, :metadata
5
+ attr_reader :text, :card, :attachments, :metadata, :ai_generated
6
6
 
7
- def initialize(text: nil, card: nil, attachments: [], metadata: {})
7
+ def initialize(text: nil, card: nil, attachments: [], metadata: {}, ai_generated: false)
8
8
  raise ArgumentError, "text or card required" if text.nil? && card.nil?
9
9
  @text = text
10
10
  @card = card
11
11
  @attachments = attachments
12
12
  @metadata = metadata
13
+ @ai_generated = ai_generated
13
14
  end
14
15
 
15
16
  def card?
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Streaming
5
+ module Chunk
6
+ module_function
7
+
8
+ def normalize(value)
9
+ return value if value.is_a?(MarkdownTextChunk) || value.is_a?(TaskUpdateChunk) || value.is_a?(PlanUpdateChunk)
10
+
11
+ if value.is_a?(Hash)
12
+ type = value[:type] || value["type"]
13
+ attributes = value.each_with_object({}) do |(key, entry), result|
14
+ result[key.to_sym] = entry
15
+ end
16
+
17
+ return MarkdownTextChunk.new(text: attributes[:text]) if type.to_s == "markdown_text"
18
+ return TaskUpdateChunk.new(**attributes.except(:type)) if type.to_s == "task_update"
19
+ return PlanUpdateChunk.new(**attributes.except(:type)) if type.to_s == "plan_update"
20
+
21
+ return nil
22
+ end
23
+
24
+ value.to_s
25
+ end
26
+
27
+ def text_of(value)
28
+ case value
29
+ when String then value
30
+ when MarkdownTextChunk then value.text
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Streaming
5
+ MarkdownTextChunk = Data.define(:text) do
6
+ def type
7
+ "markdown_text"
8
+ end
9
+
10
+ def to_h
11
+ {type: type, text: text}
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Streaming
5
+ PlanUpdateChunk = Data.define(:title) do
6
+ def type
7
+ "plan_update"
8
+ end
9
+
10
+ def to_h
11
+ {type: type, title: title}
12
+ end
13
+ end
14
+ end
15
+ end
@@ -3,39 +3,74 @@
3
3
  module ChatSDK
4
4
  module Streaming
5
5
  class Stream
6
+ include ChatSDK::Adapter::Invocation
7
+
6
8
  attr_reader :adapter, :channel_id, :thread_id, :update_interval
7
9
 
8
- def initialize(adapter:, channel_id:, thread_id:, placeholder: nil, update_interval: 0.5)
10
+ def initialize(adapter:, channel_id:, thread_id:, placeholder: nil, update_interval: 0.5, ai_generated: false)
9
11
  @adapter = adapter
10
12
  @channel_id = channel_id
11
13
  @thread_id = thread_id
12
14
  @placeholder = placeholder
13
15
  @update_interval = update_interval
16
+ @ai_generated = ai_generated
14
17
  @buffer = +""
18
+ @delivered_text = +""
15
19
  @message_id = nil
20
+ @message = nil
16
21
  @last_flush_at = nil
17
22
  end
18
23
 
19
24
  def <<(chunk)
20
- @buffer << chunk.to_s
25
+ text = Chunk.text_of(Chunk.normalize(chunk))
26
+ return self unless text
27
+
28
+ @buffer << text
21
29
  maybe_flush
22
30
  self
23
31
  end
24
32
 
25
- def run(&block)
33
+ def run(source = nil, &block)
26
34
  post_placeholder if @placeholder
27
- yield self
35
+ if source
36
+ source.each { |chunk| self << chunk }
37
+ elsif block
38
+ block.call(self)
39
+ end
28
40
  flush
41
+ message_with_text(@message, @delivered_text)
29
42
  end
30
43
 
31
44
  private
32
45
 
46
+ def message_with_text(message, text)
47
+ return unless message
48
+
49
+ ChatSDK::Message.new(
50
+ id: message.id,
51
+ text: text.dup,
52
+ author: message.author,
53
+ thread_id: message.thread_id,
54
+ channel_id: message.channel_id,
55
+ platform: message.platform,
56
+ attachments: message.attachments,
57
+ links: message.links,
58
+ reply_to: message.reply_to,
59
+ subject: message.subject,
60
+ raw: message.raw,
61
+ timestamp: message.timestamp,
62
+ is_mention: message.is_mention
63
+ )
64
+ end
65
+
33
66
  def post_placeholder
34
67
  result = adapter.post_message(
35
68
  channel_id: channel_id,
36
- message: PostableMessage.new(text: @placeholder),
69
+ message: PostableMessage.new(text: @placeholder, ai_generated: @ai_generated),
37
70
  thread_id: thread_id
38
71
  )
72
+ @delivered_text = @placeholder.dup
73
+ @message = result if result.is_a?(ChatSDK::Message)
39
74
  @message_id = result.id if result.respond_to?(:id)
40
75
  end
41
76
 
@@ -50,18 +85,23 @@ module ChatSDK
50
85
  return if @buffer.empty?
51
86
 
52
87
  if @message_id && adapter.supports?(:edit_messages)
53
- adapter.edit_message(
88
+ call_adapter(
89
+ :edit_message,
54
90
  channel_id: channel_id,
55
91
  message_id: @message_id,
56
- message: PostableMessage.new(text: @buffer.dup)
92
+ message: PostableMessage.new(text: @buffer.dup, ai_generated: @ai_generated),
93
+ thread_id: thread_id
57
94
  )
95
+ @delivered_text = @buffer.dup
58
96
  elsif !@message_id
59
97
  result = adapter.post_message(
60
98
  channel_id: channel_id,
61
- message: PostableMessage.new(text: @buffer.dup),
99
+ message: PostableMessage.new(text: @buffer.dup, ai_generated: @ai_generated),
62
100
  thread_id: thread_id
63
101
  )
102
+ @message = result if result.is_a?(ChatSDK::Message)
64
103
  @message_id = result.id if result.respond_to?(:id)
104
+ @delivered_text = @buffer.dup
65
105
  end
66
106
 
67
107
  @last_flush_at = Time.now.to_f
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Streaming
5
+ TASK_UPDATE_STATUSES = %w[pending in_progress complete error].freeze
6
+ private_constant :TASK_UPDATE_STATUSES
7
+
8
+ TaskUpdateChunk = Data.define(:id, :title, :status, :details, :output) do
9
+ def initialize(id:, title:, status:, details: nil, output: nil)
10
+ unless TASK_UPDATE_STATUSES.include?(status.to_s)
11
+ raise ArgumentError, "status must be one of #{TASK_UPDATE_STATUSES.join(", ")}"
12
+ end
13
+
14
+ super(id: id, title: title, status: status.to_s, details: details, output: output)
15
+ end
16
+
17
+ def type
18
+ "task_update"
19
+ end
20
+
21
+ def to_h
22
+ {type: type, id: id, title: title, status: status, details: details, output: output}.compact
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Streaming
5
+ class Writer
6
+ def initialize(yielder)
7
+ @yielder = yielder
8
+ end
9
+
10
+ def <<(chunk)
11
+ @yielder << chunk
12
+ self
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ class StreamingPlan
5
+ GROUP_TASKS = %w[plan timeline].freeze
6
+
7
+ attr_reader :stream, :options
8
+
9
+ def initialize(stream, group_tasks: nil, end_with: nil, update_interval: nil, ai_generated: false)
10
+ if group_tasks && !GROUP_TASKS.include?(group_tasks.to_s)
11
+ raise ArgumentError, "group_tasks must be :plan or :timeline"
12
+ end
13
+
14
+ @stream = stream
15
+ @options = {
16
+ group_tasks: group_tasks&.to_s,
17
+ end_with: end_with,
18
+ update_interval: update_interval,
19
+ ai_generated: ai_generated
20
+ }.compact
21
+ end
22
+
23
+ def to_stream_options
24
+ {
25
+ task_display_mode: options[:group_tasks],
26
+ stop_blocks: options[:end_with],
27
+ update_interval: options[:update_interval],
28
+ ai_generated: (true if options[:ai_generated])
29
+ }.compact
30
+ end
31
+ end
32
+ end
@@ -50,7 +50,7 @@ module ChatSDK
50
50
  )
51
51
  end
52
52
 
53
- def edit_message(channel_id:, message_id:, message:)
53
+ def edit_message(channel_id:, message_id:, message:, **_options)
54
54
  record = RecordedCall.new(:edit_message, channel_id: channel_id, message_id: message_id, message: message)
55
55
  @edited_messages << record
56
56
  record
@@ -68,7 +68,7 @@ module ChatSDK
68
68
  record
69
69
  end
70
70
 
71
- def delete_message(channel_id:, message_id:)
71
+ def delete_message(channel_id:, message_id:, **_options)
72
72
  record = RecordedCall.new(:delete_message, channel_id: channel_id, message_id: message_id)
73
73
  @deleted_messages << record
74
74
  record
@@ -86,13 +86,13 @@ module ChatSDK
86
86
  record
87
87
  end
88
88
 
89
- def add_reaction(channel_id:, message_id:, emoji:)
89
+ def add_reaction(channel_id:, message_id:, emoji:, **_options)
90
90
  record = RecordedCall.new(:add_reaction, channel_id: channel_id, message_id: message_id, emoji: emoji)
91
91
  @reactions_added << record
92
92
  record
93
93
  end
94
94
 
95
- def remove_reaction(channel_id:, message_id:, emoji:)
95
+ def remove_reaction(channel_id:, message_id:, emoji:, **_options)
96
96
  record = RecordedCall.new(:remove_reaction, channel_id: channel_id, message_id: message_id, emoji: emoji)
97
97
  @reactions_removed << record
98
98
  record
@@ -122,8 +122,8 @@ module ChatSDK
122
122
  record
123
123
  end
124
124
 
125
- def start_typing(channel_id:, thread_id: nil)
126
- record = RecordedCall.new(:start_typing, channel_id: channel_id, thread_id: thread_id)
125
+ def start_typing(channel_id:, thread_id: nil, message_id: nil)
126
+ record = RecordedCall.new(:start_typing, channel_id: channel_id, thread_id: thread_id, message_id: message_id)
127
127
  @typing_started << record
128
128
  record
129
129
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ChatSDK
4
4
  class Thread
5
+ include Adapter::Invocation
6
+
5
7
  attr_reader :id, :channel_id, :adapter, :chat, :current_message
6
8
 
7
9
  def initialize(id:, channel_id:, adapter:, chat:, current_message: nil)
@@ -25,6 +27,8 @@ module ChatSDK
25
27
  end
26
28
 
27
29
  def post(content)
30
+ return handle_stream(content.stream, content.to_stream_options) if content.is_a?(StreamingPlan)
31
+
28
32
  message = PostableMessage.from(content)
29
33
  adapter.post_message(channel_id: channel_id, message: message, thread_id: id)
30
34
  end
@@ -51,41 +55,54 @@ module ChatSDK
51
55
  adapter.mark_as_read(channel_id: channel_id, thread_id: id, message_id: message_id, message: target.is_a?(ChatSDK::Message) ? target : nil)
52
56
  end
53
57
 
58
+ def start_typing
59
+ adapter.start_typing(channel_id: channel_id, thread_id: id, message_id: current_message&.id)
60
+ end
61
+
54
62
  def post_ephemeral(content, user_id:)
55
63
  message = PostableMessage.from(content)
56
64
  adapter.post_ephemeral(channel_id: channel_id, user_id: user_id, message: message, thread_id: id)
57
65
  end
58
66
 
59
- def post_stream(placeholder: nil, &block)
60
- stream = Streaming::Stream.new(
61
- adapter: adapter,
62
- channel_id: channel_id,
63
- thread_id: id,
64
- placeholder: placeholder,
65
- update_interval: chat.config.streaming_update_interval
66
- )
67
- stream.run(&block)
67
+ def post_stream(source = nil, placeholder: nil, **plan_options, &block)
68
+ raise ArgumentError, "provide either a stream source or a block" if !source.nil? && block
69
+ raise ArgumentError, "a stream source or block is required" if source.nil? && !block
70
+
71
+ if block
72
+ source = Enumerator.new do |yielder|
73
+ block.call(Streaming::Writer.new(yielder))
74
+ end
75
+ end
76
+
77
+ if source.is_a?(StreamingPlan)
78
+ options = StreamingPlan.new(source.stream, **source.options.merge(plan_options)).to_stream_options
79
+ source = source.stream
80
+ else
81
+ options = StreamingPlan.new(source, **plan_options).to_stream_options
82
+ end
83
+
84
+ handle_stream(source, options.merge(placeholder: placeholder))
68
85
  end
69
86
 
70
87
  def edit(message_id, content)
71
88
  message = PostableMessage.from(content)
72
- adapter.edit_message(channel_id: channel_id, message_id: message_id, message: message)
89
+ call_adapter(:edit_message, channel_id: channel_id, message_id: message_id, message: message, thread_id: id)
73
90
  end
74
91
 
75
92
  def delete(message_id)
76
- adapter.delete_message(channel_id: channel_id, message_id: message_id)
93
+ call_adapter(:delete_message, channel_id: channel_id, message_id: message_id, thread_id: id)
77
94
  end
78
95
 
79
96
  def react(message_id, emoji)
80
- adapter.add_reaction(channel_id: channel_id, message_id: message_id, emoji: emoji)
97
+ call_adapter(:add_reaction, channel_id: channel_id, message_id: message_id, emoji: emoji, thread_id: id)
81
98
  end
82
99
 
83
100
  def unreact(message_id, emoji)
84
- adapter.remove_reaction(channel_id: channel_id, message_id: message_id, emoji: emoji)
101
+ call_adapter(:remove_reaction, channel_id: channel_id, message_id: message_id, emoji: emoji, thread_id: id)
85
102
  end
86
103
 
87
- def post_ai_stream(enumerable, placeholder: "Thinking...")
88
- ChatSDK::AI::StreamHandler.stream_to_thread(self, enumerable, placeholder: placeholder)
104
+ def post_ai_stream(enumerable, placeholder: "Thinking...", **plan_options)
105
+ ChatSDK::AI::StreamHandler.stream_to_thread(self, enumerable, placeholder: placeholder, **plan_options)
89
106
  end
90
107
 
91
108
  def upload(io:, filename:, comment: nil)
@@ -123,6 +140,60 @@ module ChatSDK
123
140
 
124
141
  private
125
142
 
143
+ def handle_stream(source, caller_options)
144
+ options = caller_options.merge(
145
+ update_interval: caller_options[:update_interval] || chat.config.streaming_update_interval
146
+ )
147
+ if current_message
148
+ options[:recipient_user_id] = current_message.author&.id
149
+ options[:recipient_team_id] = extract_recipient_team_id(current_message.raw)
150
+ end
151
+ options = options.reject { |_, value| value.nil? }
152
+ normalized_source = Enumerator.new do |yielder|
153
+ source.each do |item|
154
+ chunk = Streaming::Chunk.normalize(item)
155
+ yielder << chunk unless chunk.nil?
156
+ end
157
+ end
158
+
159
+ if adapter.respond_to?(:stream)
160
+ native_message = adapter.stream(
161
+ channel_id: channel_id,
162
+ thread_id: id,
163
+ stream: normalized_source,
164
+ options: options
165
+ )
166
+ return native_message if native_message
167
+ end
168
+
169
+ text_source = Enumerator.new do |yielder|
170
+ normalized_source.each do |item|
171
+ text = Streaming::Chunk.text_of(item)
172
+ yielder << text if text
173
+ end
174
+ end
175
+ Streaming::Stream.new(
176
+ adapter: adapter,
177
+ channel_id: channel_id,
178
+ thread_id: id,
179
+ placeholder: options[:placeholder],
180
+ update_interval: options[:update_interval],
181
+ ai_generated: options[:ai_generated] || false
182
+ ).run(text_source)
183
+ end
184
+
185
+ def extract_recipient_team_id(raw)
186
+ return unless raw.is_a?(Hash)
187
+
188
+ team_id = raw[:team_id] || raw["team_id"]
189
+ team = raw[:team] || raw["team"]
190
+ user = raw[:user] || raw["user"]
191
+ team_id ||= team if team.is_a?(String)
192
+ team_id ||= team[:id] || team["id"] if team.is_a?(Hash)
193
+ team_id ||= user[:team_id] || user["team_id"] if user.is_a?(Hash)
194
+ team_id if team_id.is_a?(String) && !team_id.empty?
195
+ end
196
+
126
197
  def state_key(suffix = nil)
127
198
  base = "#{adapter.name}:#{channel_id}:#{id}"
128
199
  suffix ? "#{base}:#{suffix}" : base
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChatSDK
4
- VERSION = "1.1.0"
4
+ VERSION = "1.3.0"
5
5
  end
@@ -12,6 +12,7 @@ module ChatSDK
12
12
  end
13
13
 
14
14
  def call(env)
15
+ current_event = nil
15
16
  request = Rack::Request.new(env) if defined?(Rack)
16
17
 
17
18
  adapter.verify_request!(request || env)
@@ -20,16 +21,58 @@ module ChatSDK
20
21
  return ack if ack
21
22
 
22
23
  events = adapter.parse_events(request || env)
23
- events.each { |event| chat.dispatch(event, adapter_name: adapter_name) }
24
+ events.each do |event|
25
+ current_event = event
26
+ chat.dispatch(event, adapter_name: adapter_name)
27
+ end
28
+
29
+ response = if webhook_response_accepts_request?
30
+ adapter.webhook_response(events, request || env)
31
+ else
32
+ adapter.webhook_response(events)
33
+ end
34
+ return response if response
24
35
 
25
36
  [200, {"content-type" => "text/plain"}, [""]]
26
37
  rescue ChatSDK::SignatureVerificationError => e
27
38
  ChatSDK::Log.warn("Signature verification failed: #{e.message}")
28
39
  [401, {"content-type" => "text/plain"}, ["Unauthorized"]]
40
+ rescue ChatSDK::HandlerExecutorError => e
41
+ chat.error_reporter.report(
42
+ e,
43
+ "Handler executor error",
44
+ **error_context(source: :executor, event: current_event)
45
+ )
46
+ # Platforms redeliver non-2xx responses; handlers did not run in this request, and workers deduplicate already-enqueued events.
47
+ [500, {"content-type" => "text/plain"}, [""]]
29
48
  rescue => e
30
- ChatSDK::Log.error("Webhook error: #{e.message}")
49
+ chat.error_reporter.report(
50
+ e,
51
+ "Webhook error",
52
+ **error_context(source: :webhook, event: current_event)
53
+ )
54
+ # Platforms retry non-2xx responses, which can repeat handler side effects.
55
+ # Logging and on_error surface failures without causing a redelivery.
31
56
  [200, {"content-type" => "text/plain"}, [""]]
32
57
  end
58
+
59
+ private
60
+
61
+ def webhook_response_accepts_request?
62
+ parameters = adapter.method(:webhook_response).parameters
63
+ parameters.any? { |type, _| type == :rest } ||
64
+ parameters.count { |type, _| %i[req opt].include?(type) } > 1
65
+ end
66
+
67
+ def error_context(source:, event:)
68
+ {
69
+ source: source,
70
+ adapter: adapter_name,
71
+ event_type: event&.type,
72
+ thread_id: event&.respond_to?(:thread_id) ? event.thread_id : nil,
73
+ channel_id: event&.respond_to?(:channel_id) ? event.channel_id : nil
74
+ }
75
+ end
33
76
  end
34
77
  end
35
78
  end
data/lib/chat_sdk.rb CHANGED
@@ -17,8 +17,8 @@ module ChatSDK
17
17
  end
18
18
  end
19
19
 
20
- def card(title: nil, subtitle: nil, width: nil, &block)
21
- Cards::Builder.new(title: title, subtitle: subtitle, width: width, &block).build
20
+ def card(title: nil, subtitle: nil, width: nil, image_url: nil, &block)
21
+ Cards::Builder.new(title: title, subtitle: subtitle, width: width, image_url: image_url, &block).build
22
22
  end
23
23
  end
24
24
  end