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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6bcc60c6b4443d14e0afb167cc7a6b8f5bbe60663fd97e275f87435313cf0705
4
- data.tar.gz: 4f3820e9f8ae54789a15b89f351e035b942d741d7670accd509adf86e693b4c8
3
+ metadata.gz: b44dd1b3b15cb4a83647923c0f75ad2208860060010a1e2c47391d1f4b44ca5a
4
+ data.tar.gz: b6e99a0348c858130e767f6647fa1a430d45e9497e6a7d652343dc4d1e0ef4de
5
5
  SHA512:
6
- metadata.gz: b60a52d3a1c878b74e57e57ee3ed2b40191f60ce2db45c3c80ec762324f54b738f6d4ea448f362f02f3e0a84026656e55090c1feef4abf96cfe6b67ba2c19675
7
- data.tar.gz: f13771e3df25d44835659b04d424b6410eb2c9f8ffd5a53e3aaa24cd111446ca10b53a6f88adccccb6b261a7289597f0ee7c166795b6abb6fe69fccfc2fa7c4d
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
@@ -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
@@ -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
@@ -10,18 +10,20 @@ module ChatSDK
10
10
  end
11
11
 
12
12
  def dispatch(event, adapter:, adapter_name:)
13
- return unless dedupe(event, adapter_name)
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
- thread = build_thread(event, adapter)
16
- thread_key = thread_key_for(event, adapter_name)
16
+ thread = build_thread(event, adapter)
17
+ thread_key = thread_key_for(event, adapter_name)
17
18
 
18
- return unless acquire_lock(thread_key, event)
19
+ return unless acquire_lock(thread_key, event)
19
20
 
20
- begin
21
- handlers = @registry.handlers_for(event)
22
- handlers.each { |handler| execute_handler(handler, event, thread) }
23
- ensure
24
- release_lock(thread_key)
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
- return true if @state.acquire_lock(lock_key, owner: lock_owner, ttl: 30)
62
-
63
- case @config.on_lock_conflict
64
- when :drop
65
- ChatSDK::Log.debug("Lock conflict, dropping event for #{thread_key}")
66
- false
67
- when :force
68
- @state.force_lock(lock_key, owner: lock_owner, ttl: 30)
69
- true
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
- case event.type
100
- when :mention, :subscribed_message, :direct_message
101
- handler.block.call(thread, event.message)
102
- when :reaction, :action, :slash_command
103
- add_thread_to_event(event, thread)
104
- handler.block.call(event)
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.instance_variable_set(:@thread, thread)
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChatSDK
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
- - Rootly
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
- - eng@rootly.com
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/rootly-chat-sdk
83
+ homepage: https://github.com/rootlyhq/chat-sdk
75
84
  licenses:
76
85
  - MIT
77
86
  metadata: