chat_sdk 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc45b8149b772db87ae0793f070a3f068353ef09a177d59c3526eb0ec37d1fb9
4
- data.tar.gz: 16eb68d587eb53a43337f22dd2cd7d5b564383d892ecda9b64b77b5ae45a05a0
3
+ metadata.gz: bf7b7fc21483f760afad8e767e6351398c2af9908b93b7bfbc696a4f8a6516d4
4
+ data.tar.gz: 414e6999b35971ca8dc4b038db62aa7956ee2f69768d7e3a7143d4ecd131d4e5
5
5
  SHA512:
6
- metadata.gz: 7a6cb0121846bf8a75b849f0d6bedfc48601c738dba99c3ad838ecdcb3b31fd7b4a27cd16bfa75cee66a4f70e62b62ae1c4730546b22f430e72d1af1bf6219c2
7
- data.tar.gz: 602d33c5bba332dac427d61e8566c396f10c057e6b17a3422d1ab4846e4d7e8857ece123b50d2d357e310072f119bee00436db593e6138e46aabc34ba65f777c
6
+ metadata.gz: 845e936413e0fa1addf62b99dcd13800b8d7ce41a6b9fa9b4a9c7ff3386258262bf5f251f58d2e691fd1378ce7828393b95567dd88fc61a59eda283701eb6f0f
7
+ data.tar.gz: aa694fade90c608eac97751710b65263a43d07f581c870ae3c70d8159d1a0f0b20a1a4d97d87d3c3948ed5f27647d7383aa2cf004a054af0dd045b32fc2fd18a
@@ -22,7 +22,7 @@ module ChatSDK
22
22
  nil
23
23
  end
24
24
 
25
- def webhook_response(_events)
25
+ def webhook_response(_events, _request = nil)
26
26
  nil
27
27
  end
28
28
 
@@ -35,6 +35,12 @@ module ChatSDK
35
35
  raise NotImplementedError
36
36
  end
37
37
 
38
+ # Stream using a platform-native API, or return nil before consuming
39
+ # chunks to delegate to the core post+edit fallback.
40
+ def stream(channel_id:, thread_id:, stream:, options: {})
41
+ nil
42
+ end
43
+
38
44
  def reply_message(channel_id:, message_id:, message:, thread_id: nil)
39
45
  require_capability!(:replies)
40
46
  raise NotImplementedError
@@ -3,9 +3,9 @@
3
3
  module ChatSDK
4
4
  module AI
5
5
  class StreamHandler
6
- def self.stream_to_thread(thread, enumerable, placeholder: "Thinking...")
7
- thread.post_stream(placeholder: placeholder) do |stream|
8
- enumerable.each { |chunk| stream << chunk.to_s }
6
+ def self.stream_to_thread(thread, enumerable, placeholder: "Thinking...", **plan_options)
7
+ thread.post_stream(placeholder: placeholder, **plan_options) do |stream|
8
+ enumerable.each { |chunk| stream << chunk }
9
9
  end
10
10
  end
11
11
  end
@@ -9,30 +9,50 @@ module ChatSDK
9
9
  @nodes = []
10
10
  end
11
11
 
12
- def button(text, id:, style: nil, value: nil, tooltip: nil, visible_to: nil)
12
+ def button(text, id:, style: nil, value: nil, tooltip: nil, visible_to: nil, disabled: nil)
13
13
  attrs = {text: text, id: id}
14
14
  attrs[:style] = style if style
15
15
  attrs[:value] = value if value
16
16
  attrs[:tooltip] = tooltip if tooltip
17
17
  attrs[:visible_to] = visible_to if visible_to
18
+ attrs[:disabled] = disabled unless disabled.nil?
18
19
  @nodes << Node.new(:button, attributes: attrs)
19
20
  end
20
21
 
21
- def link_button(text, url:, id: nil, tooltip: nil, visible_to: nil)
22
+ def link_button(text, url:, id: nil, style: nil, tooltip: nil, visible_to: nil)
22
23
  attrs = {text: text, url: url}
23
24
  attrs[:id] = id if id
25
+ attrs[:style] = style if style
24
26
  attrs[:tooltip] = tooltip if tooltip
25
27
  attrs[:visible_to] = visible_to if visible_to
26
28
  @nodes << Node.new(:link_button, attributes: attrs)
