chat_sdk 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/lib/chat_sdk/adapter/base.rb +33 -5
  3. data/lib/chat_sdk/adapter/capabilities.rb +1 -1
  4. data/lib/chat_sdk/adapter/invocation.rb +18 -0
  5. data/lib/chat_sdk/ai/conversation_scope.rb +44 -0
  6. data/lib/chat_sdk/ai/converter.rb +16 -5
  7. data/lib/chat_sdk/ai/tool_builder.rb +18 -2
  8. data/lib/chat_sdk/ai/tool_executor.rb +64 -10
  9. data/lib/chat_sdk/ai.rb +2 -2
  10. data/lib/chat_sdk/author.rb +13 -2
  11. data/lib/chat_sdk/cards/actions_context.rb +9 -3
  12. data/lib/chat_sdk/cards/builder.rb +38 -1
  13. data/lib/chat_sdk/cards/node.rb +36 -0
  14. data/lib/chat_sdk/cards/renderer.rb +20 -0
  15. data/lib/chat_sdk/channel.rb +8 -0
  16. data/lib/chat_sdk/chat.rb +35 -2
  17. data/lib/chat_sdk/config.rb +48 -1
  18. data/lib/chat_sdk/dispatcher.rb +280 -33
  19. data/lib/chat_sdk/event_registry.rb +7 -2
  20. data/lib/chat_sdk/event_serializer.rb +192 -0
  21. data/lib/chat_sdk/events/action.rb +3 -2
  22. data/lib/chat_sdk/events/installed.rb +19 -0
  23. data/lib/chat_sdk/events/message_deleted.rb +19 -0
  24. data/lib/chat_sdk/events/message_updated.rb +17 -0
  25. data/lib/chat_sdk/events/uninstalled.rb +19 -0
  26. data/lib/chat_sdk/format/mentions.rb +61 -0
  27. data/lib/chat_sdk/history.rb +151 -0
  28. data/lib/chat_sdk/message.rb +8 -2
  29. data/lib/chat_sdk/modals/builder.rb +16 -0
  30. data/lib/chat_sdk/postable_message.rb +3 -2
  31. data/lib/chat_sdk/state/base.rb +16 -0
  32. data/lib/chat_sdk/state/memory.rb +31 -0
  33. data/lib/chat_sdk/streaming/stream.rb +6 -2
  34. data/lib/chat_sdk/testing/fake_adapter.rb +32 -8
  35. data/lib/chat_sdk/thread.rb +35 -6
  36. data/lib/chat_sdk/version.rb +1 -1
  37. data/lib/chat_sdk/webhook/endpoint.rb +3 -0
  38. data/lib/chat_sdk.rb +2 -2
  39. metadata +10 -1
data/lib/chat_sdk/chat.rb CHANGED
@@ -2,13 +2,15 @@
2
2
 
3
3
  module ChatSDK
4
4
  class Chat
5
- attr_reader :config, :state
5
+ attr_reader :config, :state, :history
6
6
 
7
7
  def initialize(user_name:, adapters:, state:, **options)
8
8
  @config = Config.new(user_name: user_name, adapters: adapters, state: state, **options)
9
9
  @state = state
10
10
  @adapters = adapters
11
+ @adapters.each_value { |adapter| adapter.set_state(state) if adapter.respond_to?(:set_state) }
11
12
  @registry = EventRegistry.new
13
+ @history = History.new(self)
12
14
  @webhooks = {}
13
15
  @dispatcher = Dispatcher.new(chat: self, config: @config, state: @state, registry: @registry)
14
16
 
@@ -25,13 +27,26 @@ module ChatSDK
25
27
  end
26
28
 
27
29
  def on_new_message(pattern = nil, &block)
28
- @registry.register(:mention, matcher: pattern, &block)
30
+ unless pattern.nil? || pattern.is_a?(Regexp)
31
+ raise ChatSDK::ConfigurationError, "on_new_message pattern must be a Regexp or nil"
32
+ end
33
+ raise ChatSDK::ConfigurationError, "on_new_message requires a block" unless block
34
+
35
+ @registry.register(:new_message, matcher: pattern, &block)
29
36
  end
