chat_sdk 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/lib/chat_sdk/adapter/base.rb +33 -5
  3. data/lib/chat_sdk/adapter/capabilities.rb +1 -1
  4. data/lib/chat_sdk/adapter/invocation.rb +18 -0
  5. data/lib/chat_sdk/ai/conversation_scope.rb +44 -0
  6. data/lib/chat_sdk/ai/converter.rb +16 -5
  7. data/lib/chat_sdk/ai/tool_builder.rb +18 -2
  8. data/lib/chat_sdk/ai/tool_executor.rb +64 -10
  9. data/lib/chat_sdk/ai.rb +2 -2
  10. data/lib/chat_sdk/author.rb +13 -2
  11. data/lib/chat_sdk/cards/actions_context.rb +9 -3
  12. data/lib/chat_sdk/cards/builder.rb +38 -1
  13. data/lib/chat_sdk/cards/node.rb +36 -0
  14. data/lib/chat_sdk/cards/renderer.rb +20 -0
  15. data/lib/chat_sdk/channel.rb +8 -0
  16. data/lib/chat_sdk/chat.rb +35 -2
  17. data/lib/chat_sdk/config.rb +48 -1
  18. data/lib/chat_sdk/dispatcher.rb +280 -33
  19. data/lib/chat_sdk/event_registry.rb +7 -2
  20. data/lib/chat_sdk/event_serializer.rb +192 -0
  21. data/lib/chat_sdk/events/action.rb +3 -2
  22. data/lib/chat_sdk/events/installed.rb +19 -0
  23. data/lib/chat_sdk/events/message_deleted.rb +19 -0
  24. data/lib/chat_sdk/events/message_updated.rb +17 -0
  25. data/lib/chat_sdk/events/uninstalled.rb +19 -0
  26. data/lib/chat_sdk/format/mentions.rb +61 -0
  27. data/lib/chat_sdk/history.rb +151 -0
  28. data/lib/chat_sdk/message.rb +8 -2
  29. data/lib/chat_sdk/modals/builder.rb +16 -0
  30. data/lib/chat_sdk/postable_message.rb +3 -2
  31. data/lib/chat_sdk/state/base.rb +16 -0
  32. data/lib/chat_sdk/state/memory.rb +31 -0
  33. data/lib/chat_sdk/streaming/stream.rb +6 -2
  34. data/lib/chat_sdk/testing/fake_adapter.rb +32 -8
  35. data/lib/chat_sdk/thread.rb +35 -6
  36. data/lib/chat_sdk/version.rb +1 -1
  37. data/lib/chat_sdk/webhook/endpoint.rb +3 -0
  38. data/lib/chat_sdk.rb +2 -2
  39. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffe997e2fbdfc4703e3a9773078a7e4ca8922cdae4cde900e027218c69b28c17
4
- data.tar.gz: 8ad63e88e6e6a5d7a34b1d8b8a7b91c77eb1991f78290656e2d202909a80557b
3
+ metadata.gz: dc45b8149b772db87ae0793f070a3f068353ef09a177d59c3526eb0ec37d1fb9
4
+ data.tar.gz: 16eb68d587eb53a43337f22dd2cd7d5b564383d892ecda9b64b77b5ae45a05a0
5
5
  SHA512:
6
- metadata.gz: 040a24f524747ddd7a85a9ac1c655aca1066eab185f005a64b08c7d3be336570fb822d6f6f29e6a87bf38b14487d52b34e093081fc5121cb4b2b875e878b79d6
7
- data.tar.gz: 239851288fb9f1fe6264d8c1c0e21c6db5d3487a90fadad8042c8e077980251d486fccf102e75f5ecda933d5c2a51ed621577a7cf248609b72194d3f847ee4a2
6
+ metadata.gz: 7a6cb0121846bf8a75b849f0d6bedfc48601c738dba99c3ad838ecdcb3b31fd7b4a27cd16bfa75cee66a4f70e62b62ae1c4730546b22f430e72d1af1bf6219c2
7
+ data.tar.gz: 602d33c5bba332dac427d61e8566c396f10c057e6b17a3422d1ab4846e4d7e8857ece123b50d2d357e310072f119bee00436db593e6138e46aabc34ba65f777c
@@ -22,6 +22,10 @@ module ChatSDK
22
22
  nil
