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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36c07ba6e29c7d1cf858767caca271826418d829547426f1677a32c94133205e
4
- data.tar.gz: 0a00e35abebb7f43b8c9bb68576fce634d46088430029b33ad7f63c2d3820bbb
3
+ metadata.gz: 729c1baa552d9bad38924b1f026c75c769bb26b6dc3b4784e5e900ca66c93d21
4
+ data.tar.gz: 677e7b87d6177385e6a0b3fbe52da890a3238c31dd36c754c62d09e224ba385c
5
5
  SHA512:
6
- metadata.gz: 5483b777f81c28e3e367f84d0e4a3413b8f7d60ff09036d6148a3e565a5287252660e0fcc2aa3e7cce6c0d2f607f7a4834514f7f4da66445c1a6728ebc1a0ff6
7
- data.tar.gz: b2650d632171a42556aab7d6df09d5b6c79e7cfd1a032b9806a5d49b65892c3c62435d22463a24d382904df03c02b81eb3e93d5f86b0b83570e5ff24f9bb88a8
6
+ metadata.gz: 50cb4ab19ee8b817e4395030a9ca15ddd8980d785d0bff8f0dabb13462b636259fd6ff42afca5c2cfece542e74dd50224188dd2090e3bbf3378e558045cb4bf3
7
+ data.tar.gz: 13183055e0ce37f542350374d6210f8ec6dd2b63c34a0acef2629515cf0009bbcc7e93d03c64b39b96d16982dbce8f0904c1a7a51f1661f267f9880bf59d56bc
@@ -22,6 +22,10 @@ module ChatSDK
22
22
  nil
23
23
  end
24
24
 
25
+ def webhook_response(_events, _request = nil)
26
+ nil
27
+ end
28
+
25
29
  def parse_events(rack_request)
26
30
  raise NotImplementedError
27
31
  end
@@ -31,6 +35,12 @@ module ChatSDK
31
35
  raise NotImplementedError
32
36
  end
33
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
+
34
44
  def reply_message(channel_id:, message_id:, message:, thread_id: nil)
35
45
  require_capability!(:replies)
36
46
  raise NotImplementedError
@@ -41,12 +51,12 @@ module ChatSDK
41
51
  raise NotImplementedError
42
52
  end
43
53
 
44
- def edit_message(channel_id:, message_id:, message:)
54
+ def edit_message(channel_id:, message_id:, message:, thread_id: nil)
45
55
  require_capability!(:edit_messages)
46
56
  raise NotImplementedError
47
57
  end
48
58
 
49
- def delete_message(channel_id:, message_id:)
59
+ def delete_message(channel_id:, message_id:, thread_id: nil)
50
60
  require_capability!(:delete_messages)
51
61
  raise NotImplementedError
52
62
  end
@@ -61,12 +71,12 @@ module ChatSDK
61
71
  raise NotImplementedError
62
72
  end
63
73
 
64
- def add_reaction(channel_id:, message_id:, emoji:)
74
+ def add_reaction(channel_id:, message_id:, emoji:, thread_id: nil)
65
75
  require_capability!(:reactions)
66
76
  raise NotImplementedError
67
77
  end
68
78
 
69
- def remove_reaction(channel_id:, message_id:, emoji:)
79
+ def remove_reaction(channel_id:, message_id:, emoji:, thread_id: nil)
70
80
  require_capability!(:reactions)
71
81
  raise NotImplementedError
72
82
  end
@@ -96,11 +106,15 @@ module ChatSDK
96
106
  raise NotImplementedError
97
107
  end
98
108
 
99
- def start_typing(channel_id:, thread_id: nil)
109
+ def start_typing(channel_id:, thread_id: nil, message_id: nil)
100
110
  require_capability!(:typing_indicator)
101
111
  raise NotImplementedError
102
112
  end
103
113
 
114
+ def concurrency_scope(_event)
115
+ nil
116
+ end
117
+
104
118
  def mention(user_id)
105
119
  raise NotImplementedError