30
37
 
31
38
  def on_direct_message(&block)
32
39
  @registry.register(:direct_message, &block)
33
40
  end
34
41
 
42
+ def on_message_updated(&block)
43
+ @registry.register(:message_updated, &block)
44
+ end
45
+
46
+ def on_message_deleted(&block)
47
+ @registry.register(:message_deleted, &block)
48
+ end
49
+
35
50
  def on_reaction(emojis = nil, &block)
36
51
  @registry.register(:reaction, matcher: emojis, &block)
37
52
  end
@@ -44,6 +59,14 @@ module ChatSDK
44
59
  @registry.register(:slash_command, matcher: command, &block)
45
60
  end
46
61
 
62
+ def on_installed(&block)
63
+ @registry.register(:installed, &block)
64
+ end
65
+
66
+ def on_uninstalled(&block)
67
+ @registry.register(:uninstalled, &block)
68
+ end
69
+
47
70
  # Adapter access
48
71
  def adapter(name)
49
72
  @adapters.fetch(name) { raise ChatSDK::ConfigurationError, "Unknown adapter: #{name}" }
@@ -61,6 +84,11 @@ module ChatSDK
61
84
  Channel.new(id: channel_id, adapter: adp, chat: self)
62
85
  end
63
86
 
87
+ def thread(id, channel_id:, adapter_name: nil)
88
+ adp = adapter_name ? adapter(adapter_name) : @adapters.values.first
89
+ Thread.new(id: id, channel_id: channel_id, adapter: adp, chat: self)
90
+ end
91
+
64
92
  # Webhook endpoints (Rack apps)
65
93
  def webhooks
66
94
  @webhook_accessor ||= WebhookAccessor.new(self, @adapters)
@@ -72,6 +100,11 @@ module ChatSDK
72
100
  @dispatcher.dispatch(event, adapter: adp, adapter_name: adapter_name)
73
101
  end
74
102
 
103
+ # Deprecated compatibility alias for Vercel Chat SDK's former API.
104
+ def transcripts
105
+ history.user
106
+ end
107
+
75
108
  private
76
109
  end
77
110
 
@@ -6,12 +6,16 @@ module ChatSDK
6
6
  dedupe_ttl: 600,
7
7
  streaming_update_interval: 0.5,
8
8
  on_lock_conflict: :drop,
9
+ concurrency: nil,
10
+ lock_scope: :thread,
9
11
  handler_executor: :inline,
12
+ history: {},
10
13
  log_level: :info
11
14
  }.freeze
12
15
 
13
16
  attr_reader :user_name, :adapters, :state, :on_lock_conflict,
14
- :dedupe_ttl, :streaming_update_interval, :handler_executor, :log_level
17
+ :dedupe_ttl, :streaming_update_interval, :handler_executor, :log_level,
18
+ :history_user, :concurrency
15
19
 
16
20
  def initialize(user_name:, adapters:, state:, **options)
17
21
  raise ConfigurationError, "user_name is required" if user_name.nil? || user_name.empty?
@@ -23,9 +27,17 @@ module ChatSDK
23
27
  @state = state
24
28
  merged = DEFAULTS.merge(options)
25
29
  @on_lock_conflict = merged[:on_lock_conflict]
30
+ @concurrency = normalize_concurrency(merged[:concurrency], lock_scope: merged[:lock_scope])
26
31
  @dedupe_ttl = merged[:dedupe_ttl]
27
32
  @streaming_update_interval = merged[:streaming_update_interval]
28
33
  @handler_executor = merged[:handler_executor]
34
+ history = merged[:history] || {}
35
+ user_history = history[:user] || history["user"] || {}
36
+ @history_user = {
37
+ identity: user_history[:identity] || user_history["identity"] || merged[:identity],
38
+ retention: user_history.fetch(:retention, user_history.fetch("retention", 30 * 24 * 3600)),
39
+ max_per_user: user_history.fetch(:max_per_user, user_history.fetch("max_per_user", 200))
40
+ }
29
41
  @log_level = merged[:log_level]