23
23
  end
24
24
 
25
+ def webhook_response(_events)
26
+ nil
27
+ end
28
+
25
29
  def parse_events(rack_request)
26
30
  raise NotImplementedError
27
31
  end
@@ -31,12 +35,22 @@ module ChatSDK
31
35
  raise NotImplementedError
32
36
  end
33
37
 
34
- def edit_message(channel_id:, message_id:, message:)
38
+ def reply_message(channel_id:, message_id:, message:, thread_id: nil)
39
+ require_capability!(:replies)
40
+ raise NotImplementedError
41
+ end
42
+
43
+ def mark_as_read(channel_id:, message_id:, thread_id: nil, message: nil)
44
+ require_capability!(:read_receipts)
45
+ raise NotImplementedError
46
+ end
47
+
48
+ def edit_message(channel_id:, message_id:, message:, thread_id: nil)
35
49
  require_capability!(:edit_messages)
36
50
  raise NotImplementedError
37
51
  end
38
52
 
39
- def delete_message(channel_id:, message_id:)
53
+ def delete_message(channel_id:, message_id:, thread_id: nil)
40
54
  require_capability!(:delete_messages)
41
55
  raise NotImplementedError
42
56
  end
@@ -51,12 +65,12 @@ module ChatSDK
51
65
  raise NotImplementedError
52
66
  end
53
67
 
54
- def add_reaction(channel_id:, message_id:, emoji:)
68
+ def add_reaction(channel_id:, message_id:, emoji:, thread_id: nil)
55
69
  require_capability!(:reactions)
56
70
  raise NotImplementedError
57
71
  end
58
72
 
59
- def remove_reaction(channel_id:, message_id:, emoji:)
73
+ def remove_reaction(channel_id:, message_id:, emoji:, thread_id: nil)
60
74
  require_capability!(:reactions)
61
75
  raise NotImplementedError
62
76
  end
@@ -71,16 +85,30 @@ module ChatSDK
71
85
  raise NotImplementedError
72
86
  end
73
87
 
88
+ def fetch_channel_messages(channel_id:, cursor: nil, limit: 50)
89
+ require_capability!(:message_history)
90
+ fetch_messages(channel_id: channel_id, cursor: cursor, limit: limit)
91
+ end
92
+
93
+ def list_threads(channel_id:, cursor: nil, limit: 50)
94
+ require_capability!(:threads)
95
+ raise NotImplementedError
96
+ end
97
+
74
98
  def open_modal(trigger_id:, modal:)
75
99
  require_capability!(:modals)
76
100
  raise NotImplementedError
77
101
  end
78
102
 
79
- def start_typing(channel_id:, thread_id: nil)
103
+ def start_typing(channel_id:, thread_id: nil, message_id: nil)
80
104
  require_capability!(:typing_indicator)
81
105
  raise NotImplementedError
82
106
  end
83
107
 
108
+ def concurrency_scope(_event)
109
+ nil
110
+ end
111
+
84
112
  def mention(user_id)
85
113
  raise NotImplementedError
86
114
  end
