chat_sdk 1.0.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a031721bc3de18dd4f5c698516625486a29eb8535397c47ee12e9a2464e2d39
4
- data.tar.gz: d01aa8c75630575d3a28b12d37f2072e163575c244b801ad963eeeffe2887469
3
+ metadata.gz: 36c07ba6e29c7d1cf858767caca271826418d829547426f1677a32c94133205e
4
+ data.tar.gz: 0a00e35abebb7f43b8c9bb68576fce634d46088430029b33ad7f63c2d3820bbb
5
5
  SHA512:
6
- metadata.gz: 359f6f5fbae354a23d5f74b9ee2c99c8c24c1b41c07cb77aeb12c289b439314588907ca1ae08b47b0b0b6a601586a7e70e39c3e54157e3062ff289440d2a8eb8
7
- data.tar.gz: 6fc8824b5350703d05b96bbea9adae730f7769089dbed78f5abf9732f401384078ff1148df15525119fb6493b3aa83654455e63254344cefdff7e2d0572ebf2b
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| 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
  },
@@ -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] = @require_approval && !defn[:read_only]
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 = @chat.channel(args[:channel_id], adapter_name: adapter_name).thread(args[:channel_id])
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 = @chat.channel(args[:channel_id], adapter_name: adapter_name).thread(args[:channel_id])
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 = @chat.channel(args[:channel_id], adapter_name: adapter_name).thread(args[:channel_id])
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 = @chat.channel(args[:channel_id], adapter_name: adapter_name).thread(args[:channel_id])
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
@@ -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
- @nodes << Node.new(:link_button, attributes: {text: text, url: url})
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
@@ -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
@@ -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
 
@@ -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