30
42
 
31
43
  validate_lock_conflict!
@@ -37,5 +49,40 @@ module ChatSDK
37
49
  return if %i[drop force].include?(@on_lock_conflict) || @on_lock_conflict.respond_to?(:call)
38
50
  raise ConfigurationError, "on_lock_conflict must be :drop, :force, or a callable"
39
51
  end
52
+
53
+ def normalize_concurrency(value, lock_scope:)
54
+ supplied = if value.nil?
55
+ {}
56
+ elsif value.is_a?(Hash)
57
+ value
58
+ else
59
+ {strategy: value}
60
+ end
61
+ supplied = supplied.transform_keys(&:to_sym)
62
+ supplied[:debounce] = supplied.delete(:debounce_ms).to_f / 1000 if supplied.key?(:debounce_ms)
63
+ supplied[:queue_entry_ttl] = supplied.delete(:queue_entry_ttl_ms).to_f / 1000 if supplied.key?(:queue_entry_ttl_ms)
64
+ supplied[:max_lock_lifetime] = supplied.delete(:max_lock_lifetime_ms).to_f / 1000 if supplied.key?(:max_lock_lifetime_ms)
65
+ supplied[:lock_ttl] = supplied.delete(:lock_ttl_ms).to_f / 1000 if supplied.key?(:lock_ttl_ms)
66
+ config = {
67
+ strategy: :drop,
68
+ max_queue_size: 10,
69
+ on_queue_full: :drop_oldest,
70
+ queue_entry_ttl: 90,
71
+ debounce: 1.5,
72
+ lock_ttl: 30,
73
+ max_lock_lifetime: 600,
74
+ max_concurrent: nil,
75
+ lock_scope: lock_scope
76
+ }.merge(supplied)
77
+ config[:strategy] = config[:strategy].to_sym
78
+ config[:on_queue_full] = config[:on_queue_full].to_s.tr("-", "_").to_sym
79
+ unless %i[drop force queue burst debounce concurrent].include?(config[:strategy])
80
+ raise ConfigurationError, "concurrency strategy must be :drop, :queue, :burst, :debounce, or :concurrent"
81
+ end
82
+ if config[:max_concurrent] && config[:max_concurrent].to_i < 1
83
+ raise ConfigurationError, "max_concurrent must be at least 1"
84
+ end
85
+ config
86
+ end
40
87
  end
41
88
  end
@@ -1,40 +1,167 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+ require "time"
5
+
3
6
  module ChatSDK
4
7
  class Dispatcher
8
+ LockHeartbeat = Struct.new(:thread, :mutex, :condition, :stop, :ownership_lost) do
9
+ def ownership_lost?
10
+ mutex.synchronize { ownership_lost }
11
+ end
12
+ end
13
+
5
14
  def initialize(chat:, config:, state:, registry:)
6
15
  @chat = chat
7
16
  @config = config
8
17
  @state = state
9
18
  @registry = registry
19
+ @slot_mutex = Mutex.new
20
+ @slot_condition = ConditionVariable.new
21
+ @active_slots = Hash.new(0)
10
22
  end
11
23
 
12
24
  def dispatch(event, adapter:, adapter_name:)
13
25
  ChatSDK::Instrumentation.instrument("dispatch.chat_sdk", adapter: adapter_name, event_type: event.type) do
14
- return unless dedupe(event, adapter_name)
26
+ if %i[message_updated message_deleted].include?(event.type)
27
+ return execute_event(event, build_thread(event, adapter))
28
+ end
29
+
30
+ concurrency_scope = adapter.concurrency_scope(event)
31
+ return unless dedupe(event, adapter_name, concurrency_scope)
15
32
 
16
33
  thread = build_thread(event, adapter)
17
- thread_key = thread_key_for(event, adapter_name)
34
+ thread_key = thread_key_for(event, adapter_name, concurrency_scope)
35
+ strategy = @config.concurrency[:strategy]
18
36
 