@@ -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,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
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module AI
5
+ module ConversationScope
6
+ THREAD_KEY = :chat_sdk_ai_conversation_scope
7
+ ACTIVE_KEY = :chat_sdk_ai_conversation_scope_active
8
+
9
+ class << self
10
+ def current
11
+ ::Thread.current[THREAD_KEY]
12
+ end
13
+
14
+ def active?
15
+ !!::Thread.current[ACTIVE_KEY]
16
+ end
17
+
18
+ def with(thread)
19
+ previous = current
20
+ previous_active = active?
21
+ ::Thread.current[THREAD_KEY] = scope_for(thread)
22
+ ::Thread.current[ACTIVE_KEY] = true
23
+ yield
24
+ ensure
25
+ ::Thread.current[THREAD_KEY] = previous
26
+ ::Thread.current[ACTIVE_KEY] = previous_active
27
+ end
28
+
29
+ def scope_for(value)
30
+ return if value.nil?
31
+ return value.transform_keys(&:to_sym) if value.is_a?(Hash)
32
+
33
+ scope = {
34
+ adapter_name: value.adapter.name.to_sym,
35
+ channel_id: value.respond_to?(:channel_id) ? value.channel_id.to_s : value.id.to_s,
36
+ thread_id: value.is_a?(ChatSDK::Thread) ? value.id.to_s : nil
37
+ }
38
+ scope[:message_id] = value.current_message.id if value.respond_to?(:current_message) && value.current_message
39
+ scope
40
+ end
41
+ end
42
+ end
43
+ end
44
+ 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| m.text.nil? || m.text.strip.empty? }
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 = [{type: "text", text: content}]
31
- message.attachments.each do |att|
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
  },
@@ -127,7 +131,8 @@ module ChatSDK
127
131
  properties: {
128
132
  adapter_name: {type: "string", description: "Adapter name"},
129
133
  channel_id: {type: "string", description: "Channel ID"},
130
- 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)"}
131
136
  },
132
137
  required: %w[adapter_name channel_id]
133
138
  },
@@ -145,10 +150,21 @@ module ChatSDK
145
150
  tool_names = PRESETS[@preset]
146
151
  tool_names.each_with_object({}) do |name, tools|
147
152
  defn = TOOL_DEFINITIONS[name].dup
148
- defn[:requires_approval] = @require_approval && !defn[:read_only]
153
+ defn[:requires_approval] = approval_required?(name, defn)
149
154
  tools[name] = defn
150
155
  end
151
156
  end
157
+
158
+ private
159
+
160
+ def approval_required?(name, definition)
161
+ return false if definition[:read_only] || @require_approval == false
162
+ return true unless @require_approval.is_a?(Hash)
163
+
164
+ @require_approval.fetch(name) do
165
+ @require_approval.fetch(name.to_s, true)
166
+ end
167
+ end
152
168
  end
153
169
  end
154
170
  end
@@ -3,8 +3,13 @@
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 = (scope == false) ? false : (ConversationScope.scope_for(scope) || ConversationScope.current&.dup)
9
+ @created_in_handler = ConversationScope.active?
10
+ validate_scope!(@scope) if @scope
11
+ @strict_scope = strict_scope
12
+ @warned_unscoped = {}
8
13
  end
9
14
 
10
15
  def execute(tool_name, arguments)
@@ -13,6 +18,7 @@ module ChatSDK
13
18
 
14
19
  args = arguments.transform_keys(&:to_sym)
15
20
  adapter_name = args[:adapter_name].to_sym
21
+ guard_scope!(tool_name, adapter_name, args)
16
22
 
17
23
  send(:"execute_#{tool_name}", adapter_name, args)
18
24
  end
@@ -40,54 +46,102 @@ module ChatSDK
40
46
 
41
47
  def execute_post_message(adapter_name, args)
42
48
  channel = @chat.channel(args[:channel_id], adapter_name: adapter_name)
49
+ message = ChatSDK::PostableMessage.new(text: args[:text], ai_generated: true)
43
50
  if args[:thread_id]
44
51
  thread = channel.thread(args[:thread_id])
45
- result = thread.post(args[:text])
52
+ result = thread.post(message)
46
53
  else
47
- result = channel.post(args[:text])
54
+ result = channel.post(message)
48
55
  end
49
56
  {id: result.id, text: args[:text]}
50
57
  end
51
58
 
52
59
  def execute_send_direct_message(adapter_name, args)
53
60
  dm_channel = @chat.open_dm(args[:user_id], adapter_name: adapter_name)
