chat_sdk 1.2.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.
@@ -16,7 +16,7 @@ module ChatSDK
16
16
  data.merge!(
17
17
  "message_id" => event.message_id,
18
18
  "previous_message" => dump_message(event.previous_message),
19
- "deleted_at" => event.deleted_at&.iso8601
19
+ "deleted_at" => event.deleted_at&.iso8601(6)
20
20
  )
21
21
  when :reaction
22
22
  data.merge!("emoji" => event.emoji, "user_id" => event.user_id, "message_id" => event.message_id, "added" => event.added?)
@@ -33,6 +33,18 @@ module ChatSDK
33
33
  "action" => event.action,
34
34
  "user" => dump_author(event.user)
35
35
  )
36
+ when :assistant_thread_started, :assistant_context_changed
37
+ data.merge!(
38
+ "thread_ts" => event.thread_ts,
39
+ "user_id" => event.user_id,
40
+ "context" => event.context.to_h.transform_keys(&:to_s)
41
+ )
42
+ when :app_home_opened
43
+ data["user_id"] = event.user_id
44
+ data["tab"] = event.tab
45
+ when :member_joined_channel
46
+ data["user_id"] = event.user_id
47
+ data["inviter_id"] = event.inviter_id
36
48
  end
37
49
  data
38
50
  end
@@ -85,6 +97,24 @@ module ChatSDK
85
97
  user: load_author(data["user"]),
86
98
  **common
87
99
  )
100
+ when :assistant_thread_started, :assistant_context_changed
101
+ event_class = if type == :assistant_thread_started
102
+ Events::AssistantThreadStarted
103
+ else
104
+ Events::AssistantContextChanged
105
+ end
106
+ event_class.new(
107
+ thread_ts: data["thread_ts"],
108
+ user_id: data["user_id"],
109
+ context: data["context"] || {},
110
+ **common
111
+ )
112
+ when :app_home_opened
113
+ common.delete(:thread_id)
114
+ Events::AppHomeOpened.new(user_id: data["user_id"], tab: data["tab"], **common)
115
+ when :member_joined_channel
116
+ common.delete(:thread_id)
117
+ Events::MemberJoinedChannel.new(user_id: data["user_id"], inviter_id: data["inviter_id"], **common)
88
118
  else
89
119
  raise ArgumentError, "unsupported queued event type: #{type}"
90
120
  end
@@ -100,7 +130,7 @@ module ChatSDK
100
130
  "thread_id" => event.respond_to?(:thread_id) ? event.thread_id : nil,
101
131
  "channel_id" => event.respond_to?(:channel_id) ? event.channel_id : nil,
102
132
  "raw" => json_safe(event.raw),
103
- "timestamp" => event.timestamp&.iso8601
133
+ "timestamp" => event.timestamp&.iso8601(6)
104
134
  }
105
135
  end
106
136
 
@@ -119,7 +149,7 @@ module ChatSDK
119
149
  "reply_to" => dump_message(message.reply_to),
120
150
  "subject" => json_safe(message.subject),
121
151
  "raw" => json_safe(message.raw),
122
- "timestamp" => message.timestamp&.iso8601,
152
+ "timestamp" => message.timestamp&.iso8601(6),
123
153
  "is_mention" => message.is_mention
124
154
  }
125
155
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Events
5
+ class AppHomeOpened < Base
6
+ attr_reader :user_id, :channel_id, :tab
7
+
8
+ def initialize(user_id:, channel_id:, tab: nil, **kwargs)
9
+ super(type: :app_home_opened, **kwargs)
10
+ @user_id = user_id
11
+ @channel_id = channel_id
12
+ @tab = tab
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Events
5
+ # rubocop:disable Style/RedundantStructKeywordInit
6
+ AssistantContext = Struct.new(
7
+ :channel_id,
8
+ :team_id,
9
+ :enterprise_id,
10
+ :thread_entry_point,
11
+ :force_search,
12
+ keyword_init: true
13
+ )
14
+ # rubocop:enable Style/RedundantStructKeywordInit
15
+
16
+ def AssistantContext.coerce(value)
17
+ return value if value.is_a?(self)
18
+
19
+ value = {} if value.nil?
20
+ return value unless value.is_a?(Hash)
21
+
22
+ new(**value.transform_keys(&:to_sym).slice(*members))
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Events
5
+ class AssistantContextChanged < Base
6
+ attr_reader :thread_id, :channel_id, :thread_ts, :user_id, :context
7
+
8
+ def initialize(thread_id:, channel_id:, thread_ts:, user_id:, context: {}, **kwargs)
9
+ super(type: :assistant_context_changed, **kwargs)
10
+ @thread_id = thread_id
11
+ @channel_id = channel_id
12
+ @thread_ts = thread_ts
13
+ @user_id = user_id
14
+ @context = AssistantContext.coerce(context)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Events
5
+ class AssistantThreadStarted < Base
6
+ attr_reader :thread_id, :channel_id, :thread_ts, :user_id, :context
7
+
8
+ def initialize(thread_id:, channel_id:, thread_ts:, user_id:, context: {}, **kwargs)
9
+ super(type: :assistant_thread_started, **kwargs)
10
+ @thread_id = thread_id
11
+ @channel_id = channel_id
12
+ @thread_ts = thread_ts
13
+ @user_id = user_id
14
+ @context = AssistantContext.coerce(context)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Events
5
+ class MemberJoinedChannel < Base
6
+ attr_reader :user_id, :channel_id, :inviter_id
7
+
8
+ def initialize(user_id:, channel_id:, inviter_id: nil, **kwargs)
9
+ super(type: :member_joined_channel, **kwargs)
10
+ @user_id = user_id
11
+ @channel_id = channel_id
12
+ @inviter_id = inviter_id
13
+ end
14
+ end
15
+ end
16
+ end
@@ -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
@@ -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
@@ -7,37 +7,70 @@ module ChatSDK
7
7
 