19
- return unless acquire_lock(thread_key, event)
37
+ if strategy == :concurrent
38
+ return with_concurrent_slot(thread_key) { execute_event(event, thread) }
39
+ end
20
40
 
21
- begin
22
- handlers = @registry.handlers_for(event)
23
- handlers.each { |handler| execute_handler(handler, event, thread) }
24
- ensure
25
- release_lock(thread_key)
41
+ owner = "#{Process.pid}:#{::Thread.current.object_id}:#{SecureRandom.uuid}"
42
+ if %i[queue burst debounce].include?(strategy)
43
+ dispatch_queued(event, adapter, thread_key, owner, strategy)
44
+ else
45
+ dispatch_locked(event, thread, thread_key, owner, strategy)
26
46
  end
27
47
  end
28
48
  end
29
49
 
30
50
  private
31
51
 
32
- def dedupe(event, adapter_name)
52
+ def dispatch_locked(event, thread, thread_key, owner, strategy)
53
+ acquired = acquire_lock(thread_key, event, owner, force: strategy == :force)
54
+ return unless acquired
55
+
56
+ heartbeat = start_lock_heartbeat(thread_key, owner)
57
+ execute_event(event, thread)
58
+ ensure
59
+ stop_lock_heartbeat(heartbeat)
60
+ release_lock(thread_key, owner) if acquired
61
+ end
62
+
63
+ def dispatch_queued(event, adapter, thread_key, owner, strategy)
64
+ queue_key = "chat_sdk:queue:#{thread_key}"
65
+ acquired = acquire_lock(thread_key, event, owner, apply_conflict_policy: false)
66
+ pre_enqueued = false
67
+
68
+ unless acquired
69
+ enqueue(queue_key, event)
70
+ pre_enqueued = true
71
+ acquired = acquire_lock(thread_key, event, owner, apply_conflict_policy: false)
72
+ return unless acquired
73
+ end
74
+
75
+ heartbeat = start_lock_heartbeat(thread_key, owner)
76
+ if strategy == :queue
77
+ execute_event(event, build_thread(event, adapter)) unless pre_enqueued
78
+ else
79
+ enqueue(queue_key, event) unless pre_enqueued
80
+ sleep(@config.concurrency[:debounce].to_f) if strategy == :burst
81
+ end
82
+
83
+ if strategy == :debounce
84
+ debounce_loop(queue_key, adapter, heartbeat)
85
+ else
86
+ drain_loop(queue_key, adapter, strategy, heartbeat)
87
+ end
88
+ ensure
89
+ stop_lock_heartbeat(heartbeat)
90
+ release_lock(thread_key, owner) if acquired
91
+ end
92
+
93
+ def drain_loop(queue_key, adapter, strategy, heartbeat)
94
+ loop do
95
+ break if heartbeat&.ownership_lost?
96
+
97
+ entries = fresh_entries(@state.drain_queue(queue_key))
98
+ break if entries.empty?
99
+
100
+ events = entries.map { |entry| EventSerializer.load(entry.fetch("event")) }
101
+ current = events.last
102
+ skipped = if strategy == :debounce
103
+ []
104
+ else
105
+ events[0...-1].filter_map { |queued| queued.message if queued.respond_to?(:message) }
106
+ end
107
+ context = {skipped: skipped, total_since_last_handler: skipped.length + 1}
108
+ execute_event(current, build_thread(current, adapter), context: context)
109
+ ::Thread.pass
110
+ end
111
+ end
112
+
113
+ def debounce_loop(queue_key, adapter, heartbeat)
114
+ skipped = []
115
+ loop do
116
+ sleep(@config.concurrency[:debounce].to_f)
117
+ break if heartbeat&.ownership_lost?
118
+
119
+ entries = fresh_entries(@state.drain_queue(queue_key))
120
+ break if entries.empty?
121
+
122
+ events = entries.map { |entry| EventSerializer.load(entry.fetch("event")) }
123
+ latest = events.last
124
+ skipped.concat(events[0...-1].filter_map { |queued| queued.message if queued.respond_to?(:message) })
125
+
126
+ if @state.queue_depth(queue_key).positive?
127
+ skipped << latest.message if latest.respond_to?(:message)
128
+ next
129
+ end
130
+
131
+ relevant = skipped.select do |message|
132
+ message.thread_id == latest.thread_id
133
+ end
134
+ context = {skipped: relevant, total_since_last_handler: relevant.length + 1}
135
+ execute_event(latest, build_thread(latest, adapter), context: context)
136
+ skipped.clear
137
+ end
138
+ end
139
+
140
+ def enqueue(queue_key, event)
141
+ entry = {"event" => EventSerializer.dump(event), "enqueued_at" => Time.now.iso8601}
142
+ @state.enqueue(
143
+ queue_key,
144
+ entry,
145
+ max_size: @config.concurrency[:max_queue_size],
146
+ drop: @config.concurrency[:on_queue_full]
147
+ )
148
+ end
149
+
150
+ def fresh_entries(entries)
151
+ cutoff = Time.now - @config.concurrency[:queue_entry_ttl].to_f
152
+ entries.select do |entry|
153
+ Time.iso8601(entry.fetch("enqueued_at")) >= cutoff
154
+ rescue ArgumentError, KeyError
155
+ false
156
+ end
157
+ end
158
+
159
+ def dedupe(event, adapter_name, concurrency_scope)
33
160
  event_id = extract_event_id(event)