27
29
  end
28
30
 
29
- def select(id:, placeholder: nil, &block)
31
+ def select(id:, placeholder: nil, label: nil, initial_option: nil, optional: nil, dispatch_action: nil, &block)
30
32
  ctx = SelectContext.new
31
- 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
+
32
36
  attrs = {id: id}
33
37
  attrs[:placeholder] = placeholder if placeholder
38
+ attrs[:label] = label unless label.nil?
39
+ attrs[:initial_option] = initial_option unless initial_option.nil?
40
+ attrs[:optional] = optional unless optional.nil?
41
+ attrs[:dispatch_action] = dispatch_action unless dispatch_action.nil?
34
42
  @nodes << Node.new(:select, attributes: attrs, children: ctx.nodes)
35
43
  end
44
+
45
+ def radio_select(id:, label:, initial_option: nil, optional: nil, dispatch_action: nil, &block)
46
+ ctx = SelectContext.new
47
+ ctx.instance_eval(&block) if block
48
+ raise ArgumentError, "RadioSelect requires at least one option" if ctx.nodes.empty?
49
+
50
+ attrs = {id: id, label: label}
51
+ attrs[:initial_option] = initial_option unless initial_option.nil?
52
+ attrs[:optional] = optional unless optional.nil?
53
+ attrs[:dispatch_action] = dispatch_action unless dispatch_action.nil?
54
+ @nodes << Node.new(:radio_select, attributes: attrs, children: ctx.nodes)
55
+ end
36
56
  end
37
57
  end
38
58
  end
@@ -5,10 +5,11 @@ module ChatSDK
5
5
  class Builder
6
6
  RESERVED_TEXT_INPUT_IDS = %w[actionId value msteams].freeze
7
7
 
8
- def initialize(title: nil, subtitle: nil, width: nil, &block)
8
+ def initialize(title: nil, subtitle: nil, width: nil, image_url: nil, &block)
9
9
  @title = title
10
10
  @subtitle = subtitle
11
11
  @width = width
12
+ @image_url = image_url
12
13
  @children = []
13
14
  instance_eval(&block) if block
14
15
  end
@@ -18,11 +19,18 @@ module ChatSDK
18
19
  attrs[:title] = @title if @title
19
20
  attrs[:subtitle] = @subtitle if @subtitle
20
21
  attrs[:width] = @width if @width
22
+ attrs[:image_url] = @image_url if @image_url
21
23
  Node.new(:card, attributes: attrs, children: @children)
22
24
  end
23
25
 
24
- def text(content)
25
- @children << Node.new(:text, attributes: {content: content})
26
+ def text(content, style: nil)
27
+ attrs = {content: content}
28
+ attrs[:style] = style unless style.nil?
29
+ @children << Node.new(:text, attributes: attrs)
30
+ end
31
+
32
+ def link(url:, label:)
33
+ @children << Node.new(:link, attributes: {url: url, label: label})
26
34
  end
27
35
 
28
36
  def divider
@@ -33,7 +41,7 @@ module ChatSDK
33
41
  @children << Node.new(:image, attributes: {url: url, alt: alt})
34
42
  end
35
43
 
36
- def text_input(id:, label:, placeholder: nil, multiline: false, optional: false, value: nil)
44
+ def text_input(id:, label:, placeholder: nil, multiline: false, optional: false, value: nil, max_length: nil)
37
45
  if RESERVED_TEXT_INPUT_IDS.include?(id.to_s)
38
46
  raise ArgumentError, "Text input id #{id.inspect} is reserved"
39
47
  end
@@ -41,6 +49,7 @@ module ChatSDK
41
49
  attrs = {id: id, label: label, multiline: multiline, optional: optional}
42
50
  attrs[:placeholder] = placeholder if placeholder
43
51
  attrs[:value] = value unless value.nil?
52
+ attrs[:max_length] = max_length unless max_length.nil?
44
53
  @children << Node.new(:text_input, attributes: attrs)
45
54
  end
46
55
 
@@ -27,6 +27,8 @@ module ChatSDK
27
27
  case node.type
28
28
  when :text
29
29
  [node.attributes[:content]]
30
+ when :link
31
+ ["#{node.attributes[:label]} (#{node.attributes[:url]})"]
30
32
  when :field
31
33
  ["#{node.attributes[:label]}: #{node.attributes[:value]}"]