106
120
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Adapter
5
+ module Invocation
6
+ private
7
+
8
+ def call_adapter(method_name, **kwargs)
9
+ params = adapter.method(method_name).parameters
10
+ accepts_thread_id = params.any? do |type, name|
11
+ type == :keyrest || (%i[key keyreq].include?(type) && name == :thread_id)
12
+ end
13
+ kwargs.delete(:thread_id) unless accepts_thread_id
14
+ adapter.public_send(method_name, **kwargs)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -4,29 +4,39 @@ module ChatSDK
4
4
  module AI
5
5
  module ConversationScope
6
6
  THREAD_KEY = :chat_sdk_ai_conversation_scope
7
+ ACTIVE_KEY = :chat_sdk_ai_conversation_scope_active
7
8
 
8
9
  class << self
9
10
  def current
10
11
  ::Thread.current[THREAD_KEY]
11
12
  end
12
13
 
14
+ def active?
15
+ !!::Thread.current[ACTIVE_KEY]
16
+ end
17
+
13
18
  def with(thread)
14
19
  previous = current
20
+ previous_active = active?
15
21
  ::Thread.current[THREAD_KEY] = scope_for(thread)
22
+ ::Thread.current[ACTIVE_KEY] = true
16
23
  yield
17
24
  ensure
18
25
  ::Thread.current[THREAD_KEY] = previous
26
+ ::Thread.current[ACTIVE_KEY] = previous_active
19
27
  end
20
28
 
21
29
  def scope_for(value)
22
30
  return if value.nil?
23
31
  return value.transform_keys(&:to_sym) if value.is_a?(Hash)
24
32
 
25
- {
33
+ scope = {
26
34
  adapter_name: value.adapter.name.to_sym,
27
35
  channel_id: value.respond_to?(:channel_id) ? value.channel_id.to_s : value.id.to_s,
28
36
  thread_id: value.is_a?(ChatSDK::Thread) ? value.id.to_s : nil
29
37
  }
38
+ scope[:message_id] = value.current_message.id if value.respond_to?(:current_message) && value.current_message
39
+ scope
30
40
  end
31
41
  end
32
42
  end
@@ -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
@@ -131,7 +131,8 @@ module ChatSDK
131
131
  properties: {
132
132
  adapter_name: {type: "string", description: "Adapter name"},
133
133
  channel_id: {type: "string", description: "Channel ID"},
134
- thread_id: {type: "string", description: "Thread ID (optional)"}
134
+ thread_id: {type: "string", description: "Thread ID (optional)"},
135
+ message_id: {type: "string", description: "Current message ID (optional)"}
135
136
  },
136
137
  required: %w[adapter_name channel_id]
137
138
  },
@@ -5,13 +5,11 @@ module ChatSDK
5
5
  class ToolExecutor
6
6
  def initialize(chat:, scope: nil, strict_scope: false)
7
7
  @chat = chat
8
- @scope = if scope == false
9
- false
10
- else
11
- ConversationScope.scope_for(scope) || ConversationScope.current&.dup
12
- end
8
+ @scope = (scope == false) ? false : (ConversationScope.scope_for(scope) || ConversationScope.current&.dup)
9
+ @created_in_handler = ConversationScope.active?
10
+ validate_scope!(@scope) if @scope
13
11
  @strict_scope = strict_scope
14
- @warned_unscoped = false
12
+ @warned_unscoped = {}
15
13
  end
16
14
 
17
15
  def execute(tool_name, arguments)
@@ -20,7 +18,7 @@ module ChatSDK
20
18
 
21
19
  args = arguments.transform_keys(&:to_sym)
22
20
  adapter_name = args[:adapter_name].to_sym
23
- guard_scope!(adapter_name, args) unless tool_name == :send_direct_message
21
+ guard_scope!(tool_name, adapter_name, args)
24
22
 
25
23
  send(:"execute_#{tool_name}", adapter_name, args)
26
24
  end
@@ -48,24 +46,25 @@ module ChatSDK
48
46
 
49
47
  def execute_post_message(adapter_name, args)
50
48
  channel = @chat.channel(args[:channel_id], adapter_name: adapter_name)