34
161
  return true unless event_id
35
162
 
36
163
  is_new = @state.set_if_absent(
37
- "chat_sdk:dedupe:#{adapter_name}:#{event_id}",
164
+ dedupe_key(adapter_name, concurrency_scope, event_id),
38
165
  true,
39
166
  ttl: @config.dedupe_ttl
40
167
  )
@@ -45,41 +172,85 @@ module ChatSDK
45
172
  def extract_event_id(event)
46
173
  if event.respond_to?(:message) && event.message
47
174
  event.message.id
175
+ elsif event.respond_to?(:message_id) && event.message_id
176
+ "#{event.type}:#{event.message_id}:#{event.timestamp.to_f}"
48
177
  elsif event.respond_to?(:raw) && event.raw.is_a?(Hash)
49
178
  event.raw[:event_id] || event.raw["event_id"]
50
179
  end
51
180
  end
52
181
 
53
- def thread_key_for(event, adapter_name)
54
- channel_id = event.respond_to?(:channel_id) ? event.channel_id : nil
55
- thread_id = event.respond_to?(:thread_id) ? event.thread_id : nil
56
- "#{adapter_name}:#{channel_id}:#{thread_id}"
182
+ def dedupe_key(adapter_name, concurrency_scope, event_id)
183
+ prefix = ["chat_sdk:dedupe", adapter_name]
184
+ prefix << concurrency_scope if concurrency_scope
185
+ "#{prefix.join(":")}:#{event_id}"
57
186
  end
58
187
 
59
- def lock_owner
60
- @lock_owner ||= "#{Process.pid}:#{::Thread.current.object_id}"
188
+ def thread_key_for(event, adapter_name, concurrency_scope)
189
+ channel_id = event.respond_to?(:channel_id) ? event.channel_id : nil
190
+ thread_id = event.respond_to?(:thread_id) ? event.thread_id : nil
191
+ lock_scope = @config.concurrency[:lock_scope]
192
+ scope_id = (lock_scope&.to_sym == :channel) ? channel_id : thread_id
193
+ [adapter_name, concurrency_scope, channel_id, scope_id].compact.join(":")
61
194
  end
62
195
 
63
- def acquire_lock(thread_key, event)
196
+ def acquire_lock(thread_key, event, owner, force: false, apply_conflict_policy: true)
64
197
  lock_key = "chat_sdk:lock:#{thread_key}"