32
34
  when :button, :link_button
@@ -9,6 +9,7 @@ module ChatSDK
9
9
  when :text then node.attributes[:content]
10
10
  when :divider then "---"
11
11
  when :image then "![#{node.attributes[:alt]}](#{node.attributes[:url]})"
12
+ when :link then "[#{node.attributes[:label]}](#{node.attributes[:url]})"
12
13
  when :fields then render_fields(node)
13
14
  when :field then "**#{node.attributes[:label]}**: #{node.attributes[:value]}"
14
15
  when :section then render_section(node)
@@ -17,6 +18,7 @@ module ChatSDK
17
18
  when :link_button then "[#{node.attributes[:text]}](#{node.attributes[:url]})"
18
19
  when :text_input then node.attributes[:label]
19
20
  when :select then "_#{node.attributes[:placeholder] || "Select"}_"
21
+ when :radio_select then "_#{node.attributes[:label] || "Select"}_"
20
22
  when :table then render_table(node)
21
23
  when :chart then render_chart(node)
22
24
  else ""
data/lib/chat_sdk/chat.rb CHANGED
@@ -3,18 +3,27 @@
3
3
  module ChatSDK
4
4
  class Chat
5
5
  attr_reader :config, :state, :history
6
+ # @api private
7
+ attr_reader :error_reporter
6
8
 
7
9
  def initialize(user_name:, adapters:, state:, **options)
8
10
  @config = Config.new(user_name: user_name, adapters: adapters, state: state, **options)
9
11
  @state = state
12
+ ChatSDK::Log.logger = @config.logger if @config.logger
13
+ ChatSDK::Log.level = @config.log_level if @config.log_level
10
14
  @adapters = adapters
11
15
  @adapters.each_value { |adapter| adapter.set_state(state) if adapter.respond_to?(:set_state) }
12
16
  @registry = EventRegistry.new
13
17
  @history = History.new(self)
14
18
  @webhooks = {}
15
- @dispatcher = Dispatcher.new(chat: self, config: @config, state: @state, registry: @registry)
16
-
17
- ChatSDK::Log.level = @config.log_level
19
+ @error_reporter = ErrorReporter.new(@config.on_error)
20
+ @dispatcher = Dispatcher.new(
21
+ chat: self,
22
+ config: @config,
23
+ state: @state,
24
+ registry: @registry,
25
+ error_reporter: @error_reporter
26
+ )
18
27
  end
19
28
 
20
29
  # Event registration
@@ -67,6 +76,22 @@ module ChatSDK
67
76
  @registry.register(:uninstalled, &block)
68
77
  end
69
78
 
79
+ def on_assistant_thread_started(&block)
80
+ @registry.register(:assistant_thread_started, &block)
81
+ end
82
+
83
+ def on_assistant_context_changed(&block)
84
+ @registry.register(:assistant_context_changed, &block)
85
+ end
86
+
87
+ def on_app_home_opened(&block)
88
+ @registry.register(:app_home_opened, &block)
89
+ end
90
+
91
+ def on_member_joined_channel(&block)
92
+ @registry.register(:member_joined_channel, &block)
93
+ end
94
+
70
95
  # Adapter access
71
96
  def adapter(name)
72
97
  @adapters.fetch(name) { raise ChatSDK::ConfigurationError, "Unknown adapter: #{name}" }
@@ -97,7 +122,28 @@ module ChatSDK
97
122
  # Event dispatch (called by webhook endpoints)
98
123
  def dispatch(event, adapter_name:)
99
124
  adp = adapter(adapter_name)
100
- @dispatcher.dispatch(event, adapter: adp, adapter_name: adapter_name)
125
+ executor = @config.handler_executor
126
+ return @dispatcher.dispatch(event, adapter: adp, adapter_name: adapter_name) if executor == :inline
127
+
128
+ if executor == :thread
129
+ ::Thread.new { run_background_dispatch(event, adp, adapter_name) }
130
+ else
131
+ payload = {"adapter_name" => adapter_name.to_s, "event" => EventSerializer.dump(event)}
132
+ begin
133
+ executor.call(payload)
134
+ rescue => e
135
+ raise HandlerExecutorError, "handler_executor failed for #{event.type}: #{e.class}: #{e.message}"
136
+ end
137
+ end
138
+ nil
139
+ end
140
+
141
+ # Runs a payload produced for a callable handler_executor, such as from a background job.
142
+ def dispatch_serialized(payload)
143
+ payload = payload.transform_keys(&:to_s)
144
+ adapter_name = payload.fetch("adapter_name").to_sym
145
+ event = EventSerializer.load(payload.fetch("event"))
146
+ @dispatcher.dispatch(event, adapter: adapter(adapter_name), adapter_name: adapter_name)
101
147
  end
