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.
@@ -1,34 +1,160 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+ require "time"
5
+
3
6
  module ChatSDK
4
7
  class Dispatcher
8
+ LockHeartbeat = Struct.new(:thread, :mutex, :condition, :stop, :ownership_lost) do
9
+ def ownership_lost?
10
+ mutex.synchronize { ownership_lost }
11
+ end
12
+ end
13
+
5
14
  def initialize(chat:, config:, state:, registry:)
6
15
  @chat = chat
7
16
  @config = config
8
17
  @state = state
9
18
  @registry = registry
19
+ @slot_mutex = Mutex.new
20
+ @slot_condition = ConditionVariable.new
21
+ @active_slots = Hash.new(0)
10
22
  end
11
23
 
12
24
  def dispatch(event, adapter:, adapter_name:)
13
25
  ChatSDK::Instrumentation.instrument("dispatch.chat_sdk", adapter: adapter_name, event_type: event.type) do
26
+ if %i[message_updated message_deleted].include?(event.type)
27
+ return execute_event(event, build_thread(event, adapter))
28
+ end
29
+
14
30
  return unless dedupe(event, adapter_name)
15
31
 
16
32
  thread = build_thread(event, adapter)
17
33
  thread_key = thread_key_for(event, adapter_name)
34
+ strategy = @config.concurrency[:strategy]
18
35
 
19
- return unless acquire_lock(thread_key, event)
36
+ if strategy == :concurrent
37
+ return with_concurrent_slot(thread_key) { execute_event(event, thread) }
38
+ end
20
39
 
21
- begin
22
- handlers = @registry.handlers_for(event)
23
- handlers.each { |handler| execute_handler(handler, event, thread) }
24
- ensure
25
- release_lock(thread_key)
40
+ owner = "#{Process.pid}:#{::Thread.current.object_id}:#{SecureRandom.uuid}"
41
+ if %i[queue burst debounce].include?(strategy)
42
+ dispatch_queued(event, adapter, thread_key, owner, strategy)
43
+ else
44
+ dispatch_locked(event, thread, thread_key, owner, strategy)
26
45
  end
27
46
  end
28
47
  end
29
48
 
30
49
  private
31
50
 