8
8
  attr_reader :adapter, :channel_id, :thread_id, :update_interval
9
9
 
10
- 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)
11
11
  @adapter = adapter
12
12
  @channel_id = channel_id
13
13
  @thread_id = thread_id
14
14
  @placeholder = placeholder
15
15
  @update_interval = update_interval
16
+ @ai_generated = ai_generated
16
17
  @buffer = +""
18
+ @delivered_text = +""
17
19
  @message_id = nil
20
+ @message = nil
18
21
  @last_flush_at = nil
19
22
  end
20
23
 
21
24
  def <<(chunk)
22
- @buffer << chunk.to_s
25
+ text = Chunk.text_of(Chunk.normalize(chunk))
26
+ return self unless text
27
+
28
+ @buffer << text
23
29
  maybe_flush
24
30
  self
25
31
  end
26
32
 
27
- def run(&block)
33
+ def run(source = nil, &block)
28
34
  post_placeholder if @placeholder
29
- yield self
35
+ if source
36
+ source.each { |chunk| self << chunk }
37
+ elsif block
38
+ block.call(self)
39
+ end
30
40
  flush
41
+ message_with_text(@message, @delivered_text)
31
42
  end
32
43
 
33
44
  private
34
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
+
35
66
  def post_placeholder
36
67
  result = adapter.post_message(
37
68
  channel_id: channel_id,
38
- message: PostableMessage.new(text: @placeholder),
69
+ message: PostableMessage.new(text: @placeholder, ai_generated: @ai_generated),
39
70
  thread_id: thread_id
40
71
  )
72
+ @delivered_text = @placeholder.dup
73
+ @message = result if result.is_a?(ChatSDK::Message)
41
74
  @message_id = result.id if result.respond_to?(:id)
42
75
  end
43
76
 
@@ -56,16 +89,19 @@ module ChatSDK
56
89
  :edit_message,
57
90
  channel_id: channel_id,
58
91
  message_id: @message_id,
59
- message: PostableMessage.new(text: @buffer.dup),
92
+ message: PostableMessage.new(text: @buffer.dup, ai_generated: @ai_generated),
60
93
  thread_id: thread_id
61
94
  )
95
+ @delivered_text = @buffer.dup
62
96
  elsif !@message_id
63
97
  result = adapter.post_message(
64
98
  channel_id: channel_id,
65
- message: PostableMessage.new(text: @buffer.dup),
99
+ message: PostableMessage.new(text: @buffer.dup, ai_generated: @ai_generated),
66
100
  thread_id: thread_id
67
101
  )
102
+ @message = result if result.is_a?(ChatSDK::Message)
68
103
  @message_id = result.id if result.respond_to?(:id)
104
+ @delivered_text = @buffer.dup
69
105
  end
70
106
 
71
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
@@ -27,6 +27,8 @@ module ChatSDK
27
27
  end
28
28
 
29
29
  def post(content)
30
+ return handle_stream(content.stream, content.to_stream_options) if content.is_a?(StreamingPlan)
31
+
30
32
  message = PostableMessage.from(content)
31
33
  adapter.post_message(channel_id: channel_id, message: message, thread_id: id)
32
34
  end
@@ -62,15 +64,24 @@ module ChatSDK
62
64
  adapter.post_ephemeral(channel_id: channel_id, user_id: user_id, message: message, thread_id: id)
63
65
  end
64
66
 
65
- def post_stream(placeholder: nil, &block)
66
- stream = Streaming::Stream.new(
67
- adapter: adapter,
68
- channel_id: channel_id,
69
- thread_id: id,
70
- placeholder: placeholder,
71
- update_interval: chat.config.streaming_update_interval
72
- )
73
- 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))
74
85
  end
75
86
 
76
87
  def edit(message_id, content)
@@ -90,8 +101,8 @@ module ChatSDK
90
101
  call_adapter(:remove_reaction, channel_id: channel_id, message_id: message_id, emoji: emoji, thread_id: id)
91
102
  end
92
103
 
93
- def post_ai_stream(enumerable, placeholder: "Thinking...")
94
- 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)
95
106
  end
96
107
 
97
108
  def upload(io:, filename:, comment: nil)
@@ -129,6 +140,60 @@ module ChatSDK
129
140
 
130
141
  private
131
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
+
132
197
  def state_key(suffix = nil)
133
198
  base = "#{adapter.name}:#{channel_id}:#{id}"
134
199
  suffix ? "#{base}:#{suffix}" : base
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChatSDK
4
- VERSION = "1.2.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,19 +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
24
28
 
25
- response = adapter.webhook_response(events)
29
+ response = if webhook_response_accepts_request?
30
+ adapter.webhook_response(events, request || env)
31
+ else
32
+ adapter.webhook_response(events)
33
+ end
26
34
  return response if response
27
35
 
28
36
  [200, {"content-type" => "text/plain"}, [""]]
29
37
  rescue ChatSDK::SignatureVerificationError => e
30
38
  ChatSDK::Log.warn("Signature verification failed: #{e.message}")
31
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"}, [""]]
32
48
  rescue => e
33
- 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.
34
56
  [200, {"content-type" => "text/plain"}, [""]]
35
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
36
76
  end
37
77
  end
38
78
  end