102
148
 
103
149
  # Deprecated compatibility alias for Vercel Chat SDK's former API.
@@ -106,6 +152,21 @@ module ChatSDK
106
152
  end
107
153
 
108
154
  private
155
+
156
+ def run_background_dispatch(event, adp, adapter_name)
157
+ @dispatcher.dispatch(event, adapter: adp, adapter_name: adapter_name)
158
+ rescue => e
159
+ # Nothing above this background thread can rescue errors raised during dispatch.
160
+ @error_reporter.report(
161
+ e,
162
+ "Background dispatch error (#{event.type})",
163
+ source: :dispatch,
164
+ adapter: adapter_name,
165
+ event_type: event.type,
166
+ thread_id: event.respond_to?(:thread_id) ? event.thread_id : nil,
167
+ channel_id: event.respond_to?(:channel_id) ? event.channel_id : nil
168
+ )
169
+ end
109
170
  end
110
171
 
111
172
  class WebhookAccessor
@@ -15,7 +15,7 @@ module ChatSDK
15
15
 
16
16
  attr_reader :user_name, :adapters, :state, :on_lock_conflict,
17
17
  :dedupe_ttl, :streaming_update_interval, :handler_executor, :log_level,
18
- :history_user, :concurrency
18
+ :history_user, :concurrency, :logger, :on_error
19
19
 
20
20
  def initialize(user_name:, adapters:, state:, **options)
21
21
  raise ConfigurationError, "user_name is required" if user_name.nil? || user_name.empty?
@@ -26,6 +26,8 @@ module ChatSDK
26
26
  @adapters = adapters
27
27
  @state = state
28
28
  merged = DEFAULTS.merge(options)
29
+ @logger = merged[:logger]
30
+ @on_error = merged[:on_error]
29
31
  @on_lock_conflict = merged[:on_lock_conflict]
30
32
  @concurrency = normalize_concurrency(merged[:concurrency], lock_scope: merged[:lock_scope])
31
33
  @dedupe_ttl = merged[:dedupe_ttl]
@@ -38,13 +40,43 @@ module ChatSDK
38
40
  retention: user_history.fetch(:retention, user_history.fetch("retention", 30 * 24 * 3600)),
39
41
  max_per_user: user_history.fetch(:max_per_user, user_history.fetch("max_per_user", 200))
40
42
  }
41
- @log_level = merged[:log_level]
43
+ @log_level = if !@logger.nil? && !options.key?(:log_level)
44
+ nil
45
+ else
46
+ merged[:log_level]
47
+ end
42
48
 
49
+ validate_handler_executor!
50
+ validate_logger!
51
+ validate_on_error!
43
52
  validate_lock_conflict!
44
53
  end
45
54
 
46
55
  private
47
56
 
57
+ def validate_handler_executor!
58
+ return if %i[inline thread].include?(@handler_executor) || @handler_executor.respond_to?(:call)
59
+
60
+ raise ConfigurationError, "handler_executor must be :inline, :thread, or a callable"
61
+ end
62
+
63
+ def validate_logger!
64
+ return if @logger.nil?
65
+ unless %i[debug info warn error].all? { |level| @logger.respond_to?(level) }
66
+ raise ConfigurationError, "logger must respond to debug, info, warn, and error"
67
+ end
68
+
69
+ if !@log_level.nil? && !@logger.respond_to?(:level=)
70
+ raise ConfigurationError, "logger must respond to level= when log_level is set"
71
+ end
72
+ end
73
+
74
+ def validate_on_error!
75
+ return if @on_error.nil? || @on_error.respond_to?(:call)
76
+
77
+ raise ConfigurationError, "on_error must be a callable"
78
+ end
79
+
48
80
  def validate_lock_conflict!
49
81
  return if %i[drop force].include?(@on_lock_conflict) || @on_lock_conflict.respond_to?(:call)