51
+ def dispatch_locked(event, thread, thread_key, owner, strategy)
52
+ acquired = acquire_lock(thread_key, event, owner, force: strategy == :force)
53
+ return unless acquired
54
+
55
+ heartbeat = start_lock_heartbeat(thread_key, owner)
56
+ execute_event(event, thread)
57
+ ensure
58
+ stop_lock_heartbeat(heartbeat)
59
+ release_lock(thread_key, owner) if acquired
60
+ end
61
+
62
+ def dispatch_queued(event, adapter, thread_key, owner, strategy)
63
+ queue_key = "chat_sdk:queue:#{thread_key}"
64
+ acquired = acquire_lock(thread_key, event, owner, apply_conflict_policy: false)
65
+ pre_enqueued = false
66
+
67
+ unless acquired
68
+ enqueue(queue_key, event)
69
+ pre_enqueued = true
70
+ acquired = acquire_lock(thread_key, event, owner, apply_conflict_policy: false)
71
+ return unless acquired
72
+ end
73
+
74
+ heartbeat = start_lock_heartbeat(thread_key, owner)
75
+ if strategy == :queue
76
+ execute_event(event, build_thread(event, adapter)) unless pre_enqueued
77
+ else
78
+ enqueue(queue_key, event) unless pre_enqueued
79
+ sleep(@config.concurrency[:debounce].to_f) if strategy == :burst
80
+ end
81
+
82
+ if strategy == :debounce
83
+ debounce_loop(queue_key, adapter, heartbeat)
84
+ else
85
+ drain_loop(queue_key, adapter, strategy, heartbeat)
86
+ end
87
+ ensure
88
+ stop_lock_heartbeat(heartbeat)
89
+ release_lock(thread_key, owner) if acquired
90
+ end
91
+
92
+ def drain_loop(queue_key, adapter, strategy, heartbeat)
93
+ loop do
94
+ break if heartbeat&.ownership_lost?
95
+
96
+ entries = fresh_entries(@state.drain_queue(queue_key))
97
+ break if entries.empty?
98
+
99
+ events = entries.map { |entry| EventSerializer.load(entry.fetch("event")) }
100
+ current = events.last
101
+ skipped = if strategy == :debounce
102
+ []
103
+ else
104
+ events[0...-1].filter_map { |queued| queued.message if queued.respond_to?(:message) }
105
+ end
106
+ context = {skipped: skipped, total_since_last_handler: skipped.length + 1}
107
+ execute_event(current, build_thread(current, adapter), context: context)
108
+ ::Thread.pass
109
+ end
110
+ end
111
+
112
+ def debounce_loop(queue_key, adapter, heartbeat)
113
+ skipped = []
114
+ loop do
115
+ sleep(@config.concurrency[:debounce].to_f)
116
+ break if heartbeat&.ownership_lost?
117
+
118
+ entries = fresh_entries(@state.drain_queue(queue_key))
119
+ break if entries.empty?
120
+
121
+ events = entries.map { |entry| EventSerializer.load(entry.fetch("event")) }
122
+ latest = events.last
123
+ skipped.concat(events[0...-1].filter_map { |queued| queued.message if queued.respond_to?(:message) })
124
+
125
+ if @state.queue_depth(queue_key).positive?
126
+ skipped << latest.message if latest.respond_to?(:message)
127
+ next
128
+ end
129
+
130
+ relevant = skipped.select do |message|
131
+ message.thread_id == latest.thread_id
132
+ end
133
+ context = {skipped: relevant, total_since_last_handler: relevant.length + 1}
134
+ execute_event(latest, build_thread(latest, adapter), context: context)
135
+ skipped.clear
136
+ end
137
+ end
138
+
139
+ def enqueue(queue_key, event)
140
+ entry = {"event" => EventSerializer.dump(event), "enqueued_at" => Time.now.iso8601}
141
+ @state.enqueue(
142
+ queue_key,
143
+ entry,
144
+ max_size: @config.concurrency[:max_queue_size],
145
+ drop: @config.concurrency[:on_queue_full]
146
+ )
147
+ end
148
+
149
+ def fresh_entries(entries)
150
+ cutoff = Time.now - @config.concurrency[:queue_entry_ttl].to_f
151
+ entries.select do |entry|
152
+ Time.iso8601(entry.fetch("enqueued_at")) >= cutoff
153
+ rescue ArgumentError, KeyError
154
+ false
155
+ end
156
+ end
157
+
32
158
  def dedupe(event, adapter_name)
33
159
  event_id = extract_event_id(event)
34
160
  return true unless event_id
@@ -45,6 +171,8 @@ module ChatSDK
45
171
  def extract_event_id(event)
46
172
  if event.respond_to?(:message) && event.message
47
173
  event.message.id
174
+ elsif event.respond_to?(:message_id) && event.message_id
175
+ "#{event.type}:#{event.message_id}:#{event.timestamp.to_f}"
48
176
  elsif event.respond_to?(:raw) && event.raw.is_a?(Hash)
49
177
  event.raw[:event_id] || event.raw["event_id"]
50
178
  end
@@ -53,33 +181,69 @@ module ChatSDK
53
181
  def thread_key_for(event, adapter_name)
54
182
  channel_id = event.respond_to?(:channel_id) ? event.channel_id : nil
55
183
  thread_id = event.respond_to?(:thread_id) ? event.thread_id : nil
56
- "#{adapter_name}:#{channel_id}:#{thread_id}"
57
- end
58
-
59
- def lock_owner
60
- @lock_owner ||= "#{Process.pid}:#{::Thread.current.object_id}"
184
+ lock_scope = @config.concurrency[:lock_scope]
185
+ scope_id = (lock_scope&.to_sym == :channel) ? channel_id : thread_id
186
+ "#{adapter_name}:#{channel_id}:#{scope_id}"
61
187
  end