65
- acquired = @state.acquire_lock(lock_key, owner: lock_owner, ttl: 30)
198
+ acquired = @state.acquire_lock(lock_key, owner: owner, ttl: @config.concurrency[:lock_ttl])
66
199
 
67
- unless acquired
68
- policy = @config.on_lock_conflict
200
+ if !acquired && apply_conflict_policy
201
+ policy = force ? :force : @config.on_lock_conflict
69
202
  policy = policy.call(thread_key, event) if policy.respond_to?(:call)
70
-
71
203
  if policy == :force
72
- @state.force_lock(lock_key, owner: lock_owner, ttl: 30)
204
+ @state.force_lock(lock_key, owner: owner, ttl: @config.concurrency[:lock_ttl])
73
205
  acquired = true
74
206
  end
75
207
  end
76
208
 
77
- ChatSDK::Instrumentation.instrument("lock.chat_sdk", key: thread_key, acquired: acquired, policy: acquired ? nil : policy)
209
+ ChatSDK::Instrumentation.instrument("lock.chat_sdk", key: thread_key, acquired: acquired)
78
210
  acquired
79
211
  end
80
212
 
81
- def release_lock(thread_key)
82
- @state.release_lock("chat_sdk:lock:#{thread_key}", owner: lock_owner)
213
+ def release_lock(thread_key, owner)
214
+ @state.release_lock("chat_sdk:lock:#{thread_key}", owner: owner)
215
+ end
216
+
217
+ def start_lock_heartbeat(thread_key, owner)
218
+ lock_ttl = @config.concurrency[:lock_ttl].to_f
219
+ max_lifetime = @config.concurrency[:max_lock_lifetime].to_f
220
+ mutex = Mutex.new
221
+ condition = ConditionVariable.new
222
+ heartbeat = LockHeartbeat.new(mutex: mutex, condition: condition, stop: false, ownership_lost: false)
223
+ heartbeat.thread = ::Thread.new do
224
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
225
+ interval = [lock_ttl / 3.0, 0.01].max
226
+ loop do
227
+ should_stop = mutex.synchronize do
228
+ condition.wait(mutex, interval) unless heartbeat.stop
229
+ heartbeat.stop
230
+ end
231
+ break if should_stop
232
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) - started >= max_lifetime
233
+
234
+ extended = @state.extend_lock("chat_sdk:lock:#{thread_key}", owner: owner, ttl: lock_ttl)
235
+ unless extended
236
+ mutex.synchronize { heartbeat.ownership_lost = true }
237
+ break
238
+ end
239
+ rescue NotImplementedError
240
+ break
241
+ end
242
+ end
243
+ heartbeat
244
+ end
245
+
246
+ def stop_lock_heartbeat(heartbeat)
247
+ return unless heartbeat
248
+
249
+ heartbeat.mutex.synchronize do
250
+ heartbeat.stop = true
251
+ heartbeat.condition.signal
252
+ end
253
+ heartbeat.thread.join
83
254
  end
84
255
 
85
256
  def build_thread(event, adapter)
@@ -87,17 +258,75 @@ module ChatSDK
87
258
  channel_id = event.respond_to?(:channel_id) ? event.channel_id : nil
88
259
  return nil unless thread_id && channel_id
89
260
 
90
- ChatSDK::Thread.new(id: thread_id, channel_id: channel_id, adapter: adapter, chat: @chat)
261
+ current_message = event.respond_to?(:message) ? event.message : nil
262
+ ChatSDK::Thread.new(id: thread_id, channel_id: channel_id, adapter: adapter, chat: @chat, current_message: current_message)
263
+ end
264
+
265
+ def execute_event(event, thread, context: nil)
266
+ if %i[mention subscribed_message direct_message].include?(event.type)
267
+ route_message_event(event, thread, context)
268
+ return
269
+ end
270
+
271
+ @registry.handlers_for(event).each do |handler|
272
+ execute_handler(handler, event, thread, context: context)
273
+ end
91
274
  end
92
275
 