50
82
  raise ConfigurationError, "on_lock_conflict must be :drop, :force, or a callable"
@@ -5,17 +5,25 @@ require "time"
5
5
 
6
6
  module ChatSDK
7
7
  class Dispatcher
8
+ LIFECYCLE_EVENT_TYPES = %i[
9
+ assistant_thread_started
10
+ assistant_context_changed
11
+ app_home_opened
12
+ member_joined_channel
13
+ ].freeze
14
+
8
15
  LockHeartbeat = Struct.new(:thread, :mutex, :condition, :stop, :ownership_lost) do
9
16
  def ownership_lost?
10
17
  mutex.synchronize { ownership_lost }
11
18
  end
12
19
  end
13
20
 
14
- def initialize(chat:, config:, state:, registry:)
21
+ def initialize(chat:, config:, state:, registry:, error_reporter:)
15
22
  @chat = chat
16
23
  @config = config
17
24
  @state = state
18
25
  @registry = registry
26
+ @error_reporter = error_reporter
19
27
  @slot_mutex = Mutex.new
20
28
  @slot_condition = ConditionVariable.new
21
29
  @active_slots = Hash.new(0)
@@ -24,7 +32,13 @@ module ChatSDK
24
32
  def dispatch(event, adapter:, adapter_name:)
25
33
  ChatSDK::Instrumentation.instrument("dispatch.chat_sdk", adapter: adapter_name, event_type: event.type) do
26
34
  if %i[message_updated message_deleted].include?(event.type)
27
- return execute_event(event, build_thread(event, adapter))
35
+ return execute_event(event, build_thread(event, adapter), adapter_name: adapter_name)
36
+ end
37
+
38
+ if LIFECYCLE_EVENT_TYPES.include?(event.type)
39
+ return unless dedupe(event, adapter_name, adapter.concurrency_scope(event))
40
+
41
+ return execute_event(event, build_thread(event, adapter), adapter_name: adapter_name)
28
42
  end
29
43
 
30
44
  concurrency_scope = adapter.concurrency_scope(event)
@@ -35,32 +49,32 @@ module ChatSDK
35
49
  strategy = @config.concurrency[:strategy]
36
50
 
37
51
  if strategy == :concurrent
38
- return with_concurrent_slot(thread_key) { execute_event(event, thread) }
52
+ return with_concurrent_slot(thread_key) { execute_event(event, thread, adapter_name: adapter_name) }
39
53
  end
40
54
 
41
55
  owner = "#{Process.pid}:#{::Thread.current.object_id}:#{SecureRandom.uuid}"
42
56
  if %i[queue burst debounce].include?(strategy)
43
- dispatch_queued(event, adapter, thread_key, owner, strategy)
57
+ dispatch_queued(event, adapter, adapter_name, thread_key, owner, strategy)
44
58
  else
45
- dispatch_locked(event, thread, thread_key, owner, strategy)
59
+ dispatch_locked(event, thread, adapter_name, thread_key, owner, strategy)
46
60
  end
47
61
  end
48
62
  end
49
63
 
50
64
  private
51
65
 
52
- def dispatch_locked(event, thread, thread_key, owner, strategy)
66
+ def dispatch_locked(event, thread, adapter_name, thread_key, owner, strategy)
53
67
  acquired = acquire_lock(thread_key, event, owner, force: strategy == :force)
54
68
  return unless acquired
55
69
 
56
70
  heartbeat = start_lock_heartbeat(thread_key, owner)
57
- execute_event(event, thread)
71
+ execute_event(event, thread, adapter_name: adapter_name)
58
72
  ensure
59
73
  stop_lock_heartbeat(heartbeat)
60
74
  release_lock(thread_key, owner) if acquired
61
75
  end
62
76
 
63
- def dispatch_queued(event, adapter, thread_key, owner, strategy)
77
+ def dispatch_queued(event, adapter, adapter_name, thread_key, owner, strategy)
64
78
  queue_key = "chat_sdk:queue:#{thread_key}"
65
79
  acquired = acquire_lock(thread_key, event, owner, apply_conflict_policy: false)
66
80
  pre_enqueued = false
@@ -74,23 +88,23 @@ module ChatSDK
74
88
 
75
89
  heartbeat = start_lock_heartbeat(thread_key, owner)
