chat_sdk 0.1.0 → 0.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 +8 -0
- data/lib/chat_sdk/adapter/meta_verification.rb +42 -0
- data/lib/chat_sdk/ai/converter.rb +56 -0
- data/lib/chat_sdk/ai/stream_handler.rb +13 -0
- data/lib/chat_sdk/ai/tool_builder.rb +154 -0
- data/lib/chat_sdk/ai/tool_executor.rb +93 -0
- data/lib/chat_sdk/ai.rb +19 -0
- data/lib/chat_sdk/api_client/base.rb +99 -0
- data/lib/chat_sdk/cards/node.rb +0 -4
- data/lib/chat_sdk/cards/text_helpers.rb +34 -0
- data/lib/chat_sdk/dispatcher.rb +35 -41
- data/lib/chat_sdk/events/action.rb +1 -0
- data/lib/chat_sdk/events/reaction.rb +1 -0
- data/lib/chat_sdk/events/slash_command.rb +1 -0
- data/lib/chat_sdk/instrumentation.rb +55 -0
- data/lib/chat_sdk/thread.rb +4 -0
- data/lib/chat_sdk/version.rb +1 -1
- data/lib/chat_sdk.rb +1 -1
- metadata +13 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b44dd1b3b15cb4a83647923c0f75ad2208860060010a1e2c47391d1f4b44ca5a
|
|
4
|
+
data.tar.gz: b6e99a0348c858130e767f6647fa1a430d45e9497e6a7d652343dc4d1e0ef4de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4983f6b3a956b5b0cbe2d84c23b739d1ae6f5c8eb34484a42ea9e0f90fe37c0a390a2e0d5bbcf9b7e0a5f993e1395d38d7d830405d67f4de5183918d1bab38e6
|
|
7
|
+
data.tar.gz: bbd3377c30c6e5fc53e9959a76d96a4eb907743f834b4c4d554317240861d8ab60b85372ab51e911ba720d601abcc6ddb15646053c8409c878f865a141ab3ba6
|
|
@@ -89,6 +89,14 @@ module ChatSDK
|
|
|
89
89
|
Cards::Renderer.new.render(postable_message.card)
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
protected
|
|
93
|
+
|
|
94
|
+
def read_json_body(rack_request)
|
|
95
|
+
body = rack_request.body.read
|
|
96
|
+
rack_request.body.rewind
|
|
97
|
+
JSON.parse(body)
|
|
98
|
+
end
|
|
99
|
+
|
|
92
100
|
private
|
|
93
101
|
|
|
94
102
|
def require_capability!(cap)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
module ChatSDK
|
|
7
|
+
module Adapter
|
|
8
|
+
module MetaVerification
|
|
9
|
+
def verify_meta_signature!(rack_request, secret:, platform_name:)
|
|
10
|
+
signature = rack_request.get_header("HTTP_X_HUB_SIGNATURE_256")
|
|
11
|
+
|
|
12
|
+
unless signature
|
|
13
|
+
raise ChatSDK::SignatureVerificationError, "Missing #{platform_name} signature header"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
body = rack_request.body.read
|
|
17
|
+
rack_request.body.rewind
|
|
18
|
+
|
|
19
|
+
expected = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", secret, body)}"
|
|
20
|
+
|
|
21
|
+
unless Rack::Utils.secure_compare(signature, expected)
|
|
22
|
+
raise ChatSDK::SignatureVerificationError, "Invalid #{platform_name} signature"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def meta_ack_response(rack_request, verify_token:)
|
|
29
|
+
return nil unless rack_request.get?
|
|
30
|
+
|
|
31
|
+
params = rack_request.params
|
|
32
|
+
mode = params["hub.mode"]
|
|
33
|
+
token = params["hub.verify_token"]
|
|
34
|
+
challenge = params["hub.challenge"]
|
|
35
|
+
|
|
36
|
+
if mode == "subscribe" && token == verify_token
|
|
37
|
+
[200, {}, [challenge.to_s]]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module AI
|
|
5
|
+
class Converter
|
|
6
|
+
ROLE_USER = "user"
|
|
7
|
+
ROLE_ASSISTANT = "assistant"
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def to_ai_messages(messages, include_names: false, &transform)
|
|
11
|
+
messages
|
|
12
|
+
.sort_by { |m| m.timestamp || m.id }
|
|
13
|
+
.reject { |m| m.text.nil? || m.text.strip.empty? }
|
|
14
|
+
.filter_map { |m| convert_message(m, include_names: include_names, &transform) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def convert_message(message, include_names: false)
|
|
20
|
+
role = message.author&.bot? ? ROLE_ASSISTANT : ROLE_USER
|
|
21
|
+
|
|
22
|
+
content = message.text
|
|
23
|
+
if include_names && role == ROLE_USER && message.author
|
|
24
|
+
content = "[#{message.author.name}]: #{content}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
result = {role: role, content: content}
|
|
28
|
+
|
|
29
|
+
if message.attachments&.any?
|
|
30
|
+
parts = [{type: "text", text: content}]
|
|
31
|
+
message.attachments.each do |att|
|
|
32
|
+
parts << attachment_to_part(att)
|
|
33
|
+
end
|
|
34
|
+
result[:content] = parts
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
result = yield(result, message) if block_given?
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def attachment_to_part(attachment)
|
|
42
|
+
if attachment.is_a?(Hash)
|
|
43
|
+
mime = attachment[:mime_type] || attachment[:content_type] || "application/octet-stream"
|
|
44
|
+
if mime.start_with?("image/")
|
|
45
|
+
{type: "image", url: attachment[:url], media_type: mime}
|
|
46
|
+
else
|
|
47
|
+
{type: "file", url: attachment[:url], filename: attachment[:filename], media_type: mime}
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
{type: "text", text: attachment.to_s}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module AI
|
|
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 }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module AI
|
|
5
|
+
class ToolBuilder
|
|
6
|
+
PRESETS = {
|
|
7
|
+
reader: %i[fetch_messages fetch_thread],
|
|
8
|
+
messenger: %i[fetch_messages fetch_thread post_message send_direct_message add_reaction start_typing],
|
|
9
|
+
moderator: %i[fetch_messages fetch_thread post_message send_direct_message add_reaction
|
|
10
|
+
edit_message delete_message remove_reaction start_typing]
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
TOOL_DEFINITIONS = {
|
|
14
|
+
fetch_messages: {
|
|
15
|
+
description: "Fetch recent messages from a channel or thread",
|
|
16
|
+
parameters: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
adapter_name: {type: "string", description: "Adapter name (e.g., 'slack', 'teams')"},
|
|
20
|
+
channel_id: {type: "string", description: "Channel ID"},
|
|
21
|
+
thread_id: {type: "string", description: "Thread ID (optional)"},
|
|
22
|
+
limit: {type: "integer", description: "Max messages to fetch", default: 20}
|
|
23
|
+
},
|
|
24
|
+
required: %w[adapter_name channel_id]
|
|
25
|
+
},
|
|
26
|
+
read_only: true
|
|
27
|
+
},
|
|
28
|
+
fetch_thread: {
|
|
29
|
+
description: "Fetch all messages in a specific thread",
|
|
30
|
+
parameters: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
adapter_name: {type: "string", description: "Adapter name"},
|
|
34
|
+
channel_id: {type: "string", description: "Channel ID"},
|
|
35
|
+
thread_id: {type: "string", description: "Thread ID"}
|
|
36
|
+
},
|
|
37
|
+
required: %w[adapter_name channel_id thread_id]
|
|
38
|
+
},
|
|
39
|
+
read_only: true
|
|
40
|
+
},
|
|
41
|
+
post_message: {
|
|
42
|
+
description: "Post a message to a channel or thread",
|
|
43
|
+
parameters: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
adapter_name: {type: "string", description: "Adapter name"},
|
|
47
|
+
channel_id: {type: "string", description: "Channel ID"},
|
|
48
|
+
thread_id: {type: "string", description: "Thread ID (optional, for replies)"},
|
|
49
|
+
text: {type: "string", description: "Message text (markdown)"}
|
|
50
|
+
},
|
|
51
|
+
required: %w[adapter_name channel_id text]
|
|
52
|
+
},
|
|
53
|
+
read_only: false
|
|
54
|
+
},
|
|
55
|
+
send_direct_message: {
|
|
56
|
+
description: "Send a direct message to a user",
|
|
57
|
+
parameters: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
adapter_name: {type: "string", description: "Adapter name"},
|
|
61
|
+
user_id: {type: "string", description: "User ID"},
|
|
62
|
+
text: {type: "string", description: "Message text"}
|
|
63
|
+
},
|
|
64
|
+
required: %w[adapter_name user_id text]
|
|
65
|
+
},
|
|
66
|
+
read_only: false
|
|
67
|
+
},
|
|
68
|
+
edit_message: {
|
|
69
|
+
description: "Edit an existing message",
|
|
70
|
+
parameters: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
adapter_name: {type: "string", description: "Adapter name"},
|
|
74
|
+
channel_id: {type: "string", description: "Channel ID"},
|
|
75
|
+
message_id: {type: "string", description: "Message ID to edit"},
|
|
76
|
+
text: {type: "string", description: "New message text"}
|
|
77
|
+
},
|
|
78
|
+
required: %w[adapter_name channel_id message_id text]
|
|
79
|
+
},
|
|
80
|
+
read_only: false
|
|
81
|
+
},
|
|
82
|
+
delete_message: {
|
|
83
|
+
description: "Delete a message",
|
|
84
|
+
parameters: {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
adapter_name: {type: "string", description: "Adapter name"},
|
|
88
|
+
channel_id: {type: "string", description: "Channel ID"},
|
|
89
|
+
message_id: {type: "string", description: "Message ID to delete"}
|
|
90
|
+
},
|
|
91
|
+
required: %w[adapter_name channel_id message_id]
|
|
92
|
+
},
|
|
93
|
+
read_only: false
|
|
94
|
+
},
|
|
95
|
+
add_reaction: {
|
|
96
|
+
description: "Add an emoji reaction to a message",
|
|
97
|
+
parameters: {
|
|
98
|
+
type: "object",
|
|
99
|
+
properties: {
|
|
100
|
+
adapter_name: {type: "string", description: "Adapter name"},
|
|
101
|
+
channel_id: {type: "string", description: "Channel ID"},
|
|
102
|
+
message_id: {type: "string", description: "Message ID"},
|
|
103
|
+
emoji: {type: "string", description: "Emoji name (e.g., 'thumbsup')"}
|
|
104
|
+
},
|
|
105
|
+
required: %w[adapter_name channel_id message_id emoji]
|
|
106
|
+
},
|
|
107
|
+
read_only: false
|
|
108
|
+
},
|
|
109
|
+
remove_reaction: {
|
|
110
|
+
description: "Remove an emoji reaction from a message",
|
|
111
|
+
parameters: {
|
|
112
|
+
type: "object",
|
|
113
|
+
properties: {
|
|
114
|
+
adapter_name: {type: "string", description: "Adapter name"},
|
|
115
|
+
channel_id: {type: "string", description: "Channel ID"},
|
|
116
|
+
message_id: {type: "string", description: "Message ID"},
|
|
117
|
+
emoji: {type: "string", description: "Emoji name"}
|
|
118
|
+
},
|
|
119
|
+
required: %w[adapter_name channel_id message_id emoji]
|
|
120
|
+
},
|
|
121
|
+
read_only: false
|
|
122
|
+
},
|
|
123
|
+
start_typing: {
|
|
124
|
+
description: "Show typing indicator in a channel",
|
|
125
|
+
parameters: {
|
|
126
|
+
type: "object",
|
|
127
|
+
properties: {
|
|
128
|
+
adapter_name: {type: "string", description: "Adapter name"},
|
|
129
|
+
channel_id: {type: "string", description: "Channel ID"},
|
|
130
|
+
thread_id: {type: "string", description: "Thread ID (optional)"}
|
|
131
|
+
},
|
|
132
|
+
required: %w[adapter_name channel_id]
|
|
133
|
+
},
|
|
134
|
+
read_only: false
|
|
135
|
+
}
|
|
136
|
+
}.freeze
|
|
137
|
+
|
|
138
|
+
def initialize(preset: :messenger, require_approval: true)
|
|
139
|
+
@preset = preset.to_sym
|
|
140
|
+
@require_approval = require_approval
|
|
141
|
+
raise ChatSDK::ConfigurationError, "Unknown preset: #{@preset}" unless PRESETS.key?(@preset)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def build
|
|
145
|
+
tool_names = PRESETS[@preset]
|
|
146
|
+
tool_names.each_with_object({}) do |name, tools|
|
|
147
|
+
defn = TOOL_DEFINITIONS[name].dup
|
|
148
|
+
defn[:requires_approval] = @require_approval && !defn[:read_only]
|
|
149
|
+
tools[name] = defn
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module AI
|
|
5
|
+
class ToolExecutor
|
|
6
|
+
def initialize(chat:)
|
|
7
|
+
@chat = chat
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def execute(tool_name, arguments)
|
|
11
|
+
tool_name = tool_name.to_sym
|
|
12
|
+
raise ChatSDK::Error, "Unknown tool: #{tool_name}" unless ToolBuilder::TOOL_DEFINITIONS.key?(tool_name)
|
|
13
|
+
|
|
14
|
+
args = arguments.transform_keys(&:to_sym)
|
|
15
|
+
adapter_name = args[:adapter_name].to_sym
|
|
16
|
+
|
|
17
|
+
send(:"execute_#{tool_name}", adapter_name, args)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def execute_fetch_messages(adapter_name, args)
|
|
23
|
+
channel = @chat.channel(args[:channel_id], adapter_name: adapter_name)
|
|
24
|
+
messages, _cursor = channel.adapter.fetch_messages(
|
|
25
|
+
channel_id: args[:channel_id],
|
|
26
|
+
thread_id: args[:thread_id],
|
|
27
|
+
limit: args[:limit] || 20
|
|
28
|
+
)
|
|
29
|
+
serialize_messages(messages)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def execute_fetch_thread(adapter_name, args)
|
|
33
|
+
channel = @chat.channel(args[:channel_id], adapter_name: adapter_name)
|
|
34
|
+
messages, _cursor = channel.adapter.fetch_messages(
|
|
35
|
+
channel_id: args[:channel_id],
|
|
36
|
+
thread_id: args[:thread_id]
|
|
37
|
+
)
|
|
38
|
+
serialize_messages(messages)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def execute_post_message(adapter_name, args)
|
|
42
|
+
channel = @chat.channel(args[:channel_id], adapter_name: adapter_name)
|
|
43
|
+
if args[:thread_id]
|
|
44
|
+
thread = channel.thread(args[:thread_id])
|
|
45
|
+
result = thread.post(args[:text])
|
|
46
|
+
else
|
|
47
|
+
result = channel.post(args[:text])
|
|
48
|
+
end
|
|
49
|
+
{id: result.id, text: args[:text]}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def execute_send_direct_message(adapter_name, args)
|
|
53
|
+
dm_channel = @chat.open_dm(args[:user_id], adapter_name: adapter_name)
|
|
54
|
+
result = dm_channel.post(args[:text])
|
|
55
|
+
{id: result.id, channel_id: dm_channel.id}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def execute_edit_message(adapter_name, args)
|
|
59
|
+
thread = @chat.channel(args[:channel_id], adapter_name: adapter_name).thread(args[:channel_id])
|
|
60
|
+
thread.edit(args[:message_id], args[:text])
|
|
61
|
+
{success: true}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def execute_delete_message(adapter_name, args)
|
|
65
|
+
thread = @chat.channel(args[:channel_id], adapter_name: adapter_name).thread(args[:channel_id])
|
|
66
|
+
thread.delete(args[:message_id])
|
|
67
|
+
{success: true}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def execute_add_reaction(adapter_name, args)
|
|
71
|
+
thread = @chat.channel(args[:channel_id], adapter_name: adapter_name).thread(args[:channel_id])
|
|
72
|
+
thread.react(args[:message_id], args[:emoji])
|
|
73
|
+
{success: true}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def execute_remove_reaction(adapter_name, args)
|
|
77
|
+
thread = @chat.channel(args[:channel_id], adapter_name: adapter_name).thread(args[:channel_id])
|
|
78
|
+
thread.unreact(args[:message_id], args[:emoji])
|
|
79
|
+
{success: true}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def execute_start_typing(adapter_name, args)
|
|
83
|
+
adapter = @chat.adapter(adapter_name)
|
|
84
|
+
adapter.start_typing(channel_id: args[:channel_id], thread_id: args[:thread_id])
|
|
85
|
+
{success: true}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def serialize_messages(messages)
|
|
89
|
+
messages.map { |m| {id: m.id, text: m.text, author: m.author&.name, timestamp: m.timestamp} }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/chat_sdk/ai.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module AI
|
|
5
|
+
class << self
|
|
6
|
+
def to_ai_messages(messages, include_names: false, &transform)
|
|
7
|
+
Converter.to_ai_messages(messages, include_names: include_names, &transform)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def create_tools(preset: :messenger, require_approval: true)
|
|
11
|
+
ToolBuilder.new(preset: preset, require_approval: require_approval).build
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create_executor(chat:)
|
|
15
|
+
ToolExecutor.new(chat: chat)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module ApiClient
|
|
5
|
+
class Base
|
|
6
|
+
MAX_RETRIES = 2
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def connection
|
|
11
|
+
@connection ||= build_connection { |f| f.request :json }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def upload_connection
|
|
15
|
+
@upload_connection ||= build_connection { |f| f.request :multipart }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build_connection
|
|
19
|
+
Faraday.new(url: base_url) do |f|
|
|
20
|
+
yield f
|
|
21
|
+
configure_auth(f)
|
|
22
|
+
f.response :json
|
|
23
|
+
f.adapter :net_http
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def base_url
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def adapter_name
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def configure_auth(faraday)
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve_path(path)
|
|
40
|
+
path
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def request(method, path, body = nil)
|
|
44
|
+
retries = 0
|
|
45
|
+
resolved = resolve_path(path)
|
|
46
|
+
begin
|
|
47
|
+
ChatSDK::Instrumentation.instrument("api_request.chat_sdk", adapter: adapter_name, method: method, path: resolved) do
|
|
48
|
+
response = connection.public_send(method, resolved) do |req|
|
|
49
|
+
req.body = body if body && method != :get
|
|
50
|
+
end
|
|
51
|
+
handle_response(response)
|
|
52
|
+
end
|
|
53
|
+
rescue ChatSDK::RateLimitedError => e
|
|
54
|
+
ChatSDK::Instrumentation.instrument("rate_limited.chat_sdk", adapter: adapter_name, retry_after: e.retry_after, attempt: retries + 1)
|
|
55
|
+
retries += 1
|
|
56
|
+
raise if retries > MAX_RETRIES
|
|
57
|
+
sleep(e.retry_after || (2**retries * 0.5))
|
|
58
|
+
retry
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def handle_response(response)
|
|
63
|
+
return extract_success_body(response) if response.success?
|
|
64
|
+
|
|
65
|
+
body = response.body
|
|
66
|
+
|
|
67
|
+
if response.status == 429
|
|
68
|
+
raise ChatSDK::RateLimitedError.new(
|
|
69
|
+
"#{adapter_name} API rate limited",
|
|
70
|
+
retry_after: extract_retry_after(response),
|
|
71
|
+
status: response.status,
|
|
72
|
+
body: body,
|
|
73
|
+
adapter_name: adapter_name
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
raise ChatSDK::PlatformError.new(
|
|
78
|
+
"#{adapter_name} API error: #{extract_error_message(response)}",
|
|
79
|
+
status: response.status,
|
|
80
|
+
body: body,
|
|
81
|
+
adapter_name: adapter_name
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def extract_success_body(response)
|
|
86
|
+
body = response.body
|
|
87
|
+
body.is_a?(Hash) ? body : {}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def extract_retry_after(_response)
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def extract_error_message(response)
|
|
95
|
+
response.status.to_s
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/chat_sdk/cards/node.rb
CHANGED
|
@@ -25,14 +25,10 @@ module ChatSDK
|
|
|
25
25
|
def collect_text(nodes = [self])
|
|
26
26
|
nodes.flat_map do |node|
|
|
27
27
|
case node.type
|
|
28
|
-
when :card
|
|
29
|
-
collect_text(node.children)
|
|
30
28
|
when :text
|
|
31
29
|
[node.attributes[:content]]
|
|
32
30
|
when :field
|
|
33
31
|
["#{node.attributes[:label]}: #{node.attributes[:value]}"]
|
|
34
|
-
when :section
|
|
35
|
-
collect_text(node.children)
|
|
36
32
|
when :button, :link_button
|
|
37
33
|
[node.attributes[:text]]
|
|
38
34
|
else
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Cards
|
|
5
|
+
module TextHelpers
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def collect_text_parts(node)
|
|
9
|
+
parts = []
|
|
10
|
+
node.children.each do |child|
|
|
11
|
+
case child.type
|
|
12
|
+
when :text
|
|
13
|
+
parts << child.attributes[:content]
|
|
14
|
+
when :divider
|
|
15
|
+
parts << "---"
|
|
16
|
+
when :fields
|
|
17
|
+
child.children.each do |field|
|
|
18
|
+
parts << "#{field.attributes[:label]}: #{field.attributes[:value]}"
|
|
19
|
+
end
|
|
20
|
+
when :section
|
|
21
|
+
parts << child.attributes[:title] if child.attributes[:title]
|
|
22
|
+
parts.concat(collect_text_parts(child))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
parts
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def truncate(text, max)
|
|
29
|
+
return "" unless text
|
|
30
|
+
(text.length > max) ? "#{text[0..max - 4]}..." : text
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/chat_sdk/dispatcher.rb
CHANGED
|
@@ -10,18 +10,20 @@ module ChatSDK
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def dispatch(event, adapter:, adapter_name:)
|
|
13
|
-
|
|
13
|
+
ChatSDK::Instrumentation.instrument("dispatch.chat_sdk", adapter: adapter_name, event_type: event.type) do
|
|
14
|
+
return unless dedupe(event, adapter_name)
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
thread = build_thread(event, adapter)
|
|
17
|
+
thread_key = thread_key_for(event, adapter_name)
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
return unless acquire_lock(thread_key, event)
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
begin
|
|
22
|
+
handlers = @registry.handlers_for(event)
|
|
23
|
+
handlers.each { |handler| execute_handler(handler, event, thread) }
|
|
24
|
+
ensure
|
|
25
|
+
release_lock(thread_key)
|
|
26
|
+
end
|
|
25
27
|
end
|
|
26
28
|
end
|
|
27
29
|
|
|
@@ -31,11 +33,13 @@ module ChatSDK
|
|
|
31
33
|
event_id = extract_event_id(event)
|
|
32
34
|
return true unless event_id
|
|
33
35
|
|
|
34
|
-
@state.set_if_absent(
|
|
36
|
+
is_new = @state.set_if_absent(
|
|
35
37
|
"chat_sdk:dedupe:#{adapter_name}:#{event_id}",
|
|
36
38
|
true,
|
|
37
39
|
ttl: @config.dedupe_ttl
|
|
38
40
|
)
|
|
41
|
+
ChatSDK::Instrumentation.instrument("dedupe.chat_sdk", adapter: adapter_name, event_id: event_id, duplicate: !is_new)
|
|
42
|
+
is_new
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
def extract_event_id(event)
|
|
@@ -58,29 +62,20 @@ module ChatSDK
|
|
|
58
62
|
|
|
59
63
|
def acquire_lock(thread_key, event)
|
|
60
64
|
lock_key = "chat_sdk:lock:#{thread_key}"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
else
|
|
71
|
-
if @config.on_lock_conflict.respond_to?(:call)
|
|
72
|
-
policy = @config.on_lock_conflict.call(thread_key, event)
|
|
73
|
-
case policy
|
|
74
|
-
when :force
|
|
75
|
-
@state.force_lock(lock_key, owner: lock_owner, ttl: 30)
|
|
76
|
-
true
|
|
77
|
-
else
|
|
78
|
-
false
|
|
79
|
-
end
|
|
80
|
-
else
|
|
81
|
-
false
|
|
65
|
+
acquired = @state.acquire_lock(lock_key, owner: lock_owner, ttl: 30)
|
|
66
|
+
|
|
67
|
+
unless acquired
|
|
68
|
+
policy = @config.on_lock_conflict
|
|
69
|
+
policy = policy.call(thread_key, event) if policy.respond_to?(:call)
|
|
70
|
+
|
|
71
|
+
if policy == :force
|
|
72
|
+
@state.force_lock(lock_key, owner: lock_owner, ttl: 30)
|
|
73
|
+
acquired = true
|
|
82
74
|
end
|
|
83
75
|
end
|
|
76
|
+
|
|
77
|
+
ChatSDK::Instrumentation.instrument("lock.chat_sdk", key: thread_key, acquired: acquired, policy: acquired ? nil : policy)
|
|
78
|
+
acquired
|
|
84
79
|
end
|
|
85
80
|
|
|
86
81
|
def release_lock(thread_key)
|
|
@@ -96,12 +91,14 @@ module ChatSDK
|
|
|
96
91
|
end
|
|
97
92
|
|
|
98
93
|
def execute_handler(handler, event, thread)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
94
|
+
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)
|
|
101
|
+
end
|
|
105
102
|
end
|
|
106
103
|
rescue => e
|
|
107
104
|
ChatSDK::Log.error("Handler error (#{event.type}): #{e.message}")
|
|
@@ -109,10 +106,7 @@ module ChatSDK
|
|
|
109
106
|
end
|
|
110
107
|
|
|
111
108
|
def add_thread_to_event(event, thread)
|
|
112
|
-
event.
|
|
113
|
-
unless event.respond_to?(:thread)
|
|
114
|
-
event.define_singleton_method(:thread) { @thread }
|
|
115
|
-
end
|
|
109
|
+
event.thread = thread if event.respond_to?(:thread=)
|
|
116
110
|
end
|
|
117
111
|
end
|
|
118
112
|
end
|
|
@@ -4,6 +4,7 @@ module ChatSDK
|
|
|
4
4
|
module Events
|
|
5
5
|
class Action < Base
|
|
6
6
|
attr_reader :action_id, :value, :user, :thread_id, :channel_id, :trigger_id
|
|
7
|
+
attr_accessor :thread
|
|
7
8
|
|
|
8
9
|
def initialize(action_id:, thread_id:, channel_id:, value: nil, user: nil, trigger_id: nil, **kwargs)
|
|
9
10
|
super(type: :action, **kwargs)
|
|
@@ -4,6 +4,7 @@ module ChatSDK
|
|
|
4
4
|
module Events
|
|
5
5
|
class Reaction < Base
|
|
6
6
|
attr_reader :emoji, :user_id, :message_id, :thread_id, :channel_id, :added
|
|
7
|
+
attr_accessor :thread
|
|
7
8
|
|
|
8
9
|
def initialize(emoji:, user_id:, message_id:, thread_id:, channel_id:, added: true, **kwargs)
|
|
9
10
|
super(type: :reaction, **kwargs)
|
|
@@ -4,6 +4,7 @@ module ChatSDK
|
|
|
4
4
|
module Events
|
|
5
5
|
class SlashCommand < Base
|
|
6
6
|
attr_reader :command, :text, :user_id, :channel_id, :trigger_id
|
|
7
|
+
attr_accessor :thread
|
|
7
8
|
|
|
8
9
|
def initialize(command:, user_id:, channel_id:, text: "", trigger_id: nil, **kwargs)
|
|
9
10
|
super(type: :slash_command, **kwargs)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Instrumentation
|
|
5
|
+
@subscribers = Hash.new { |h, k| h[k] = [] }
|
|
6
|
+
@mutex = Mutex.new
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def subscribe(event_name, &block)
|
|
10
|
+
@mutex.synchronize { @subscribers[event_name] << block }
|
|
11
|
+
block
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def unsubscribe(event_name, block)
|
|
15
|
+
@mutex.synchronize { @subscribers[event_name].delete(block) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def instrument(event_name, payload = {})
|
|
19
|
+
listeners = @mutex.synchronize { @subscribers[event_name]&.dup }
|
|
20
|
+
unless listeners&.any?
|
|
21
|
+
return yield if block_given?
|
|
22
|
+
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
start = monotonic_now
|
|
27
|
+
result = yield if block_given?
|
|
28
|
+
payload[:duration] = monotonic_now - start
|
|
29
|
+
fire(listeners, event_name, payload)
|
|
30
|
+
result
|
|
31
|
+
rescue => e
|
|
32
|
+
payload[:duration] = monotonic_now - start if start
|
|
33
|
+
payload[:error] = e
|
|
34
|
+
fire(listeners, event_name, payload) if listeners&.any?
|
|
35
|
+
raise
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reset!
|
|
39
|
+
@mutex.synchronize { @subscribers.clear }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def fire(listeners, event_name, payload)
|
|
45
|
+
listeners.each { |block| block.call(event_name, payload) }
|
|
46
|
+
rescue => e
|
|
47
|
+
ChatSDK::Log.debug("Instrumentation subscriber error: #{e.message}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def monotonic_now
|
|
51
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/chat_sdk/thread.rb
CHANGED
|
@@ -61,6 +61,10 @@ module ChatSDK
|
|
|
61
61
|
adapter.remove_reaction(channel_id: channel_id, message_id: message_id, emoji: emoji)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
def post_ai_stream(enumerable, placeholder: "Thinking...")
|
|
65
|
+
ChatSDK::AI::StreamHandler.stream_to_thread(self, enumerable, placeholder: placeholder)
|
|
66
|
+
end
|
|
67
|
+
|
|
64
68
|
def upload(io:, filename:, comment: nil)
|
|
65
69
|
adapter.upload_file(channel_id: channel_id, io: io, filename: filename, thread_id: id, comment: comment)
|
|
66
70
|
end
|
data/lib/chat_sdk/version.rb
CHANGED
data/lib/chat_sdk.rb
CHANGED
|
@@ -9,7 +9,7 @@ module ChatSDK
|
|
|
9
9
|
def loader
|
|
10
10
|
@loader ||= begin
|
|
11
11
|
loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
|
|
12
|
-
loader.inflector.inflect("chat_sdk" => "ChatSDK")
|
|
12
|
+
loader.inflector.inflect("chat_sdk" => "ChatSDK", "ai" => "AI")
|
|
13
13
|
loader.ignore("#{__dir__}/chat_sdk/version.rb")
|
|
14
14
|
loader.ignore("#{__dir__}/chat_sdk/errors.rb")
|
|
15
15
|
loader.setup
|
metadata
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: chat_sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- Quentin Rousseau
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
@@ -26,7 +26,7 @@ dependencies:
|
|
|
26
26
|
description: Platform-agnostic chat bot framework with normalized events, cards DSL,
|
|
27
27
|
streaming, and pluggable adapters
|
|
28
28
|
email:
|
|
29
|
-
-
|
|
29
|
+
- quentin@rootly.com
|
|
30
30
|
executables: []
|
|
31
31
|
extensions: []
|
|
32
32
|
extra_rdoc_files: []
|
|
@@ -34,6 +34,13 @@ files:
|
|
|
34
34
|
- lib/chat_sdk.rb
|
|
35
35
|
- lib/chat_sdk/adapter/base.rb
|
|
36
36
|
- lib/chat_sdk/adapter/capabilities.rb
|
|
37
|
+
- lib/chat_sdk/adapter/meta_verification.rb
|
|
38
|
+
- lib/chat_sdk/ai.rb
|
|
39
|
+
- lib/chat_sdk/ai/converter.rb
|
|
40
|
+
- lib/chat_sdk/ai/stream_handler.rb
|
|
41
|
+
- lib/chat_sdk/ai/tool_builder.rb
|
|
42
|
+
- lib/chat_sdk/ai/tool_executor.rb
|
|
43
|
+
- lib/chat_sdk/api_client/base.rb
|
|
37
44
|
- lib/chat_sdk/author.rb
|
|
38
45
|
- lib/chat_sdk/cards/actions_context.rb
|
|
39
46
|
- lib/chat_sdk/cards/builder.rb
|
|
@@ -41,6 +48,7 @@ files:
|
|
|
41
48
|
- lib/chat_sdk/cards/node.rb
|
|
42
49
|
- lib/chat_sdk/cards/renderer.rb
|
|
43
50
|
- lib/chat_sdk/cards/select_context.rb
|
|
51
|
+
- lib/chat_sdk/cards/text_helpers.rb
|
|
44
52
|
- lib/chat_sdk/channel.rb
|
|
45
53
|
- lib/chat_sdk/chat.rb
|
|
46
54
|
- lib/chat_sdk/config.rb
|
|
@@ -54,6 +62,7 @@ files:
|
|
|
54
62
|
- lib/chat_sdk/events/reaction.rb
|
|
55
63
|
- lib/chat_sdk/events/slash_command.rb
|
|
56
64
|
- lib/chat_sdk/events/subscribed_message.rb
|
|
65
|
+
- lib/chat_sdk/instrumentation.rb
|
|
57
66
|
- lib/chat_sdk/log.rb
|
|
58
67
|
- lib/chat_sdk/message.rb
|
|
59
68
|
- lib/chat_sdk/modals/builder.rb
|
|
@@ -71,7 +80,7 @@ files:
|
|
|
71
80
|
- lib/chat_sdk/version.rb
|
|
72
81
|
- lib/chat_sdk/webhook/endpoint.rb
|
|
73
82
|
- lib/chat_sdk/webhook/router.rb
|
|
74
|
-
homepage: https://github.com/rootlyhq/
|
|
83
|
+
homepage: https://github.com/rootlyhq/chat-sdk
|
|
75
84
|
licenses:
|
|
76
85
|
- MIT
|
|
77
86
|
metadata:
|