93
- def execute_handler(handler, event, thread)
276
+ def route_message_event(event, thread, context)
277
+ message = event.message
278
+ dm_handlers = @registry.handlers_for_type(:direct_message, event: event)
279
+ if event.type == :direct_message && dm_handlers.any?
280
+ run_handlers(dm_handlers, event, thread, context)
281
+ return
282
+ end
283
+
284
+ message.is_mention = true if event.type == :direct_message
285
+
286
+ if thread&.subscribed?
287
+ run_handlers(@registry.handlers_for_type(:subscribed_message, event: event), event, thread, context)
288
+ return
289
+ end
290
+
291
+ if message.is_mention.nil?
292
+ message.is_mention = if event.type == :mention
293
+ true
294
+ else
295
+ detect_mention(message.text)
296
+ end
297
+ end
298
+
299
+ if message.is_mention
300
+ run_handlers(@registry.handlers_for_type(:mention, event: event), event, thread, context)
301
+ return
302
+ end
303
+
304
+ @registry.handlers_for_type(:new_message, event: event).each do |handler|
305
+ execute_handler(handler, event, thread, context: context)
306
+ end
307
+ end
308
+
309
+ def run_handlers(handlers, event, thread, context)
310
+ handlers.each { |handler| execute_handler(handler, event, thread, context: context) }
311
+ end
312
+
313
+ def detect_mention(text)
314
+ masked_text = ChatSDK::Format::Mentions.mask_code_spans(text)
315
+ /(?<![[:word:]])@#{Regexp.escape(@config.user_name)}\b/i.match?(masked_text)
316
+ end
317
+
318
+ def execute_handler(handler, event, thread, context: nil)
94
319
  ChatSDK::Instrumentation.instrument("handler.chat_sdk", handler_type: event.type) do
95
- case event.type
96
- when :mention, :subscribed_message, :direct_message
97
- handler.block.call(thread, event.message)
98
- when :reaction, :action, :slash_command
99
- add_thread_to_event(event, thread)
100
- handler.block.call(event)
320
+ ChatSDK::AI::ConversationScope.with(thread) do
321
+ case event.type
322
+ when :mention, :subscribed_message, :direct_message, :new_message
323
+ handler.block.call(thread, event.message, context)
324
+ when :message_updated
325
+ handler.block.call(thread, event.message, event.previous_message)
326
+ when :reaction, :action, :slash_command, :message_deleted, :installed, :uninstalled
327
+ add_thread_to_event(event, thread)
328
+ handler.block.call(event)
329
+ end
101
330
  end
102
331
  end
103
332
  rescue => e
@@ -108,5 +337,23 @@ module ChatSDK
108
337
  def add_thread_to_event(event, thread)
109
338
  event.thread = thread if event.respond_to?(:thread=)
110
339
  end
340
+
341
+ def with_concurrent_slot(key)
342
+ max = @config.concurrency[:max_concurrent]
343
+ return yield unless max
344
+
345
+ @slot_mutex.synchronize do
346
+ @slot_condition.wait(@slot_mutex) while @active_slots[key] >= max
347
+ @active_slots[key] += 1
348
+ end
349
+ yield
350
+ ensure
351
+ if max
352
+ @slot_mutex.synchronize do
353
+ @active_slots[key] -= 1
354
+ @slot_condition.broadcast
355
+ end
356
+ end
357
+ end
111
358
  end
112
359
  end
@@ -13,13 +13,18 @@ module ChatSDK
13
13
  end
14
14
 
15
15
  def handlers_for(event)
16
- @handlers.select { |h| matches?(h, event) }
16
+ handlers_for_type(event.type, event: event)
17
+ end
18
+
19
+ def handlers_for_type(type, event: nil)
20
+ @handlers.select do |handler|
21
+ handler.type == type && (!event || matches?(handler, event))
22
+ end
17
23
  end
18
24
 
19
25
  private
20
26
 
21
27
  def matches?(handler, event)
22
- return false unless handler.type == event.type
23
28
  return true if handler.matcher.nil?
24
29
 
25
30
  case handler.matcher