54
- result = dm_channel.post(args[:text])
61
+ result = dm_channel.post(ChatSDK::PostableMessage.new(text: args[:text], ai_generated: true))
55
62
  {id: result.id, channel_id: dm_channel.id}
56
63
  end
57
64
 
58
65
  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])
66
+ thread = target_thread(adapter_name, args)
67
+ thread.edit(args[:message_id], ChatSDK::PostableMessage.new(text: args[:text], ai_generated: true))
61
68
  {success: true}
62
69
  end
63
70
 
64
71
  def execute_delete_message(adapter_name, args)
65
- thread = @chat.channel(args[:channel_id], adapter_name: adapter_name).thread(args[:channel_id])
72
+ thread = target_thread(adapter_name, args)
66
73
  thread.delete(args[:message_id])
67
74
  {success: true}
68
75
  end
69
76
 
70
77
  def execute_add_reaction(adapter_name, args)
71
- thread = @chat.channel(args[:channel_id], adapter_name: adapter_name).thread(args[:channel_id])
78
+ thread = target_thread(adapter_name, args)
72
79
  thread.react(args[:message_id], args[:emoji])
73
80
  {success: true}
74
81
  end
75
82
 
76
83
  def execute_remove_reaction(adapter_name, args)
77
- thread = @chat.channel(args[:channel_id], adapter_name: adapter_name).thread(args[:channel_id])
84
+ thread = target_thread(adapter_name, args)
78
85
  thread.unreact(args[:message_id], args[:emoji])
79
86
  {success: true}
80
87
  end
81
88
 
82
89
  def execute_start_typing(adapter_name, args)
83
90
  adapter = @chat.adapter(adapter_name)
84
- adapter.start_typing(channel_id: args[:channel_id], thread_id: args[:thread_id])
91
+ scoped_message_id = @scope.is_a?(Hash) ? @scope[:message_id] : nil
92
+ message_id = args[:message_id] || ConversationScope.current&.[](:message_id) || scoped_message_id
93
+ adapter.start_typing(channel_id: args[:channel_id], thread_id: args[:thread_id], message_id: message_id)
85
94
  {success: true}
86
95
  end
87
96
 
88
97
  def serialize_messages(messages)
89
98
  messages.map { |m| {id: m.id, text: m.text, author: m.author&.name, timestamp: m.timestamp} }
90
99
  end
100
+
101
+ def target_thread(adapter_name, args)
102
+ channel = @chat.channel(args[:channel_id], adapter_name: adapter_name)
103
+ channel.thread(args[:thread_id] || args[:channel_id])
104
+ end
105
+
106
+ def guard_scope!(tool_name, adapter_name, args)
107
+ return if @scope == false
108
+
109
+ scope = @scope || ConversationScope.current
110
+ unless scope
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]
116
+ ChatSDK::Log.warn("AI tool ran without a conversation scope; pass scope: to create_executor to confine access")
117
+ @warned_unscoped[tool_name] = true
118
+ end
119
+
120
+ return
121
+ end
122
+
123
+ return if tool_name == :send_direct_message
124
+
125
+ target_channel = args[:channel_id].to_s
126
+ target_thread = args[:thread_id]&.to_s
127
+ same_channel = adapter_name == scope.fetch(:adapter_name).to_sym && target_channel == scope.fetch(:channel_id).to_s
128
+ scope_is_channel = scope[:thread_id].nil?
129
+ in_scope = same_channel && (!@strict_scope || scope_is_channel || target_thread == scope[:thread_id].to_s)
130
+ return if in_scope
131
+
132
+ target = [adapter_name, target_channel, target_thread].compact.join(":")
133
+ active = [scope[:adapter_name], scope[:channel_id], scope[:thread_id]].compact.join(":")
134
+ raise ChatSDK::Error, "AI tool call blocked: executor is scoped to #{active.inspect}, but targeted #{target.inspect}"
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
91
145
  end
92
146
  end
93
147
  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
@@ -2,14 +2,17 @@
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, 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
13
+ @system = system
12
14
  @locale = locale