49
+ message = ChatSDK::PostableMessage.new(text: args[:text], ai_generated: true)
51
50
  if args[:thread_id]
52
51
  thread = channel.thread(args[:thread_id])
53
- result = thread.post(args[:text])
52
+ result = thread.post(message)
54
53
  else
55
- result = channel.post(args[:text])
54
+ result = channel.post(message)
56
55
  end
57
56
  {id: result.id, text: args[:text]}
58
57
  end
59
58
 
60
59
  def execute_send_direct_message(adapter_name, args)
61
60
  dm_channel = @chat.open_dm(args[:user_id], adapter_name: adapter_name)
62
- result = dm_channel.post(args[:text])
61
+ result = dm_channel.post(ChatSDK::PostableMessage.new(text: args[:text], ai_generated: true))
63
62
  {id: result.id, channel_id: dm_channel.id}
64
63
  end
65
64
 
66
65
  def execute_edit_message(adapter_name, args)
67
66
  thread = target_thread(adapter_name, args)
68
- thread.edit(args[:message_id], args[:text])
67
+ thread.edit(args[:message_id], ChatSDK::PostableMessage.new(text: args[:text], ai_generated: true))
69
68
  {success: true}
70
69
  end
71
70
 
@@ -89,7 +88,9 @@ module ChatSDK
89
88
 
90
89
  def execute_start_typing(adapter_name, args)
91
90
  adapter = @chat.adapter(adapter_name)
92
- adapter.start_typing(channel_id: args[:channel_id], thread_id: args[:thread_id])
91
+ scoped_message_id = @scope.is_a?(Hash) ? @scope[:message_id] : nil
92
+ message_id = args[:message_id] || ConversationScope.current&.[](:message_id) || scoped_message_id
93
+ adapter.start_typing(channel_id: args[:channel_id], thread_id: args[:thread_id], message_id: message_id)
93
94
  {success: true}
94
95
  end
95
96
 
@@ -102,18 +103,25 @@ module ChatSDK
102
103
  channel.thread(args[:thread_id] || args[:channel_id])
103
104
  end
104
105
 
105
- def guard_scope!(adapter_name, args)
106
+ def guard_scope!(tool_name, adapter_name, args)
106
107
  return if @scope == false
107
108
 
108
109
  scope = @scope || ConversationScope.current
109
110
  unless scope
110
- unless @warned_unscoped
111
+ if ConversationScope.active? || @created_in_handler
112
+ raise ChatSDK::Error, "AI tool call requires an explicit scope when run inside a handler without a conversation"
113
+ end
114
+
115
+ unless @warned_unscoped[tool_name]
111
116
  ChatSDK::Log.warn("AI tool ran without a conversation scope; pass scope: to create_executor to confine access")
112
- @warned_unscoped = true
117
+ @warned_unscoped[tool_name] = true
113
118
  end
119
+
114
120
  return
115
121
  end
116
122
 
123
+ return if tool_name == :send_direct_message
124
+
117
125
  target_channel = args[:channel_id].to_s
118
126
  target_thread = args[:thread_id]&.to_s
119
127
  same_channel = adapter_name == scope.fetch(:adapter_name).to_sym && target_channel == scope.fetch(:channel_id).to_s
@@ -125,6 +133,15 @@ module ChatSDK
125
133
  active = [scope[:adapter_name], scope[:channel_id], scope[:thread_id]].compact.join(":")
126
134
  raise ChatSDK::Error, "AI tool call blocked: executor is scoped to #{active.inspect}, but targeted #{target.inspect}"
127
135
  end
136
+
137
+ def validate_scope!(scope)
138
+ %i[channel_id thread_id].each do |key|
139
+ next if key == :thread_id && scope[key].nil?
140
+ next unless scope.key?(key) && scope[key].to_s.empty?
141
+
142
+ raise ChatSDK::ConfigurationError, "AI tool scope #{key} must not be empty"
143
+ end
144
+ end
128
145
  end
129
146
  end
130
147
  end
@@ -4,11 +4,12 @@ module ChatSDK
4
4
  class Author
5
5
  attr_reader :id, :name, :platform, :locale, :email, :raw
6
6
 
7
- def initialize(id:, name:, platform:, bot: false, system: false, locale: nil, email: nil, raw: nil)
7
+ def initialize(id:, name:, platform:, bot: false, me: false, system: false, locale: nil, email: nil, raw: nil)
8
8
  @id = id
9
9
  @name = name
10
10
  @platform = platform
11
11
  @bot = bot
12
+ @me = !!me
12
13
  @system = system
13
14
  @locale = locale
14
15
  @email = email
@@ -19,6 +20,10 @@ module ChatSDK
19
20
  @bot
20
21
  end
21
22
 
23
+ def me?
24
+ @me
25
+ end
26
+
22
27
  def system?
23
28
  @system
24
29
  end
@@ -9,28 +9,50 @@ module ChatSDK
9
9
  @nodes = []
10
10
  end
11
11
 
12
- def button(text, id:, style: nil, value: nil, tooltip: 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
+ attrs[:visible_to] = visible_to if visible_to
18
+ attrs[:disabled] = disabled unless disabled.nil?
17
19
  @nodes << Node.new(:button, attributes: attrs)
18
20
  end
19
21
 
20
- def link_button(text, url:, id: nil, tooltip: nil)
22
+ def link_button(text, url:, id: nil, style: nil, tooltip: nil, visible_to: nil)
21
23
  attrs = {text: text, url: url}
22
24
  attrs[:id] = id if id
25
+ attrs[:style] = style if style
23
26
  attrs[:tooltip] = tooltip if tooltip
27
+ attrs[:visible_to] = visible_to if visible_to
24
28
  @nodes << Node.new(:link_button, attributes: attrs)
25
29
  end
26
30
 
27
- def select(id:, placeholder: nil, &block)
31
+ def select(id:, placeholder: nil, label: nil, initial_option: nil, optional: nil, dispatch_action: nil, &block)
28
32
  ctx = 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}
31
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?
32
42
  @nodes << Node.new(:select, attributes: attrs, children: ctx.nodes)
33
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
34
56
  end
35
57
  end
36
58
  end
@@ -3,10 +3,13 @@
3
3
  module ChatSDK
4
4
  module Cards
5
5
  class Builder
6
- def initialize(title: nil, subtitle: nil, width: nil, &block)
6
+ RESERVED_TEXT_INPUT_IDS = %w[actionId value msteams].freeze
7
+
8
+ def initialize(title: nil, subtitle: nil, width: nil, image_url: nil, &block)
7
9
  @title = title
8
10
  @subtitle = subtitle
9
11
  @width = width
12
+ @image_url = image_url
10
13
  @children = []
11
14
  instance_eval(&block) if block
12
15
  end
@@ -16,11 +19,18 @@ module ChatSDK
16
19
  attrs[:title] = @title if @title
17
20
  attrs[:subtitle] = @subtitle if @subtitle
18
21
  attrs[:width] = @width if @width
22
+ attrs[:image_url] = @image_url if @image_url
19
23
  Node.new(:card, attributes: attrs, children: @children)
20
24
  end
21
25
 
22
- def text(content)
23
- @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})
24
34
  end
25
35
 
26
36
  def divider
@@ -31,6 +41,18 @@ module ChatSDK
31
41
  @children << Node.new(:image, attributes: {url: url, alt: alt})
32
42
  end
33
43
 
44
+ def text_input(id:, label:, placeholder: nil, multiline: false, optional: false, value: nil, max_length: nil)
45
+ if RESERVED_TEXT_INPUT_IDS.include?(id.to_s)
46
+ raise ArgumentError, "Text input id #{id.inspect} is reserved"
47
+ end
48
+
49
+ attrs = {id: id, label: label, multiline: multiline, optional: optional}
50
+ attrs[:placeholder] = placeholder if placeholder
51
+ attrs[:value] = value unless value.nil?
52
+ attrs[:max_length] = max_length unless max_length.nil?
53
+ @children << Node.new(:text_input, attributes: attrs)
54
+ end
55
+
34
56
  def fields(&block)
35
57
  ctx = FieldsContext.new
