chat_sdk 1.0.1 → 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 +4 -4
- data/lib/chat_sdk/adapter/base.rb +20 -0
- data/lib/chat_sdk/adapter/capabilities.rb +1 -1
- data/lib/chat_sdk/ai/conversation_scope.rb +34 -0
- data/lib/chat_sdk/ai/converter.rb +16 -5
- data/lib/chat_sdk/ai/tool_builder.rb +16 -1
- data/lib/chat_sdk/ai/tool_executor.rb +42 -5
- data/lib/chat_sdk/ai.rb +2 -2
- data/lib/chat_sdk/author.rb +8 -2
- data/lib/chat_sdk/cards/actions_context.rb +7 -3
- data/lib/chat_sdk/cards/builder.rb +21 -1
- data/lib/chat_sdk/cards/node.rb +34 -0
- data/lib/chat_sdk/cards/renderer.rb +19 -0
- data/lib/chat_sdk/channel.rb +8 -0
- data/lib/chat_sdk/chat.rb +20 -1
- data/lib/chat_sdk/config.rb +48 -1
- data/lib/chat_sdk/dispatcher.rb +221 -28
- data/lib/chat_sdk/event_serializer.rb +162 -0
- data/lib/chat_sdk/events/message_deleted.rb +19 -0
- data/lib/chat_sdk/events/message_updated.rb +17 -0
- data/lib/chat_sdk/history.rb +151 -0
- data/lib/chat_sdk/message.rb +5 -2
- data/lib/chat_sdk/modals/builder.rb +16 -0
- data/lib/chat_sdk/state/base.rb +16 -0
- data/lib/chat_sdk/state/memory.rb +31 -0
- data/lib/chat_sdk/testing/fake_adapter.rb +26 -2
- data/lib/chat_sdk/thread.rb +25 -2
- data/lib/chat_sdk/version.rb +1 -1
- data/lib/chat_sdk.rb +2 -2
- metadata +6 -1
data/lib/chat_sdk/dispatcher.rb
CHANGED
|
@@ -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
|
-
|
|
36
|
+
if strategy == :concurrent
|
|
37
|
+
return with_concurrent_slot(thread_key) { execute_event(event, thread) }
|
|
38
|
+
end
|
|
20
39
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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:
|
|
191
|
+
acquired = @state.acquire_lock(lock_key, owner: owner, ttl: @config.concurrency[:lock_ttl])
|
|
66
192
|
|
|
67
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
data/lib/chat_sdk/message.rb
CHANGED
|
@@ -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
|