76
90
  if strategy == :queue
77
- execute_event(event, build_thread(event, adapter)) unless pre_enqueued
91
+ execute_event(event, build_thread(event, adapter), adapter_name: adapter_name) unless pre_enqueued
78
92
  else
79
93
  enqueue(queue_key, event) unless pre_enqueued
80
94
  sleep(@config.concurrency[:debounce].to_f) if strategy == :burst
81
95
  end
82
96
 
83
97
  if strategy == :debounce
84
- debounce_loop(queue_key, adapter, heartbeat)
98
+ debounce_loop(queue_key, adapter, adapter_name, heartbeat)
85
99
  else
86
- drain_loop(queue_key, adapter, strategy, heartbeat)
100
+ drain_loop(queue_key, adapter, adapter_name, strategy, heartbeat)
87
101
  end
88
102
  ensure
89
103
  stop_lock_heartbeat(heartbeat)
90
104
  release_lock(thread_key, owner) if acquired
91
105
  end
92
106
 
93
- def drain_loop(queue_key, adapter, strategy, heartbeat)
107
+ def drain_loop(queue_key, adapter, adapter_name, strategy, heartbeat)
94
108
  loop do
95
109
  break if heartbeat&.ownership_lost?
96
110
 
@@ -105,12 +119,12 @@ module ChatSDK
105
119
  events[0...-1].filter_map { |queued| queued.message if queued.respond_to?(:message) }
106
120
  end
107
121
  context = {skipped: skipped, total_since_last_handler: skipped.length + 1}
108
- execute_event(current, build_thread(current, adapter), context: context)
122
+ execute_event(current, build_thread(current, adapter), adapter_name: adapter_name, context: context)
109
123
  ::Thread.pass
110
124
  end
111
125
  end
112
126
 
113
- def debounce_loop(queue_key, adapter, heartbeat)
127
+ def debounce_loop(queue_key, adapter, adapter_name, heartbeat)
114
128
  skipped = []
115
129
  loop do
116
130
  sleep(@config.concurrency[:debounce].to_f)
@@ -132,7 +146,7 @@ module ChatSDK
132
146
  message.thread_id == latest.thread_id
133
147
  end
134
148
  context = {skipped: relevant, total_since_last_handler: relevant.length + 1}
135
- execute_event(latest, build_thread(latest, adapter), context: context)
149
+ execute_event(latest, build_thread(latest, adapter), adapter_name: adapter_name, context: context)
136
150
  skipped.clear
137
151
  end
138
152
  end
@@ -262,29 +276,29 @@ module ChatSDK
262
276
  ChatSDK::Thread.new(id: thread_id, channel_id: channel_id, adapter: adapter, chat: @chat, current_message: current_message)
263
277
  end
264
278
 
265
- def execute_event(event, thread, context: nil)
279
+ def execute_event(event, thread, adapter_name:, context: nil)
266
280
  if %i[mention subscribed_message direct_message].include?(event.type)
267
- route_message_event(event, thread, context)
281
+ route_message_event(event, thread, adapter_name, context)
268
282
  return
269
283
  end
270
284
 
271
285
  @registry.handlers_for(event).each do |handler|
272
- execute_handler(handler, event, thread, context: context)
286
+ execute_handler(handler, event, thread, adapter_name: adapter_name, context: context)
273
287
  end
274
288
  end
275
289
 
276
- def route_message_event(event, thread, context)
290
+ def route_message_event(event, thread, adapter_name, context)
277
291
  message = event.message
278
292
  dm_handlers = @registry.handlers_for_type(:direct_message, event: event)
279
293
  if event.type == :direct_message && dm_handlers.any?
280
- run_handlers(dm_handlers, event, thread, context)
294
+ run_handlers(dm_handlers, event, thread, adapter_name, context)
281
295
  return
282
296
  end
283
297
 
284
298
  message.is_mention = true if event.type == :direct_message
285
299
 
286
300
  if thread&.subscribed?
287
- run_handlers(@registry.handlers_for_type(:subscribed_message, event: event), event, thread, context)
301
+ run_handlers(@registry.handlers_for_type(:subscribed_message, event: event), event, thread, adapter_name, context)
288
302
  return
289
303
  end
290
304
 
@@ -297,17 +311,19 @@ module ChatSDK
297
311
  end
