chat_sdk 1.0.1 → 1.1.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 +20 -0
- data/lib/chat_sdk/adapter/capabilities.rb +1 -1
- data/lib/chat_sdk/ai/conversation_scope.rb +34 -0
- data/lib/chat_sdk/ai/converter.rb +16 -5
- data/lib/chat_sdk/ai/tool_builder.rb +16 -1
- data/lib/chat_sdk/ai/tool_executor.rb +42 -5
- data/lib/chat_sdk/ai.rb +2 -2
- data/lib/chat_sdk/author.rb +8 -2
- data/lib/chat_sdk/cards/actions_context.rb +7 -3
- data/lib/chat_sdk/cards/builder.rb +21 -1
- data/lib/chat_sdk/cards/node.rb +34 -0
- data/lib/chat_sdk/cards/renderer.rb +19 -0
- data/lib/chat_sdk/channel.rb +8 -0
- data/lib/chat_sdk/chat.rb +20 -1
- data/lib/chat_sdk/config.rb +48 -1
- data/lib/chat_sdk/dispatcher.rb +221 -28
- data/lib/chat_sdk/event_serializer.rb +162 -0
- data/lib/chat_sdk/events/message_deleted.rb +19 -0
- data/lib/chat_sdk/events/message_updated.rb +17 -0
- data/lib/chat_sdk/history.rb +151 -0
- data/lib/chat_sdk/message.rb +5 -2
- data/lib/chat_sdk/modals/builder.rb +16 -0
- data/lib/chat_sdk/state/base.rb +16 -0
- data/lib/chat_sdk/state/memory.rb +31 -0
- data/lib/chat_sdk/testing/fake_adapter.rb +26 -2
- data/lib/chat_sdk/thread.rb +25 -2
- data/lib/chat_sdk/version.rb +1 -1
- data/lib/chat_sdk.rb +2 -2
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36c07ba6e29c7d1cf858767caca271826418d829547426f1677a32c94133205e
|
|
4
|
+
data.tar.gz: 0a00e35abebb7f43b8c9bb68576fce634d46088430029b33ad7f63c2d3820bbb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5483b777f81c28e3e367f84d0e4a3413b8f7d60ff09036d6148a3e565a5287252660e0fcc2aa3e7cce6c0d2f607f7a4834514f7f4da66445c1a6728ebc1a0ff6
|
|
7
|
+
data.tar.gz: b2650d632171a42556aab7d6df09d5b6c79e7cfd1a032b9806a5d49b65892c3c62435d22463a24d382904df03c02b81eb3e93d5f86b0b83570e5ff24f9bb88a8
|
|
@@ -31,6 +31,16 @@ module ChatSDK
|
|
|
31
31
|
raise NotImplementedError
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
def reply_message(channel_id:, message_id:, message:, thread_id: nil)
|
|
35
|
+
require_capability!(:replies)
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def mark_as_read(channel_id:, message_id:, thread_id: nil, message: nil)
|
|
40
|
+
require_capability!(:read_receipts)
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
end
|
|
43
|
+
|
|
34
44
|
def edit_message(channel_id:, message_id:, message:)
|
|
35
45
|
require_capability!(:edit_messages)
|
|
36
46
|
raise NotImplementedError
|
|
@@ -71,6 +81,16 @@ module ChatSDK
|
|
|
71
81
|
raise NotImplementedError
|
|
72
82
|
end
|
|
73
83
|
|
|
84
|
+
def fetch_channel_messages(channel_id:, cursor: nil, limit: 50)
|
|
85
|
+
require_capability!(:message_history)
|
|
86
|
+
fetch_messages(channel_id: channel_id, cursor: cursor, limit: limit)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def list_threads(channel_id:, cursor: nil, limit: 50)
|
|
90
|
+
require_capability!(:threads)
|
|
91
|
+
raise NotImplementedError
|
|
92
|
+
end
|
|
93
|
+
|
|
74
94
|
def open_modal(trigger_id:, modal:)
|
|
75
95
|
require_capability!(:modals)
|
|
76
96
|
raise NotImplementedError
|
|
@@ -7,7 +7,7 @@ module ChatSDK
|
|
|
7
7
|
edit_messages delete_messages ephemeral_messages
|
|
8
8
|
file_uploads reactions modals typing_indicator
|
|
9
9
|
streaming_edit threads direct_messages
|
|
10
|
-
scheduled_messages message_history
|
|
10
|
+
scheduled_messages message_history replies read_receipts
|
|
11
11
|
].freeze
|
|
12
12
|
|
|
13
13
|
def self.included(base)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module AI
|
|
5
|
+
module ConversationScope
|
|
6
|
+
THREAD_KEY = :chat_sdk_ai_conversation_scope
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def current
|
|
10
|
+
::Thread.current[THREAD_KEY]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def with(thread)
|
|
14
|
+
previous = current
|
|
15
|
+
::Thread.current[THREAD_KEY] = scope_for(thread)
|
|
16
|
+
yield
|
|
17
|
+
ensure
|
|
18
|
+
::Thread.current[THREAD_KEY] = previous
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def scope_for(value)
|
|
22
|
+
return if value.nil?
|
|
23
|
+
return value.transform_keys(&:to_sym) if value.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
adapter_name: value.adapter.name.to_sym,
|
|
27
|
+
channel_id: value.respond_to?(:channel_id) ? value.channel_id.to_s : value.id.to_s,
|
|
28
|
+
thread_id: value.is_a?(ChatSDK::Thread) ? value.id.to_s : nil
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -10,7 +10,7 @@ module ChatSDK
|
|
|
10
10
|
def to_ai_messages(messages, include_names: false, &transform)
|
|
11
11
|
messages
|
|
12
12
|
.sort_by { |m| m.timestamp || m.id }
|
|
13
|
-
.reject { |m|
|
|
13
|
+
.reject { |m| blank_message?(m) }
|
|
14
14
|
.filter_map { |m| convert_message(m, include_names: include_names, &transform) }
|
|
15
15
|
end
|
|
16
16
|
|
|
@@ -19,16 +19,22 @@ module ChatSDK
|
|
|
19
19
|
def convert_message(message, include_names: false)
|
|
20
20
|
role = message.author&.bot? ? ROLE_ASSISTANT : ROLE_USER
|
|
21
21
|
|
|
22
|
-
content = message.text
|
|
22
|
+
content = message.text.to_s
|
|
23
23
|
if include_names && role == ROLE_USER && message.author
|
|
24
24
|
content = "[#{message.author.name}]: #{content}"
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
result = {role: role, content: content}
|
|
28
28
|
|
|
29
|
-
if message.attachments&.any?
|
|
30
|
-
parts = [
|
|
31
|
-
|
|
29
|
+
if message.attachments&.any? || message.links&.any?
|
|
30
|
+
parts = []
|
|
31
|
+
parts << {type: "text", text: content} unless content.strip.empty?
|
|
32
|
+
Array(message.links).each do |link|
|
|
33
|
+
url = link.is_a?(Hash) ? (link[:url] || link["url"]) : link.to_s
|
|
34
|
+
title = link.is_a?(Hash) ? (link[:title] || link["title"]) : nil
|
|
35
|
+
parts << {type: "text", text: title ? "[#{title}](#{url})" : url}
|
|
36
|
+
end
|
|
37
|
+
Array(message.attachments).each do |att|
|
|
32
38
|
parts << attachment_to_part(att)
|
|
33
39
|
end
|
|
34
40
|
result[:content] = parts
|
|
@@ -38,6 +44,11 @@ module ChatSDK
|
|
|
38
44
|
result
|
|
39
45
|
end
|
|
40
46
|
|
|
47
|
+
def blank_message?(message)
|
|
48
|
+
text_blank = message.text.nil? || message.text.strip.empty?
|
|
49
|
+
text_blank && !message.attachments&.any? && !message.links&.any?
|
|
50
|
+
end
|
|
51
|
+
|
|
41
52
|
def attachment_to_part(attachment)
|
|
42
53
|
if attachment.is_a?(Hash)
|
|
43
54
|
mime = attachment[:mime_type] || attachment[:content_type] || "application/octet-stream"
|
|
@@ -72,6 +72,7 @@ module ChatSDK
|
|
|
72
72
|
properties: {
|
|
73
73
|
adapter_name: {type: "string", description: "Adapter name"},
|
|
74
74
|
channel_id: {type: "string", description: "Channel ID"},
|
|
75
|
+
thread_id: {type: "string", description: "Thread ID (optional)"},
|
|
75
76
|
message_id: {type: "string", description: "Message ID to edit"},
|
|
76
77
|
text: {type: "string", description: "New message text"}
|
|
77
78
|
},
|
|
@@ -86,6 +87,7 @@ module ChatSDK
|
|
|
86
87
|
properties: {
|
|
87
88
|
adapter_name: {type: "string", description: "Adapter name"},
|
|
88
89
|
channel_id: {type: "string", description: "Channel ID"},
|
|
90
|
+
thread_id: {type: "string", description: "Thread ID (optional)"},
|
|
89
91
|
message_id: {type: "string", description: "Message ID to delete"}
|
|
90
92
|
},
|
|
91
93
|
required: %w[adapter_name channel_id message_id]
|
|
@@ -99,6 +101,7 @@ module ChatSDK
|
|
|
99
101
|
properties: {
|
|
100
102
|
adapter_name: {type: "string", description: "Adapter name"},
|
|
101
103
|
channel_id: {type: "string", description: "Channel ID"},
|
|
104
|
+
thread_id: {type: "string", description: "Thread ID (optional)"},
|
|
102
105
|
message_id: {type: "string", description: "Message ID"},
|
|
103
106
|
emoji: {type: "string", description: "Emoji name (e.g., 'thumbsup')"}
|
|
104
107
|
},
|
|
@@ -113,6 +116,7 @@ module ChatSDK
|
|
|
113
116
|
properties: {
|
|
114
117
|
adapter_name: {type: "string", description: "Adapter name"},
|
|
115
118
|
channel_id: {type: "string", description: "Channel ID"},
|
|
119
|
+
thread_id: {type: "string", description: "Thread ID (optional)"},
|
|
116
120
|
message_id: {type: "string", description: "Message ID"},
|
|
117
121
|
emoji: {type: "string", description: "Emoji name"}
|
|
118
122
|
},
|
|
@@ -145,10 +149,21 @@ module ChatSDK
|
|
|
145
149
|
tool_names = PRESETS[@preset]
|
|
146
150
|
tool_names.each_with_object({}) do |name, tools|
|
|
147
151
|
defn = TOOL_DEFINITIONS[name].dup
|
|
148
|
-
defn[:requires_approval] =
|
|
152
|
+
defn[:requires_approval] = approval_required?(name, defn)
|
|
149
153
|
tools[name] = defn
|
|
150
154
|
end
|
|
151
155
|
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def approval_required?(name, definition)
|
|
160
|
+
return false if definition[:read_only] || @require_approval == false
|
|
161
|
+
return true unless @require_approval.is_a?(Hash)
|
|
162
|
+
|
|
163
|
+
@require_approval.fetch(name) do
|
|
164
|
+
@require_approval.fetch(name.to_s, true)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
152
167
|
end
|
|
153
168
|
end
|
|
154
169
|
end
|
|
@@ -3,8 +3,15 @@
|
|
|
3
3
|
module ChatSDK
|
|
4
4
|
module AI
|
|
5
5
|
class ToolExecutor
|
|
6
|
-
def initialize(chat:)
|
|
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
|
|
13
|
+
@strict_scope = strict_scope
|
|
14
|
+
@warned_unscoped = false
|
|
8
15
|
end
|
|
9
16
|
|
|
10
17
|
def execute(tool_name, arguments)
|
|
@@ -13,6 +20,7 @@ module ChatSDK
|
|
|
13
20
|
|
|
14
21
|
args = arguments.transform_keys(&:to_sym)
|
|
15
22
|
adapter_name = args[:adapter_name].to_sym
|
|
23
|
+
guard_scope!(adapter_name, args) unless tool_name == :send_direct_message
|
|
16
24
|
|
|
17
25
|
send(:"execute_#{tool_name}", adapter_name, args)
|
|
18
26
|
end
|
|
@@ -56,25 +64,25 @@ module ChatSDK
|
|
|
56
64
|
end
|
|
57
65
|
|
|
58
66
|
def execute_edit_message(adapter_name, args)
|
|
59
|
-
thread =
|
|
67
|
+
thread = target_thread(adapter_name, args)
|
|
60
68
|
thread.edit(args[:message_id], args[:text])
|
|
61
69
|
{success: true}
|
|
62
70
|
end
|
|
63
71
|
|
|
64
72
|
def execute_delete_message(adapter_name, args)
|
|
65
|
-
thread =
|
|
73
|
+
thread = target_thread(adapter_name, args)
|
|
66
74
|
thread.delete(args[:message_id])
|
|
67
75
|
{success: true}
|
|
68
76
|
end
|
|
69
77
|
|
|
70
78
|
def execute_add_reaction(adapter_name, args)
|
|
71
|
-
thread =
|
|
79
|
+
thread = target_thread(adapter_name, args)
|
|
72
80
|
thread.react(args[:message_id], args[:emoji])
|
|
73
81
|
{success: true}
|
|
74
82
|
end
|
|
75
83
|
|
|
76
84
|
def execute_remove_reaction(adapter_name, args)
|
|
77
|
-
thread =
|
|
85
|
+
thread = target_thread(adapter_name, args)
|
|
78
86
|
thread.unreact(args[:message_id], args[:emoji])
|
|
79
87
|
{success: true}
|
|
80
88
|
end
|
|
@@ -88,6 +96,35 @@ module ChatSDK
|
|
|
88
96
|
def serialize_messages(messages)
|
|
89
97
|
messages.map { |m| {id: m.id, text: m.text, author: m.author&.name, timestamp: m.timestamp} }
|
|
90
98
|
end
|
|
99
|
+
|
|
100
|
+
def target_thread(adapter_name, args)
|
|
101
|
+
channel = @chat.channel(args[:channel_id], adapter_name: adapter_name)
|
|
102
|
+
channel.thread(args[:thread_id] || args[:channel_id])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def guard_scope!(adapter_name, args)
|
|
106
|
+
return if @scope == false
|
|
107
|
+
|
|
108
|
+
scope = @scope || ConversationScope.current
|
|
109
|
+
unless scope
|
|
110
|
+
unless @warned_unscoped
|
|
111
|
+
ChatSDK::Log.warn("AI tool ran without a conversation scope; pass scope: to create_executor to confine access")
|
|
112
|
+
@warned_unscoped = true
|
|
113
|
+
end
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
target_channel = args[:channel_id].to_s
|
|
118
|
+
target_thread = args[:thread_id]&.to_s
|
|
119
|
+
same_channel = adapter_name == scope.fetch(:adapter_name).to_sym && target_channel == scope.fetch(:channel_id).to_s
|
|
120
|
+
scope_is_channel = scope[:thread_id].nil?
|
|
121
|
+
in_scope = same_channel && (!@strict_scope || scope_is_channel || target_thread == scope[:thread_id].to_s)
|
|
122
|
+
return if in_scope
|
|
123
|
+
|
|
124
|
+
target = [adapter_name, target_channel, target_thread].compact.join(":")
|
|
125
|
+
active = [scope[:adapter_name], scope[:channel_id], scope[:thread_id]].compact.join(":")
|
|
126
|
+
raise ChatSDK::Error, "AI tool call blocked: executor is scoped to #{active.inspect}, but targeted #{target.inspect}"
|
|
127
|
+
end
|
|
91
128
|
end
|
|
92
129
|
end
|
|
93
130
|
end
|
data/lib/chat_sdk/ai.rb
CHANGED
|
@@ -11,8 +11,8 @@ module ChatSDK
|
|
|
11
11
|
ToolBuilder.new(preset: preset, require_approval: require_approval).build
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def create_executor(chat:)
|
|
15
|
-
ToolExecutor.new(chat: chat)
|
|
14
|
+
def create_executor(chat:, scope: nil, strict_scope: false)
|
|
15
|
+
ToolExecutor.new(chat: chat, scope: scope, strict_scope: strict_scope)
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
end
|
data/lib/chat_sdk/author.rb
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module ChatSDK
|
|
4
4
|
class Author
|
|
5
|
-
attr_reader :id, :name, :platform, :locale, :raw
|
|
5
|
+
attr_reader :id, :name, :platform, :locale, :email, :raw
|
|
6
6
|
|
|
7
|
-
def initialize(id:, name:, platform:, bot: false, locale: nil, raw: nil)
|
|
7
|
+
def initialize(id:, name:, platform:, bot: 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
|
+
@system = system
|
|
12
13
|
@locale = locale
|
|
14
|
+
@email = email
|
|
13
15
|
@raw = raw
|
|
14
16
|
end
|
|
15
17
|
|
|
@@ -17,6 +19,10 @@ module ChatSDK
|
|
|
17
19
|
@bot
|
|
18
20
|
end
|
|
19
21
|
|
|
22
|
+
def system?
|
|
23
|
+
@system
|
|
24
|
+
end
|
|
25
|
+
|
|
20
26
|
def ==(other)
|
|
21
27
|
other.is_a?(Author) && id == other.id && platform == other.platform
|
|
22
28
|
end
|
|
@@ -9,15 +9,19 @@ module ChatSDK
|
|
|
9
9
|
@nodes = []
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def button(text, id:, style: nil, value: nil)
|
|
12
|
+
def button(text, id:, style: nil, value: nil, tooltip: nil)
|
|
13
13
|
attrs = {text: text, id: id}
|
|
14
14
|
attrs[:style] = style if style
|
|
15
15
|
attrs[:value] = value if value
|
|
16
|
+
attrs[:tooltip] = tooltip if tooltip
|
|
16
17
|
@nodes << Node.new(:button, attributes: attrs)
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
def link_button(text, url:)
|
|
20
|
-
|
|
20
|
+
def link_button(text, url:, id: nil, tooltip: nil)
|
|
21
|
+
attrs = {text: text, url: url}
|
|
22
|
+
attrs[:id] = id if id
|
|
23
|
+
attrs[:tooltip] = tooltip if tooltip
|
|
24
|
+
@nodes << Node.new(:link_button, attributes: attrs)
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def select(id:, placeholder: nil, &block)
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
module ChatSDK
|
|
4
4
|
module Cards
|
|
5
5
|
class Builder
|
|
6
|
-
def initialize(title: nil, subtitle: nil, &block)
|
|
6
|
+
def initialize(title: nil, subtitle: nil, width: nil, &block)
|
|
7
7
|
@title = title
|
|
8
8
|
@subtitle = subtitle
|
|
9
|
+
@width = width
|
|
9
10
|
@children = []
|
|
10
11
|
instance_eval(&block) if block
|
|
11
12
|
end
|
|
@@ -14,6 +15,7 @@ module ChatSDK
|
|
|
14
15
|
attrs = {}
|
|
15
16
|
attrs[:title] = @title if @title
|
|
16
17
|
attrs[:subtitle] = @subtitle if @subtitle
|
|
18
|
+
attrs[:width] = @width if @width
|
|
17
19
|
Node.new(:card, attributes: attrs, children: @children)
|
|
18
20
|
end
|
|
19
21
|
|
|
@@ -47,6 +49,24 @@ module ChatSDK
|
|
|
47
49
|
ctx.instance_eval(&block)
|
|
48
50
|
@children << Node.new(:actions, children: ctx.nodes)
|
|
49
51
|
end
|
|
52
|
+
|
|
53
|
+
def table(headers:, rows:, align: nil, caption: nil, page_size: nil)
|
|
54
|
+
attrs = {headers: headers, rows: rows}
|
|
55
|
+
attrs[:align] = align if align
|
|
56
|
+
attrs[:caption] = caption if caption
|
|
57
|
+
attrs[:page_size] = page_size if page_size
|
|
58
|
+
@children << Node.new(:table, attributes: attrs)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def chart(title:, type:, segments: nil, categories: nil, series: nil, x_label: nil, y_label: nil)
|
|
62
|
+
attrs = {title: title, chart_type: type}
|
|
63
|
+
attrs[:segments] = segments if segments
|
|
64
|
+
attrs[:categories] = categories if categories
|
|
65
|
+
attrs[:series] = series if series
|
|
66
|
+
attrs[:x_label] = x_label if x_label
|
|
67
|
+
attrs[:y_label] = y_label if y_label
|
|
68
|
+
@children << Node.new(:chart, attributes: attrs)
|
|
69
|
+
end
|
|
50
70
|
end
|
|
51
71
|
end
|
|
52
72
|
end
|
data/lib/chat_sdk/cards/node.rb
CHANGED
|
@@ -31,11 +31,45 @@ module ChatSDK
|
|
|
31
31
|
["#{node.attributes[:label]}: #{node.attributes[:value]}"]
|
|
32
32
|
when :button, :link_button
|
|
33
33
|
[node.attributes[:text]]
|
|
34
|
+
when :table
|
|
35
|
+
table_text(node)
|
|
36
|
+
when :chart
|
|
37
|
+
chart_text(node)
|
|
34
38
|
else
|
|
35
39
|
collect_text(node.children)
|
|
36
40
|
end
|
|
37
41
|
end
|
|
38
42
|
end
|
|
43
|
+
|
|
44
|
+
def table_text(node)
|
|
45
|
+
headers = Array(node.attributes[:headers]).map(&:to_s)
|
|
46
|
+
rows = Array(node.attributes[:rows]).map { |row| Array(row).map(&:to_s) }
|
|
47
|
+
lines = []
|
|
48
|
+
lines << node.attributes[:caption].to_s if node.attributes[:caption]
|
|
49
|
+
lines << headers.join(" | ") unless headers.empty?
|
|
50
|
+
lines.concat(rows.map { |row| row.join(" | ") })
|
|
51
|
+
lines
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def chart_text(node)
|
|
55
|
+
lines = [node.attributes[:title].to_s]
|
|
56
|
+
if node.attributes[:chart_type].to_sym == :pie
|
|
57
|
+
lines.concat(Array(node.attributes[:segments]).map do |segment|
|
|
58
|
+
"#{value_for(segment, :label)}: #{value_for(segment, :value)}"
|
|
59
|
+
end)
|
|
60
|
+
else
|
|
61
|
+
Array(node.attributes[:series]).each do |series|
|
|
62
|
+
data = value_for(series, :data) || []
|
|
63
|
+
points = data.map { |point| "#{value_for(point, :label)}=#{value_for(point, :value)}" }
|
|
64
|
+
lines << "#{value_for(series, :name)}: #{points.join(", ")}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
lines
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def value_for(hash, key)
|
|
71
|
+
hash[key] || hash[key.to_s]
|
|
72
|
+
end
|
|
39
73
|
end
|
|
40
74
|
end
|
|
41
75
|
end
|
|
@@ -16,6 +16,8 @@ module ChatSDK
|
|
|
16
16
|
when :button then "[#{node.attributes[:text]}]"
|
|
17
17
|
when :link_button then "[#{node.attributes[:text]}](#{node.attributes[:url]})"
|
|
18
18
|
when :select then "_#{node.attributes[:placeholder] || "Select"}_"
|
|
19
|
+
when :table then render_table(node)
|
|
20
|
+
when :chart then render_chart(node)
|
|
19
21
|
else ""
|
|
20
22
|
end
|
|
21
23
|
end
|
|
@@ -44,6 +46,23 @@ module ChatSDK
|
|
|
44
46
|
def render_actions(node)
|
|
45
47
|
node.children.map { |c| render(c) }.join(" | ")
|
|
46
48
|
end
|
|
49
|
+
|
|
50
|
+
def render_table(node)
|
|
51
|
+
headers = Array(node.attributes[:headers]).map(&:to_s)
|
|
52
|
+
rows = Array(node.attributes[:rows]).map { |row| Array(row).map(&:to_s) }
|
|
53
|
+
parts = []
|
|
54
|
+
parts << node.attributes[:caption].to_s if node.attributes[:caption]
|
|
55
|
+
unless headers.empty?
|
|
56
|
+
parts << "| #{headers.join(" | ")} |"
|
|
57
|
+
parts << "| #{headers.map { "---" }.join(" | ")} |"
|
|
58
|
+
end
|
|
59
|
+
parts.concat(rows.map { |row| "| #{row.join(" | ")} |" })
|
|
60
|
+
parts.join("\n")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def render_chart(node)
|
|
64
|
+
node.fallback_text
|
|
65
|
+
end
|
|
47
66
|
end
|
|
48
67
|
end
|
|
49
68
|
end
|
data/lib/chat_sdk/channel.rb
CHANGED
|
@@ -19,6 +19,14 @@ module ChatSDK
|
|
|
19
19
|
ChatSDK::Thread.new(id: thread_id, channel_id: id, adapter: adapter, chat: chat)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def messages(cursor: nil, limit: 50)
|
|
23
|
+
adapter.fetch_channel_messages(channel_id: id, cursor: cursor, limit: limit)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def threads(cursor: nil, limit: 50)
|
|
27
|
+
adapter.list_threads(channel_id: id, cursor: cursor, limit: limit)
|
|
28
|
+
end
|
|
29
|
+
|
|
22
30
|
def ==(other)
|
|
23
31
|
other.is_a?(Channel) && id == other.id
|
|
24
32
|
end
|
data/lib/chat_sdk/chat.rb
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
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
11
|
@registry = EventRegistry.new
|
|
12
|
+
@history = History.new(self)
|
|
12
13
|
@webhooks = {}
|
|
13
14
|
@dispatcher = Dispatcher.new(chat: self, config: @config, state: @state, registry: @registry)
|
|
14
15
|
|
|
@@ -32,6 +33,14 @@ module ChatSDK
|
|
|
32
33
|
@registry.register(:direct_message, &block)
|
|
33
34
|
end
|
|
34
35
|
|
|
36
|
+
def on_message_updated(&block)
|
|
37
|
+
@registry.register(:message_updated, &block)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def on_message_deleted(&block)
|
|
41
|
+
@registry.register(:message_deleted, &block)
|
|
42
|
+
end
|
|
43
|
+
|
|
35
44
|
def on_reaction(emojis = nil, &block)
|
|
36
45
|
@registry.register(:reaction, matcher: emojis, &block)
|
|
37
46
|
end
|
|
@@ -61,6 +70,11 @@ module ChatSDK
|
|
|
61
70
|
Channel.new(id: channel_id, adapter: adp, chat: self)
|
|
62
71
|
end
|
|
63
72
|
|
|
73
|
+
def thread(id, channel_id:, adapter_name: nil)
|
|
74
|
+
adp = adapter_name ? adapter(adapter_name) : @adapters.values.first
|
|
75
|
+
Thread.new(id: id, channel_id: channel_id, adapter: adp, chat: self)
|
|
76
|
+
end
|
|
77
|
+
|
|
64
78
|
# Webhook endpoints (Rack apps)
|
|
65
79
|
def webhooks
|
|
66
80
|
@webhook_accessor ||= WebhookAccessor.new(self, @adapters)
|
|
@@ -72,6 +86,11 @@ module ChatSDK
|
|
|
72
86
|
@dispatcher.dispatch(event, adapter: adp, adapter_name: adapter_name)
|
|
73
87
|
end
|
|
74
88
|
|
|
89
|
+
# Deprecated compatibility alias for Vercel Chat SDK's former API.
|
|
90
|
+
def transcripts
|
|
91
|
+
history.user
|
|
92
|
+
end
|
|
93
|
+
|
|
75
94
|
private
|
|
76
95
|
end
|
|
77
96
|
|
data/lib/chat_sdk/config.rb
CHANGED
|
@@ -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
|