62
188
 
63
- def acquire_lock(thread_key, event)
189
+ def acquire_lock(thread_key, event, owner, force: false, apply_conflict_policy: true)
64
190
  lock_key = "chat_sdk:lock:#{thread_key}"
65
- acquired = @state.acquire_lock(lock_key, owner: lock_owner, ttl: 30)
191
+ acquired = @state.acquire_lock(lock_key, owner: owner, ttl: @config.concurrency[:lock_ttl])
66
192
 
67
- unless acquired
68
- policy = @config.on_lock_conflict
193
+ if !acquired && apply_conflict_policy
194
+ policy = force ? :force : @config.on_lock_conflict
69
195
  policy = policy.call(thread_key, event) if policy.respond_to?(:call)
70
-
71
196
  if policy == :force
72
- @state.force_lock(lock_key, owner: lock_owner, ttl: 30)
197
+ @state.force_lock(lock_key, owner: owner, ttl: @config.concurrency[:lock_ttl])
73
198
  acquired = true
74
199
  end
75
200
  end
76
201
 
77
- ChatSDK::Instrumentation.instrument("lock.chat_sdk", key: thread_key, acquired: acquired, policy: acquired ? nil : policy)
202
+ ChatSDK::Instrumentation.instrument("lock.chat_sdk", key: thread_key, acquired: acquired)
78
203
  acquired
79
204
  end
80
205
 
81
- def release_lock(thread_key)
82
- @state.release_lock("chat_sdk:lock:#{thread_key}", owner: lock_owner)
206
+ def release_lock(thread_key, owner)
207
+ @state.release_lock("chat_sdk:lock:#{thread_key}", owner: owner)
208
+ end
209
+
210
+ def start_lock_heartbeat(thread_key, owner)
211
+ lock_ttl = @config.concurrency[:lock_ttl].to_f
212
+ max_lifetime = @config.concurrency[:max_lock_lifetime].to_f
213
+ mutex = Mutex.new
214
+ condition = ConditionVariable.new
215
+ heartbeat = LockHeartbeat.new(mutex: mutex, condition: condition, stop: false, ownership_lost: false)
216
+ heartbeat.thread = ::Thread.new do
217
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
218
+ interval = [lock_ttl / 3.0, 0.01].max
219
+ loop do
220
+ should_stop = mutex.synchronize do
221
+ condition.wait(mutex, interval) unless heartbeat.stop
222
+ heartbeat.stop
223
+ end
224
+ break if should_stop
225
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) - started >= max_lifetime
226
+
227
+ extended = @state.extend_lock("chat_sdk:lock:#{thread_key}", owner: owner, ttl: lock_ttl)
228
+ unless extended
229
+ mutex.synchronize { heartbeat.ownership_lost = true }
230
+ break
231
+ end
232
+ rescue NotImplementedError
233
+ break
234
+ end
235
+ end
236
+ heartbeat
237
+ end
238
+
239
+ def stop_lock_heartbeat(heartbeat)
240
+ return unless heartbeat
241
+
242
+ heartbeat.mutex.synchronize do
243
+ heartbeat.stop = true
244
+ heartbeat.condition.signal
245
+ end
246
+ heartbeat.thread.join
83
247
  end
84
248
 
85
249
  def build_thread(event, adapter)
@@ -87,17 +251,28 @@ module ChatSDK
87
251
  channel_id = event.respond_to?(:channel_id) ? event.channel_id : nil
88
252
  return nil unless thread_id && channel_id
89
253
 
90
- ChatSDK::Thread.new(id: thread_id, channel_id: channel_id, adapter: adapter, chat: @chat)
254
+ current_message = event.respond_to?(:message) ? event.message : nil
255
+ ChatSDK::Thread.new(id: thread_id, channel_id: channel_id, adapter: adapter, chat: @chat, current_message: current_message)
91
256
  end