298
312
 
299
313
  if message.is_mention
300
- run_handlers(@registry.handlers_for_type(:mention, event: event), event, thread, context)
314
+ run_handlers(@registry.handlers_for_type(:mention, event: event), event, thread, adapter_name, context)
301
315
  return
302
316
  end
303
317
 
304
318
  @registry.handlers_for_type(:new_message, event: event).each do |handler|
305
- execute_handler(handler, event, thread, context: context)
319
+ execute_handler(handler, event, thread, adapter_name: adapter_name, context: context)
306
320
  end
307
321
  end
308
322
 
309
- def run_handlers(handlers, event, thread, context)
310
- handlers.each { |handler| execute_handler(handler, event, thread, context: context) }
323
+ def run_handlers(handlers, event, thread, adapter_name, context)
324
+ handlers.each do |handler|
325
+ execute_handler(handler, event, thread, adapter_name: adapter_name, context: context)
326
+ end
311
327
  end
312
328
 
313
329
  def detect_mention(text)
@@ -315,7 +331,7 @@ module ChatSDK
315
331
  /(?<![[:word:]])@#{Regexp.escape(@config.user_name)}\b/i.match?(masked_text)
316
332
  end
317
333
 
318
- def execute_handler(handler, event, thread, context: nil)
334
+ def execute_handler(handler, event, thread, adapter_name:, context: nil)
319
335
  ChatSDK::Instrumentation.instrument("handler.chat_sdk", handler_type: event.type) do
320
336
  ChatSDK::AI::ConversationScope.with(thread) do
321
337
  case event.type
@@ -326,12 +342,21 @@ module ChatSDK
326
342
  when :reaction, :action, :slash_command, :message_deleted, :installed, :uninstalled
327
343
  add_thread_to_event(event, thread)
328
344
  handler.block.call(event)
345
+ when *LIFECYCLE_EVENT_TYPES
346
+ handler.block.call(event)
329
347
  end
330
348
  end
331
349
  end
332
350
  rescue => e
333
- ChatSDK::Log.error("Handler error (#{event.type}): #{e.message}")
334
- ChatSDK::Log.debug(e.backtrace&.first(5)&.join("\n"))
351
+ @error_reporter.report(
352
+ e,
353
+ "Handler error (#{event.type})",
354
+ source: :handler,
355
+ adapter: adapter_name,
356
+ event_type: event.type,
357
+ thread_id: event.respond_to?(:thread_id) ? event.thread_id : nil,
358
+ channel_id: event.respond_to?(:channel_id) ? event.channel_id : nil
359
+ )
335
360
  end
336
361
 
337
362
  def add_thread_to_event(event, thread)
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ class ErrorReporter
5
+ def initialize(callback = nil)
6
+ @callback = callback
7
+ end
8
+
9
+ def report(error, message, **context)
10
+ context = context.compact
11
+ ChatSDK::Log.error(format_entry(error, message, context))
12
+ reporter = @callback || self.class.default_callback
13
+ reporter&.call(error, context)
14
+ rescue => e
15
+ ChatSDK::Log.error("on_error callback failed: #{e.class}: #{e.message}")
16
+ end
17
+
18
+ def self.default_callback
19
+ return unless defined?(::Rails) && ::Rails.respond_to?(:error)
20
+
21
+ rails_reporter = ::Rails.error
22
+ return unless rails_reporter.respond_to?(:report)
23
+
24
+ ->(error, context) { rails_reporter.report(error, handled: true, severity: :error, context: context) }
25
+ end
26
+
27
+ private
28
+
29
+ def format_entry(error, message, context)
30
+ details = context.empty? ? "" : " (#{context.map { |key, value| "#{key}=#{value}" }.join(" ")})"
31
+ entry = "#{message}#{details}: #{error.class}: #{error.message}"
32
+ backtrace = error.backtrace
33
+ backtrace ? "#{entry}\n#{backtrace.join("\n")}" : entry
34
+ end
35
+ end
36
+ end
@@ -15,6 +15,7 @@ module ChatSDK
15
15
 
16
16
  class LockConflictError < Error; end
17
17
  class SignatureVerificationError < Error; end
18
+ class HandlerExecutorError < Error; end
18
19
 
19
20
  class PlatformError < Error
20
21
  attr_reader :status, :body, :adapter_name