15
+ @email = email
13
16
  @raw = raw
14
17
  end
15
18
 
@@ -17,6 +20,14 @@ module ChatSDK
17
20
  @bot
18
21
  end
19
22
 
23
+ def me?
24
+ @me
25
+ end
26
+
27
+ def system?
28
+ @system
29
+ end
30
+
20
31
  def ==(other)
21
32
  other.is_a?(Author) && id == other.id && platform == other.platform
22
33
  end
@@ -9,15 +9,21 @@ 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, visible_to: 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
17
+ attrs[:visible_to] = visible_to if visible_to
16
18
  @nodes << Node.new(:button, attributes: attrs)
17
19
  end
18
20
 
19
- def link_button(text, url:)
20
- @nodes << Node.new(:link_button, attributes: {text: text, url: url})
21
+ def link_button(text, url:, id: nil, tooltip: nil, visible_to: nil)
22
+ attrs = {text: text, url: url}
23
+ attrs[:id] = id if id
24
+ attrs[:tooltip] = tooltip if tooltip
25
+ attrs[:visible_to] = visible_to if visible_to
26
+ @nodes << Node.new(:link_button, attributes: attrs)
21
27
  end
22
28
 
23
29
  def select(id:, placeholder: nil, &block)
@@ -3,9 +3,12 @@
3
3
  module ChatSDK
4
4
  module Cards
5
5
  class Builder
6
- def initialize(title: nil, subtitle: nil, &block)
6
+ RESERVED_TEXT_INPUT_IDS = %w[actionId value msteams].freeze
7
+
8
+ def initialize(title: nil, subtitle: nil, width: nil, &block)
7
9
  @title = title
8
10
  @subtitle = subtitle
11
+ @width = width
9
12
  @children = []
10
13
  instance_eval(&block) if block
11
14
  end
@@ -14,6 +17,7 @@ module ChatSDK
14
17
  attrs = {}
15
18
  attrs[:title] = @title if @title
16
19
  attrs[:subtitle] = @subtitle if @subtitle
20
+ attrs[:width] = @width if @width
17
21
  Node.new(:card, attributes: attrs, children: @children)
18
22
  end
19
23
 
@@ -29,6 +33,17 @@ module ChatSDK
29
33
  @children << Node.new(:image, attributes: {url: url, alt: alt})
30
34
  end
31
35
 
36
+ def text_input(id:, label:, placeholder: nil, multiline: false, optional: false, value: nil)
37
+ if RESERVED_TEXT_INPUT_IDS.include?(id.to_s)
38
+ raise ArgumentError, "Text input id #{id.inspect} is reserved"
39
+ end
40
+
41
+ attrs = {id: id, label: label, multiline: multiline, optional: optional}
42
+ attrs[:placeholder] = placeholder if placeholder
43
+ attrs[:value] = value unless value.nil?
44
+ @children << Node.new(:text_input, attributes: attrs)
45
+ end
46
+
32
47
  def fields(&block)
33
48
  ctx = FieldsContext.new
34
49
  ctx.instance_eval(&block)
@@ -47,6 +62,28 @@ module ChatSDK
47
62
  ctx.instance_eval(&block)
48
63
  @children << Node.new(:actions, children: ctx.nodes)
49
64
  end
65
+
66
+ def table(headers:, rows:, align: nil, caption: nil, page_size: nil, widths: nil, grid_lines: nil, grid_style: nil, vertical_align: nil)
67
+ attrs = {headers: headers, rows: rows}
68
+ attrs[:align] = align if align
69
+ attrs[:caption] = caption if caption
70
+ attrs[:page_size] = page_size if page_size
71
+ attrs[:widths] = widths if widths
72
+ attrs[:grid_lines] = grid_lines unless grid_lines.nil?
73
+ attrs[:grid_style] = grid_style if grid_style
74
+ attrs[:vertical_align] = vertical_align if vertical_align
75
+ @children << Node.new(:table, attributes: attrs)
76
+ end
77
+
78
+ def chart(title:, type:, segments: nil, categories: nil, series: nil, x_label: nil, y_label: nil)
79
+ attrs = {title: title, chart_type: type}
80
+ attrs[:segments] = segments if segments
81
+ attrs[:categories] = categories if categories
82
+ attrs[:series] = series if series
83
+ attrs[:x_label] = x_label if x_label
84
+ attrs[:y_label] = y_label if y_label
85
+ @children << Node.new(:chart, attributes: attrs)
86
+ end
50
87
  end