92
257
 
93
- def execute_handler(handler, event, thread)
258
+ def execute_event(event, thread, context: nil)
259
+ @registry.handlers_for(event).each do |handler|
260
+ execute_handler(handler, event, thread, context: context)
261
+ end
262
+ end
263
+
264
+ def execute_handler(handler, event, thread, context: nil)
94
265
  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)
266
+ ChatSDK::AI::ConversationScope.with(thread) do
267
+ case event.type
268
+ when :mention, :subscribed_message, :direct_message
269
+ handler.block.call(thread, event.message, context)
270
+ when :message_updated
271
+ handler.block.call(thread, event.message, event.previous_message)
272
+ when :reaction, :action, :slash_command, :message_deleted
273
+ add_thread_to_event(event, thread)
274
+ handler.block.call(event)
275
+ end
101
276
  end
102
277
  end
103
278
  rescue => e
@@ -108,5 +283,23 @@ module ChatSDK
108
283
  def add_thread_to_event(event, thread)
109
284
  event.thread = thread if event.respond_to?(:thread=)
110
285
  end
286
+
287
+ def with_concurrent_slot(key)
288
+ max = @config.concurrency[:max_concurrent]
289
+ return yield unless max
290
+
291
+ @slot_mutex.synchronize do
292
+ @slot_condition.wait(@slot_mutex) while @active_slots[key] >= max
293
+ @active_slots[key] += 1
294
+ end
295
+ yield
296
+ ensure
297
+ if max
298
+ @slot_mutex.synchronize do
299
+ @active_slots[key] -= 1
300
+ @slot_condition.broadcast
301
+ end
302
+ end
303
+ end
111
304
  end
112
305
  end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module ChatSDK