36
58
  ctx.instance_eval(&block)
@@ -50,11 +72,15 @@ module ChatSDK
50
72
  @children << Node.new(:actions, children: ctx.nodes)
51
73
  end
52
74
 
53
- def table(headers:, rows:, align: nil, caption: nil, page_size: nil)
75
+ def table(headers:, rows:, align: nil, caption: nil, page_size: nil, widths: nil, grid_lines: nil, grid_style: nil, vertical_align: nil)
54
76
  attrs = {headers: headers, rows: rows}
55
77
  attrs[:align] = align if align
56
78
  attrs[:caption] = caption if caption
57
79
  attrs[:page_size] = page_size if page_size
80
+ attrs[:widths] = widths if widths
81
+ attrs[:grid_lines] = grid_lines unless grid_lines.nil?
82
+ attrs[:grid_style] = grid_style if grid_style
83
+ attrs[:vertical_align] = vertical_align if vertical_align
58
84
  @children << Node.new(:table, attributes: attrs)
59
85
  end
60
86
 
@@ -27,10 +27,14 @@ 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
33
35
  [node.attributes[:text]]
36
+ when :text_input
37
+ [node.attributes[:label]]
34
38
  when :table
35
39
  table_text(node)
36
40
  when :chart
@@ -9,13 +9,16 @@ 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)
15
16
  when :actions then render_actions(node)
16
17
  when :button then "[#{node.attributes[:text]}]"
17
18
  when :link_button then "[#{node.attributes[:text]}](#{node.attributes[:url]})"
19
+ when :text_input then node.attributes[:label]
18
20
  when :select then "_#{node.attributes[:placeholder] || "Select"}_"
21
+ when :radio_select then "_#{node.attributes[:label] || "Select"}_"
19
22
  when :table then render_table(node)
20
23
  when :chart then render_chart(node)
21
24
  else ""
data/lib/chat_sdk/chat.rb CHANGED
@@ -3,17 +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
15
+ @adapters.each_value { |adapter| adapter.set_state(state) if adapter.respond_to?(:set_state) }
11
16
  @registry = EventRegistry.new
12
17
  @history = History.new(self)
13
18
  @webhooks = {}
14
- @dispatcher = Dispatcher.new(chat: self, config: @config, state: @state, registry: @registry)
15
-
16
- 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
+ )
17
27
  end
18
28
 
19
29
  # Event registration
@@ -26,7 +36,12 @@ module ChatSDK
26
36
  end
27
37
 
28
38
  def on_new_message(pattern = nil, &block)
29
- @registry.register(:mention, matcher: pattern, &block)
39
+ unless pattern.nil? || pattern.is_a?(Regexp)
40
+ raise ChatSDK::ConfigurationError, "on_new_message pattern must be a Regexp or nil"
41
+ end
42
+ raise ChatSDK::ConfigurationError, "on_new_message requires a block" unless block
43
+
44
+ @registry.register(:new_message, matcher: pattern, &block)
30
45
  end
31
46
 
32
47
  def on_direct_message(&block)
@@ -53,6 +68,30 @@ module ChatSDK
53
68
  @registry.register(:slash_command, matcher: command, &block)
54
69
  end
55
70
 
71
+ def on_installed(&block)
72
+ @registry.register(:installed, &block)
73
+ end
74
+
75
+ def on_uninstalled(&block)
76
+ @registry.register(:uninstalled, &block)
77
+ end
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
+
56
95
  # Adapter access
57
96
  def adapter(name)
58
97
  @adapters.fetch(name) { raise ChatSDK::ConfigurationError, "Unknown adapter: #{name}" }
@@ -83,7 +122,28 @@ module ChatSDK
83
122
  # Event dispatch (called by webhook endpoints)
84
123
  def dispatch(event, adapter_name:)
85
124
  adp = adapter(adapter_name)
86
- @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)
87
147
  end
88
148
 
89
149
  # Deprecated compatibility alias for Vercel Chat SDK's former API.
@@ -92,6 +152,21 @@ module ChatSDK
92
152
  end
93
153
 
94
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
95
170
  end
96
171
 
97
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"