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.
- checksums.yaml +4 -4
- data/lib/chat_sdk/adapter/base.rb +19 -5
- data/lib/chat_sdk/adapter/invocation.rb +18 -0
- data/lib/chat_sdk/ai/conversation_scope.rb +11 -1
- data/lib/chat_sdk/ai/stream_handler.rb +3 -3
- data/lib/chat_sdk/ai/tool_builder.rb +2 -1
- data/lib/chat_sdk/ai/tool_executor.rb +32 -15
- data/lib/chat_sdk/author.rb +6 -1
- data/lib/chat_sdk/cards/actions_context.rb +26 -4
- data/lib/chat_sdk/cards/builder.rb +30 -4
- data/lib/chat_sdk/cards/node.rb +4 -0
- data/lib/chat_sdk/cards/renderer.rb +3 -0
- data/lib/chat_sdk/chat.rb +80 -5
- data/lib/chat_sdk/config.rb +34 -2
- data/lib/chat_sdk/dispatcher.rb +107 -28
- data/lib/chat_sdk/error_reporter.rb +36 -0
- data/lib/chat_sdk/errors.rb +1 -0
- data/lib/chat_sdk/event_registry.rb +7 -2
- data/lib/chat_sdk/event_serializer.rb +66 -6
- data/lib/chat_sdk/events/action.rb +3 -2
- data/lib/chat_sdk/events/app_home_opened.rb +16 -0
- data/lib/chat_sdk/events/assistant_context.rb +25 -0
- data/lib/chat_sdk/events/assistant_context_changed.rb +18 -0
- data/lib/chat_sdk/events/assistant_thread_started.rb +18 -0
- data/lib/chat_sdk/events/installed.rb +19 -0
- data/lib/chat_sdk/events/member_joined_channel.rb +16 -0
- data/lib/chat_sdk/events/uninstalled.rb +19 -0
- data/lib/chat_sdk/format/mentions.rb +61 -0
- data/lib/chat_sdk/message.rb +4 -1
- data/lib/chat_sdk/modals/builder.rb +39 -4
- data/lib/chat_sdk/postable_message.rb +3 -2
- data/lib/chat_sdk/streaming/chunk.rb +35 -0
- data/lib/chat_sdk/streaming/markdown_text_chunk.rb +15 -0
- data/lib/chat_sdk/streaming/plan_update_chunk.rb +15 -0
- data/lib/chat_sdk/streaming/stream.rb +48 -8
- data/lib/chat_sdk/streaming/task_update_chunk.rb +26 -0
- data/lib/chat_sdk/streaming/writer.rb +16 -0
- data/lib/chat_sdk/streaming_plan.rb +32 -0
- data/lib/chat_sdk/testing/fake_adapter.rb +6 -6
- data/lib/chat_sdk/thread.rb +86 -15
- data/lib/chat_sdk/version.rb +1 -1
- data/lib/chat_sdk/webhook/endpoint.rb +45 -2
- data/lib/chat_sdk.rb +2 -2
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 729c1baa552d9bad38924b1f026c75c769bb26b6dc3b4784e5e900ca66c93d21
|
|
4
|
+
data.tar.gz: 677e7b87d6177385e6a0b3fbe52da890a3238c31dd36c754c62d09e224ba385c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 =
|
|
9
|
-
|
|
10
|
-
|
|
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 =
|
|
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)
|
|
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(
|
|
52
|
+
result = thread.post(message)
|
|
54
53
|
else
|
|
55
|
-
result = channel.post(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/chat_sdk/author.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/chat_sdk/cards/node.rb
CHANGED
|
@@ -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
|
-
@
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
data/lib/chat_sdk/config.rb
CHANGED
|
@@ -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 =
|
|
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"
|