7
+ class EventSerializer
8
+ class << self
9
+ def dump(event)
10
+ data = common_event(event)
11
+ case event.type
12
+ when :mention, :subscribed_message, :direct_message, :message_updated
13
+ data["message"] = dump_message(event.message)
14
+ data["previous_message"] = dump_message(event.previous_message) if event.respond_to?(:previous_message) && event.previous_message
15
+ when :message_deleted
16
+ data.merge!(
17
+ "message_id" => event.message_id,
18
+ "previous_message" => dump_message(event.previous_message),
19
+ "deleted_at" => event.deleted_at&.iso8601
20
+ )
21
+ when :reaction
22
+ data.merge!("emoji" => event.emoji, "user_id" => event.user_id, "message_id" => event.message_id, "added" => event.added?)
23
+ when :action
24
+ data.merge!("action_id" => event.action_id, "value" => event.value, "user" => dump_author(event.user), "trigger_id" => event.trigger_id)
25
+ when :slash_command
26
+ data.merge!("command" => event.command, "text" => event.text, "user_id" => event.user_id, "trigger_id" => event.trigger_id)
27
+ end
28
+ data
29
+ end
30
+
31
+ def load(data)
32
+ data = data.transform_keys(&:to_s)
33
+ type = data.fetch("type").to_sym
34
+ common = {
35
+ thread_id: data["thread_id"],
36
+ channel_id: data["channel_id"],
37
+ platform: data["platform"].to_sym,
38
+ adapter_name: data["adapter_name"].to_sym,
39
+ raw: data["raw"],
40
+ timestamp: parse_time(data["timestamp"])
41
+ }
42
+ case type
43
+ when :mention, :subscribed_message, :direct_message
44
+ class_name = {mention: Events::Mention, subscribed_message: Events::SubscribedMessage, direct_message: Events::DirectMessage}.fetch(type)
45
+ class_name.new(message: load_message(data["message"]), **common)
46
+ when :message_updated
47
+ Events::MessageUpdated.new(message: load_message(data["message"]), previous_message: load_message(data["previous_message"]), **common)
48
+ when :message_deleted
49
+ Events::MessageDeleted.new(
50
+ message_id: data["message_id"],
51
+ previous_message: load_message(data["previous_message"]),
52
+ deleted_at: parse_time(data["deleted_at"]),
53
+ **common
54
+ )
55
+ when :reaction
56
+ Events::Reaction.new(emoji: data["emoji"], user_id: data["user_id"], message_id: data["message_id"], added: data["added"], **common)
57
+ when :action
58
+ Events::Action.new(action_id: data["action_id"], value: data["value"], user: load_author(data["user"]), trigger_id: data["trigger_id"], **common)
59
+ when :slash_command
60
+ common.delete(:thread_id)
61
+ Events::SlashCommand.new(command: data["command"], text: data["text"], user_id: data["user_id"], trigger_id: data["trigger_id"], **common)
62
+ else
63
+ raise ArgumentError, "unsupported queued event type: #{type}"
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def common_event(event)
70
+ {
71
+ "type" => event.type.to_s,
72
+ "platform" => event.platform.to_s,
73
+ "adapter_name" => event.adapter_name.to_s,
74
+ "thread_id" => event.respond_to?(:thread_id) ? event.thread_id : nil,
75
+ "channel_id" => event.respond_to?(:channel_id) ? event.channel_id : nil,
76
+ "raw" => json_safe(event.raw),
77
+ "timestamp" => event.timestamp&.iso8601
78
+ }
79
+ end
80
+
81
+ def dump_message(message)
82
+ return unless message
83
+
84
+ {
85
+ "id" => message.id,
86
+ "text" => message.text,
87
+ "author" => dump_author(message.author),
88
+ "thread_id" => message.thread_id,
89
+ "channel_id" => message.channel_id,
90
+ "platform" => message.platform.to_s,
91
+ "attachments" => json_safe(message.attachments),
92
+ "links" => json_safe(message.links),
93
+ "reply_to" => dump_message(message.reply_to),
94
+ "subject" => json_safe(message.subject),
95
+ "raw" => json_safe(message.raw),
96
+ "timestamp" => message.timestamp&.iso8601
97
+ }
98
+ end
99
+
100
+ def load_message(data)
101
+ return unless data
102
+
103
+ data = data.transform_keys(&:to_s)
104
+ Message.new(
105
+ id: data["id"],
106
+ text: data["text"],
107
+ author: load_author(data["author"]),
108
+ thread_id: data["thread_id"],
109
+ channel_id: data["channel_id"],
110
+ platform: data["platform"].to_sym,
111
+ attachments: data["attachments"] || [],
112
+ links: data["links"] || [],
113
+ reply_to: load_message(data["reply_to"]),
114
+ subject: data["subject"],
115
+ raw: data["raw"],
116
+ timestamp: parse_time(data["timestamp"])
117
+ )
118
+ end
119
+
120
+ def dump_author(author)
121
+ return unless author
122
+
123
+ {
124
+ "id" => author.id,
125
+ "name" => author.name,
126
+ "platform" => author.platform.to_s,
127
+ "bot" => author.bot?,
128
+ "system" => author.system?,
129
+ "locale" => author.locale,
130
+ "email" => author.email,
131
+ "raw" => json_safe(author.raw)
132
+ }
133
+ end
134
+
135
+ def load_author(data)
136
+ return unless data
137
+
138
+ data = data.transform_keys(&:to_s)
139
+ Author.new(
140
+ id: data["id"],
141
+ name: data["name"],
142
+ platform: data["platform"].to_sym,
143
+ bot: data["bot"],
144
+ system: data["system"],
145
+ locale: data["locale"],
146
+ email: data["email"],
147
+ raw: data["raw"]
148
+ )
149
+ end
150
+
151
+ def parse_time(value)
152
+ Time.iso8601(value) if value
153
+ end
154
+
155
+ def json_safe(value)
156
+ JSON.parse(JSON.generate(value))
157
+ rescue JSON::GeneratorError, TypeError
158
+ nil
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Events
5
+ class MessageDeleted < Base
6
+ attr_reader :message_id, :previous_message, :thread_id, :channel_id, :deleted_at
7
+ attr_accessor :thread
8
+
9
+ def initialize(message_id:, thread_id:, channel_id:, previous_message: nil, deleted_at: nil, **kwargs)
10
+ super(type: :message_deleted, **kwargs)
11
+ @message_id = message_id
12
+ @previous_message = previous_message
13
+ @thread_id = thread_id
14
+ @channel_id = channel_id
15
+ @deleted_at = deleted_at
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Events
5
+ class MessageUpdated < Base
6
+ attr_reader :message, :previous_message, :thread_id, :channel_id
7
+
8
+ def initialize(message:, thread_id:, channel_id:, previous_message: nil, **kwargs)
9
+ super(type: :message_updated, **kwargs)
10
+ @message = message
11
+ @previous_message = previous_message
12
+ @thread_id = thread_id
13
+ @channel_id = channel_id
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "securerandom"
5
+
6
+ module ChatSDK
7
+ class History
8
+ attr_reader :user, :thread, :channel
9
+
10
+ def initialize(chat)
11
+ @user = UserHistory.new(chat)
12
+ @thread = ThreadHistory.new(chat)
13
+ @channel = ChannelHistory.new(chat)
14
+ end
15
+
16
+ class UserHistory
17
+ def initialize(chat)
18
+ @chat = chat
19
+ end
20
+
21
+ def append(thread, message, user_key: nil)
22
+ user_key ||= resolve_user_key(thread, message)
23
+ return nil unless user_key
24
+
25
+ entry = normalize_entry(thread, message, user_key)
26
+ entries = Array(@chat.state.get(storage_key(user_key)))
27
+ entries << entry
28
+ max = @chat.config.history_user[:max_per_user]
29
+ entries = entries.last(max) if max && max != false
30
+ @chat.state.set(storage_key(user_key), entries, ttl: @chat.config.history_user[:retention])
31
+ entry
32
+ end
33
+
34
+ def list(user_key:, limit: 50, platforms: nil, thread_id: nil, roles: nil)
35
+ entries = Array(@chat.state.get(storage_key(user_key)))
36
+ entries = entries.select { |entry| Array(platforms).map(&:to_s).include?(entry["platform"].to_s) } if platforms
37
+ entries = entries.select { |entry| entry["thread_id"] == thread_id } if thread_id
38
+ entries = entries.select { |entry| Array(roles).map(&:to_s).include?(entry["role"].to_s) } if roles
39
+ limit ? entries.last(limit) : entries
40
+ end
41
+
42
+ def count(user_key:)
43
+ Array(@chat.state.get(storage_key(user_key))).length
44
+ end
45
+
46
+ def delete(user_key:)
47
+ count = Array(@chat.state.get(storage_key(user_key))).length
48
+ @chat.state.delete(storage_key(user_key))
49
+ {deleted: count}
50
+ end
51
+
52
+ def to_prompt_entries(entries)
53
+ entries.map { |entry| {role: entry["role"], content: entry["text"]} }
54
+ end
55
+
56
+ private
57
+
58
+ def resolve_user_key(thread, message)
59
+ resolver = @chat.config.history_user[:identity]
60
+ return resolver.call(thread, message) if resolver
61
+ message.author.email if message.is_a?(Message) && message.author&.email
62
+ rescue => e
63
+ ChatSDK::Log.warn("History identity resolver failed: #{e.message}")
64
+ nil
65
+ end
66
+
67
+ def normalize_entry(thread, message, user_key)
68
+ if message.is_a?(Message)
69
+ {
70
+ "id" => SecureRandom.uuid,
71
+ "platform_message_id" => message.id,
72
+ "user_key" => user_key,
73
+ "role" => message.author&.bot? ? "assistant" : "user",
74
+ "text" => message.text.to_s,
75
+ "platform" => message.platform.to_s,
76
+ "thread_id" => thread.id,
77
+ "timestamp" => (message.timestamp || Time.now).iso8601
78
+ }
79
+ else
80
+ data = message.transform_keys(&:to_sym)
81
+ {
82
+ "id" => SecureRandom.uuid,
83
+ "platform_message_id" => data[:platform_message_id] || data[:id],
84
+ "user_key" => user_key,
85
+ "role" => (data[:role] || "assistant").to_s,
86
+ "text" => data[:text].to_s,
87
+ "platform" => (data[:platform] || thread.adapter.name).to_s,
88
+ "thread_id" => thread.id,
89
+ "timestamp" => (data[:timestamp] || Time.now).iso8601
90
+ }
91
+ end
92
+ end
93
+
94
+ def storage_key(user_key)
95
+ "chat_sdk:history:user:#{user_key}"
96
+ end
97
+ end
98
+
99
+ class ThreadHistory
100
+ def initialize(chat)
101
+ @chat = chat
102
+ end
103
+
104
+ def list(thread, cursor: nil, limit: 50)
105
+ unless thread.is_a?(ChatSDK::Thread)
106
+ raise ArgumentError, "thread history requires a ChatSDK::Thread"
107
+ end
108
+
109
+ messages, next_cursor = thread.adapter.fetch_messages(
110
+ channel_id: thread.channel_id,
111
+ thread_id: thread.id,
112
+ cursor: cursor,
113
+ limit: limit
114
+ )
115
+ {messages: messages, next_cursor: next_cursor}
116
+ end
117
+ end
118
+
119
+ class ChannelHistory
120
+ def initialize(chat)
121
+ @chat = chat
122
+ end
123
+
124
+ def list_messages(channel, cursor: nil, limit: 50)
125
+ unless channel.is_a?(ChatSDK::Channel)
126
+ raise ArgumentError, "channel history requires a ChatSDK::Channel"
127
+ end
128
+
129
+ messages, next_cursor = channel.adapter.fetch_channel_messages(
130
+ channel_id: channel.id,
131
+ cursor: cursor,
132
+ limit: limit
133
+ )
134
+ {messages: messages, next_cursor: next_cursor}
135
+ end
136
+
137
+ def list_threads(channel, cursor: nil, limit: 50)
138
+ unless channel.is_a?(ChatSDK::Channel)
139
+ raise ArgumentError, "channel history requires a ChatSDK::Channel"
140
+ end
141
+
142
+ threads, next_cursor = channel.adapter.list_threads(
143
+ channel_id: channel.id,
144
+ cursor: cursor,
145
+ limit: limit
146
+ )
147
+ {threads: threads, next_cursor: next_cursor}
148
+ end
149
+ end
150
+ end
151
+ end
@@ -3,10 +3,10 @@
3
3
  module ChatSDK
4
4
  class Message
5
5
  attr_reader :id, :text, :author, :thread_id, :channel_id,
6
- :platform, :attachments, :raw, :timestamp
6
+ :platform, :attachments, :links, :reply_to, :subject, :raw, :timestamp
7
7
 
8
8
  def initialize(id:, text:, author:, thread_id:, channel_id:, platform:,
9
- attachments: [], raw: nil, timestamp: nil)
9
+ attachments: [], links: [], reply_to: nil, subject: nil, raw: nil, timestamp: nil)
10
10
  @id = id
11
11
  @text = text
12
12
  @author = author
@@ -14,6 +14,9 @@ module ChatSDK
14
14
  @channel_id = channel_id
15
15
  @platform = platform
16
16
  @attachments = attachments
17
+ @links = links
18
+ @reply_to = reply_to
19
+ @subject = subject
17
20
  @raw = raw
18
21
  @timestamp = timestamp
19
22
  end