51
88
  end
52
89
  end
@@ -31,11 +31,47 @@ module ChatSDK
31
31
  ["#{node.attributes[:label]}: #{node.attributes[:value]}"]
32
32
  when :button, :link_button
33
33
  [node.attributes[:text]]
34
+ when :text_input
35
+ [node.attributes[:label]]
36
+ when :table
37
+ table_text(node)
38
+ when :chart
39
+ chart_text(node)
34
40
  else
35
41
  collect_text(node.children)
36
42
  end
37
43
  end
38
44
  end
45
+
46
+ def table_text(node)
47
+ headers = Array(node.attributes[:headers]).map(&:to_s)
48
+ rows = Array(node.attributes[:rows]).map { |row| Array(row).map(&:to_s) }
49
+ lines = []
50
+ lines << node.attributes[:caption].to_s if node.attributes[:caption]
51
+ lines << headers.join(" | ") unless headers.empty?
52
+ lines.concat(rows.map { |row| row.join(" | ") })
53
+ lines
54
+ end
55
+
56
+ def chart_text(node)
57
+ lines = [node.attributes[:title].to_s]
58
+ if node.attributes[:chart_type].to_sym == :pie
59
+ lines.concat(Array(node.attributes[:segments]).map do |segment|
60
+ "#{value_for(segment, :label)}: #{value_for(segment, :value)}"
61
+ end)
62
+ else
63
+ Array(node.attributes[:series]).each do |series|
64
+ data = value_for(series, :data) || []
65
+ points = data.map { |point| "#{value_for(point, :label)}=#{value_for(point, :value)}" }
66
+ lines << "#{value_for(series, :name)}: #{points.join(", ")}"
67
+ end
68
+ end
69
+ lines
70
+ end
71
+
72
+ def value_for(hash, key)
73
+ hash[key] || hash[key.to_s]
74
+ end
39
75
  end
40
76
  end
41
77
  end
@@ -15,7 +15,10 @@ module ChatSDK
15
15
  when :actions then render_actions(node)
16
16
  when :button then "[#{node.attributes[:text]}]"
17
17
  when :link_button then "[#{node.attributes[:text]}](#{node.attributes[:url]})"
18
+ when :text_input then node.attributes[:label]
18
19
  when :select then "_#{node.attributes[:placeholder] || "Select"}_"
20
+ when :table then render_table(node)
21
+ when :chart then render_chart(node)
19
22
  else ""
20
23
  end
21
24
  end
@@ -44,6 +47,23 @@ module ChatSDK
44
47
  def render_actions(node)
45
48
  node.children.map { |c| render(c) }.join(" | ")
46
49
  end
50
+
51
+ def render_table(node)
52
+ headers = Array(node.attributes[:headers]).map(&:to_s)
53
+ rows = Array(node.attributes[:rows]).map { |row| Array(row).map(&:to_s) }
54
+ parts = []
55
+ parts << node.attributes[:caption].to_s if node.attributes[:caption]
56
+ unless headers.empty?
57
+ parts << "| #{headers.join(" | ")} |"
58
+ parts << "| #{headers.map { "---" }.join(" | ")} |"
59
+ end
60
+ parts.concat(rows.map { |row| "| #{row.join(" | ")} |" })
61
+ parts.join("\n")
62
+ end
63
+
64
+ def render_chart(node)
65
+ node.fallback_text
66
+ end
47
67
  end
48
68
  end
49
69
  end
@@ -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