chat_sdk 0.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/lib/chat_sdk/adapter/base.rb +99 -0
  3. data/lib/chat_sdk/adapter/capabilities.rb +34 -0
  4. data/lib/chat_sdk/author.rb +28 -0
  5. data/lib/chat_sdk/cards/actions_context.rb +32 -0
  6. data/lib/chat_sdk/cards/builder.rb +52 -0
  7. data/lib/chat_sdk/cards/fields_context.rb +17 -0
  8. data/lib/chat_sdk/cards/node.rb +45 -0
  9. data/lib/chat_sdk/cards/renderer.rb +49 -0
  10. data/lib/chat_sdk/cards/select_context.rb +19 -0
  11. data/lib/chat_sdk/channel.rb +31 -0
  12. data/lib/chat_sdk/chat.rb +93 -0
  13. data/lib/chat_sdk/config.rb +41 -0
  14. data/lib/chat_sdk/dispatcher.rb +118 -0
  15. data/lib/chat_sdk/errors.rb +38 -0
  16. data/lib/chat_sdk/event_registry.rb +41 -0
  17. data/lib/chat_sdk/events/action.rb +19 -0
  18. data/lib/chat_sdk/events/base.rb +17 -0
  19. data/lib/chat_sdk/events/direct_message.rb +16 -0
  20. data/lib/chat_sdk/events/mention.rb +16 -0
  21. data/lib/chat_sdk/events/reaction.rb +27 -0
  22. data/lib/chat_sdk/events/slash_command.rb +18 -0
  23. data/lib/chat_sdk/events/subscribed_message.rb +16 -0
  24. data/lib/chat_sdk/log.rb +23 -0
  25. data/lib/chat_sdk/message.rb +30 -0
  26. data/lib/chat_sdk/modals/builder.rb +40 -0
  27. data/lib/chat_sdk/postable_message.rb +28 -0
  28. data/lib/chat_sdk/state/base.rb +47 -0
  29. data/lib/chat_sdk/state/memory.rb +105 -0
  30. data/lib/chat_sdk/streaming/stream.rb +71 -0
  31. data/lib/chat_sdk/testing/adapter_contract.rb +81 -0
  32. data/lib/chat_sdk/testing/fake_adapter.rb +195 -0
  33. data/lib/chat_sdk/testing/helpers.rb +31 -0
  34. data/lib/chat_sdk/testing/recorded_call.rb +22 -0
  35. data/lib/chat_sdk/testing/state_contract.rb +81 -0
  36. data/lib/chat_sdk/testing.rb +13 -0
  37. data/lib/chat_sdk/thread.rb +104 -0
  38. data/lib/chat_sdk/version.rb +5 -0
  39. data/lib/chat_sdk/webhook/endpoint.rb +35 -0
  40. data/lib/chat_sdk/webhook/router.rb +24 -0
  41. data/lib/chat_sdk.rb +26 -0
  42. metadata +97 -0
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+
7
+ class NotSupportedError < Error
8
+ attr_reader :capability, :adapter_name
9
+ def initialize(capability, adapter_name)
10
+ @capability = capability
11
+ @adapter_name = adapter_name
12
+ super("#{adapter_name} adapter does not support #{capability}")
13
+ end
14
+ end
15
+
16
+ class LockConflictError < Error; end
17
+ class SignatureVerificationError < Error; end
18
+
19
+ class PlatformError < Error
20
+ attr_reader :status, :body, :adapter_name
21
+ def initialize(message, status: nil, body: nil, adapter_name: nil)
22
+ @status = status
23
+ @body = body
24
+ @adapter_name = adapter_name
25
+ super(message)
26
+ end
27
+ end
28
+
29
+ class RateLimitedError < PlatformError
30
+ attr_reader :retry_after
31
+ def initialize(message, retry_after: nil, **kwargs)
32
+ @retry_after = retry_after
33
+ super(message, **kwargs)
34
+ end
35
+ end
36
+
37
+ class DuplicateEventError < Error; end
38
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ class EventRegistry
5
+ Handler = Struct.new(:type, :matcher, :block)
6
+
7
+ def initialize
8
+ @handlers = []
9
+ end
10
+
11
+ def register(type, matcher: nil, &block)
12
+ @handlers << Handler.new(type: type, matcher: matcher, block: block)
13
+ end
14
+
15
+ def handlers_for(event)
16
+ @handlers.select { |h| matches?(h, event) }
17
+ end
18
+
19
+ private
20
+
21
+ def matches?(handler, event)
22
+ return false unless handler.type == event.type
23
+ return true if handler.matcher.nil?
24
+
25
+ case handler.matcher
26
+ when Regexp
27
+ event.respond_to?(:message) && handler.matcher.match?(event.message&.text)
28
+ when String
29
+ case event.type
30
+ when :action then event.action_id == handler.matcher
31
+ when :slash_command then event.command == handler.matcher
32
+ else false
33
+ end
34
+ when Array
35
+ event.type == :reaction && handler.matcher.include?(event.emoji)
36
+ else
37
+ false
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Events
5
+ class Action < Base
6
+ attr_reader :action_id, :value, :user, :thread_id, :channel_id, :trigger_id
7
+
8
+ def initialize(action_id:, thread_id:, channel_id:, value: nil, user: nil, trigger_id: nil, **kwargs)
9
+ super(type: :action, **kwargs)
10
+ @action_id = action_id
11
+ @value = value
12
+ @user = user
13
+ @thread_id = thread_id
14
+ @channel_id = channel_id
15
+ @trigger_id = trigger_id
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 Base
6
+ attr_reader :type, :platform, :adapter_name, :raw, :timestamp
7
+
8
+ def initialize(type:, platform:, adapter_name:, raw: nil, timestamp: nil)
9
+ @type = type
10
+ @platform = platform
11
+ @adapter_name = adapter_name
12
+ @raw = raw
13
+ @timestamp = timestamp || Time.now
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Events
5
+ class DirectMessage < Base
6
+ attr_reader :message, :thread_id, :channel_id
7
+
8
+ def initialize(message:, thread_id:, channel_id:, **kwargs)
9
+ super(type: :direct_message, **kwargs)
10
+ @message = message
11
+ @thread_id = thread_id
12
+ @channel_id = channel_id
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Events
5
+ class Mention < Base
6
+ attr_reader :message, :thread_id, :channel_id
7
+
8
+ def initialize(message:, thread_id:, channel_id:, **kwargs)
9
+ super(type: :mention, **kwargs)
10
+ @message = message
11
+ @thread_id = thread_id
12
+ @channel_id = channel_id
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Events
5
+ class Reaction < Base
6
+ attr_reader :emoji, :user_id, :message_id, :thread_id, :channel_id, :added
7
+
8
+ def initialize(emoji:, user_id:, message_id:, thread_id:, channel_id:, added: true, **kwargs)
9
+ super(type: :reaction, **kwargs)
10
+ @emoji = emoji
11
+ @user_id = user_id
12
+ @message_id = message_id
13
+ @thread_id = thread_id
14
+ @channel_id = channel_id
15
+ @added = added
16
+ end
17
+
18
+ def added?
19
+ @added
20
+ end
21
+
22
+ def removed?
23
+ !@added
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Events
5
+ class SlashCommand < Base
6
+ attr_reader :command, :text, :user_id, :channel_id, :trigger_id
7
+
8
+ def initialize(command:, user_id:, channel_id:, text: "", trigger_id: nil, **kwargs)
9
+ super(type: :slash_command, **kwargs)
10
+ @command = command
11
+ @text = text
12
+ @user_id = user_id
13
+ @channel_id = channel_id
14
+ @trigger_id = trigger_id
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Events
5
+ class SubscribedMessage < Base
6
+ attr_reader :message, :thread_id, :channel_id
7
+
8
+ def initialize(message:, thread_id:, channel_id:, **kwargs)
9
+ super(type: :subscribed_message, **kwargs)
10
+ @message = message
11
+ @thread_id = thread_id
12
+ @channel_id = channel_id
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module ChatSDK
6
+ module Log
7
+ class << self
8
+ attr_writer :logger
9
+
10
+ def logger
11
+ @logger ||= ::Logger.new($stdout, progname: "ChatSDK")
12
+ end
13
+
14
+ %i[debug info warn error fatal].each do |level|
15
+ define_method(level) { |msg = nil, &block| logger.send(level, msg, &block) }
16
+ end
17
+
18
+ def level=(level)
19
+ logger.level = level
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ class Message
5
+ attr_reader :id, :text, :author, :thread_id, :channel_id,
6
+ :platform, :attachments, :raw, :timestamp
7
+
8
+ def initialize(id:, text:, author:, thread_id:, channel_id:, platform:,
9
+ attachments: [], raw: nil, timestamp: nil)
10
+ @id = id
11
+ @text = text
12
+ @author = author
13
+ @thread_id = thread_id
14
+ @channel_id = channel_id
15
+ @platform = platform
16
+ @attachments = attachments
17
+ @raw = raw
18
+ @timestamp = timestamp
19
+ end
20
+
21
+ def ==(other)
22
+ other.is_a?(Message) && id == other.id && platform == other.platform
23
+ end
24
+ alias_method :eql?, :==
25
+
26
+ def hash
27
+ [id, platform].hash
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Modals
5
+ class Builder
6
+ def initialize(title:, submit_label: nil, callback_id: nil, &block)
7
+ @title = title
8
+ @submit_label = submit_label
9
+ @callback_id = callback_id
10
+ @children = []
11
+ instance_eval(&block) if block
12
+ end
13
+
14
+ def build
15
+ attrs = {title: @title}
16
+ attrs[:submit_label] = @submit_label if @submit_label
17
+ attrs[:callback_id] = @callback_id if @callback_id
18
+ ChatSDK::Cards::Node.new(:modal, attributes: attrs, children: @children)
19
+ end
20
+
21
+ def text_input(id:, label:, placeholder: nil, multiline: false, optional: false)
22
+ attrs = {id: id, label: label, input_type: :text, multiline: multiline, optional: optional}
23
+ attrs[:placeholder] = placeholder if placeholder
24
+ @children << ChatSDK::Cards::Node.new(:input, attributes: attrs)
25
+ end
26
+
27
+ def select_input(id:, label:, placeholder: nil, optional: false, &block)
28
+ ctx = ChatSDK::Cards::SelectContext.new
29
+ ctx.instance_eval(&block)
30
+ attrs = {id: id, label: label, input_type: :select, optional: optional}
31
+ attrs[:placeholder] = placeholder if placeholder
32
+ @children << ChatSDK::Cards::Node.new(:input, attributes: attrs, children: ctx.nodes)
33
+ end
34
+
35
+ def static_text(content)
36
+ @children << ChatSDK::Cards::Node.new(:text, attributes: {content: content})
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ class PostableMessage
5
+ attr_reader :text, :card, :attachments, :metadata
6
+
7
+ def initialize(text: nil, card: nil, attachments: [], metadata: {})
8
+ raise ArgumentError, "text or card required" if text.nil? && card.nil?
9
+ @text = text
10
+ @card = card
11
+ @attachments = attachments
12
+ @metadata = metadata
13
+ end
14
+
15
+ def card?
16
+ !@card.nil?
17
+ end
18
+
19
+ def self.from(content)
20
+ case content
21
+ when PostableMessage then content
22
+ when String then new(text: content)
23
+ when Cards::Node then new(card: content, text: content.fallback_text)
24
+ else raise ArgumentError, "cannot convert #{content.class} to PostableMessage"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module State
5
+ class Base
6
+ def subscribe(thread_id)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def unsubscribe(thread_id)
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def subscribed?(thread_id)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def acquire_lock(key, owner:, ttl:)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def release_lock(key, owner:)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def force_lock(key, owner:, ttl:)
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def get(key)
31
+ raise NotImplementedError
32
+ end
33
+
34
+ def set(key, value, ttl: nil)
35
+ raise NotImplementedError
36
+ end
37
+
38
+ def delete(key)
39
+ raise NotImplementedError
40
+ end
41
+
42
+ def set_if_absent(key, value, ttl: nil)
43
+ raise NotImplementedError
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module State
5
+ class Memory < Base
6
+ def initialize
7
+ @mutex = Mutex.new
8
+ @store = {}
9
+ @expirations = {}
10
+ @subscriptions = Set.new
11
+ @locks = {}
12
+ end
13
+
14
+ def subscribe(thread_id)
15
+ @mutex.synchronize { @subscriptions.add(thread_id) }
16
+ end
17
+
18
+ def unsubscribe(thread_id)
19
+ @mutex.synchronize { @subscriptions.delete(thread_id) }
20
+ end
21
+
22
+ def subscribed?(thread_id)
23
+ @mutex.synchronize { @subscriptions.include?(thread_id) }
24
+ end
25
+
26
+ def acquire_lock(key, owner:, ttl:)
27
+ @mutex.synchronize do
28
+ expire_if_needed(key)
29
+ return false if @locks.key?(key)
30
+ @locks[key] = {owner: owner, expires_at: Time.now.to_f + ttl}
31
+ true
32
+ end
33
+ end
34
+
35
+ def release_lock(key, owner:)
36
+ @mutex.synchronize do
37
+ return false unless @locks[key] && @locks[key][:owner] == owner
38
+ @locks.delete(key)
39
+ true
40
+ end
41
+ end
42
+
43
+ def force_lock(key, owner:, ttl:)
44
+ @mutex.synchronize do
45
+ @locks[key] = {owner: owner, expires_at: Time.now.to_f + ttl}
46
+ true
47
+ end
48
+ end
49
+
50
+ def get(key)
51
+ @mutex.synchronize do
52
+ expire_if_needed(key)
53
+ @store[key]
54
+ end
55
+ end
56
+
57
+ def set(key, value, ttl: nil)
58
+ @mutex.synchronize do
59
+ @store[key] = value
60
+ @expirations[key] = Time.now.to_f + ttl if ttl
61
+ value
62
+ end
63
+ end
64
+
65
+ def delete(key)
66
+ @mutex.synchronize do
67
+ @store.delete(key)
68
+ @expirations.delete(key)
69
+ @locks.delete(key)
70
+ end
71
+ end
72
+
73
+ def set_if_absent(key, value, ttl: nil)
74
+ @mutex.synchronize do
75
+ expire_if_needed(key)
76
+ return false if @store.key?(key)
77
+ @store[key] = value
78
+ @expirations[key] = Time.now.to_f + ttl if ttl
79
+ true
80
+ end
81
+ end
82
+
83
+ def clear
84
+ @mutex.synchronize do
85
+ @store.clear
86
+ @expirations.clear
87
+ @subscriptions.clear
88
+ @locks.clear
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def expire_if_needed(key)
95
+ if @expirations[key] && @expirations[key] < Time.now.to_f
96
+ @store.delete(key)
97
+ @expirations.delete(key)
98
+ end
99
+ if @locks[key] && @locks[key][:expires_at] < Time.now.to_f
100
+ @locks.delete(key)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Streaming
5
+ class Stream
6
+ attr_reader :adapter, :channel_id, :thread_id, :update_interval
7
+
8
+ def initialize(adapter:, channel_id:, thread_id:, placeholder: nil, update_interval: 0.5)
9
+ @adapter = adapter
10
+ @channel_id = channel_id
11
+ @thread_id = thread_id
12
+ @placeholder = placeholder
13
+ @update_interval = update_interval
14
+ @buffer = +""
15
+ @message_id = nil
16
+ @last_flush_at = nil
17
+ end
18
+
19
+ def <<(chunk)
20
+ @buffer << chunk.to_s
21
+ maybe_flush
22
+ self
23
+ end
24
+
25
+ def run(&block)
26
+ post_placeholder if @placeholder
27
+ yield self
28
+ flush
29
+ end
30
+
31
+ private
32
+
33
+ def post_placeholder
34
+ result = adapter.post_message(
35
+ channel_id: channel_id,
36
+ message: PostableMessage.new(text: @placeholder),
37
+ thread_id: thread_id
38
+ )
39
+ @message_id = result.id if result.respond_to?(:id)
40
+ end
41
+
42
+ def maybe_flush
43
+ return unless @message_id
44
+ return if @last_flush_at && (Time.now.to_f - @last_flush_at) < update_interval
45
+
46
+ flush
47
+ end
48
+
49
+ def flush
50
+ return if @buffer.empty?
51
+
52
+ if @message_id && adapter.supports?(:edit_messages)
53
+ adapter.edit_message(
54
+ channel_id: channel_id,
55
+ message_id: @message_id,
56
+ message: PostableMessage.new(text: @buffer.dup)
57
+ )
58
+ elsif !@message_id
59
+ result = adapter.post_message(
60
+ channel_id: channel_id,
61
+ message: PostableMessage.new(text: @buffer.dup),
62
+ thread_id: thread_id
63
+ )
64
+ @message_id = result.id if result.respond_to?(:id)
65
+ end
66
+
67
+ @last_flush_at = Time.now.to_f
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Testing
5
+ module AdapterContract
6
+ RSpec.shared_examples "a chat_sdk platform adapter" do
7
+ it "responds to #name with a symbol" do
8
+ expect(subject.name).to be_a(Symbol)
9
+ end
10
+
11
+ it "responds to #client" do
12
+ expect(subject).to respond_to(:client)
13
+ end
14
+
15
+ it "responds to #verify_request!" do
16
+ expect(subject).to respond_to(:verify_request!)
17
+ end
18
+
19
+ it "responds to #parse_events" do
20
+ expect(subject).to respond_to(:parse_events)
21
+ end
22
+
23
+ it "responds to #post_message" do
24
+ expect(subject).to respond_to(:post_message)
25
+ end
26
+
27
+ it "responds to #mention" do
28
+ expect(subject).to respond_to(:mention)
29
+ end
30
+
31
+ it "responds to #render" do
32
+ expect(subject).to respond_to(:render)
33
+ end
34
+
35
+ it "responds to #supports?" do
36
+ expect(subject).to respond_to(:supports?)
37
+ end
38
+
39
+ it "raises NotSupportedError for undeclared capabilities" do
40
+ undeclared = Adapter::Capabilities::KNOWN - subject.class.declared_capabilities
41
+ undeclared.each do |cap|
42
+ method_for_cap = capability_method(cap)
43
+ next unless method_for_cap && subject.respond_to?(method_for_cap)
44
+ expect { subject.send(method_for_cap, **capability_args(cap)) }.to raise_error(NotSupportedError)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def capability_method(cap)
51
+ {
52
+ edit_messages: :edit_message,
53
+ delete_messages: :delete_message,
54
+ ephemeral_messages: :post_ephemeral,
55
+ file_uploads: :upload_file,
56
+ reactions: :add_reaction,
57
+ modals: :open_modal,
58
+ typing_indicator: :start_typing,
59
+ direct_messages: :open_dm,
60
+ message_history: :fetch_messages
61
+ }[cap]
62
+ end
63
+
64
+ def capability_args(cap)
65
+ case cap
66
+ when :edit_messages then {channel_id: "C1", message_id: "M1", message: PostableMessage.new(text: "t")}
67
+ when :delete_messages then {channel_id: "C1", message_id: "M1"}
68
+ when :ephemeral_messages then {channel_id: "C1", user_id: "U1", message: PostableMessage.new(text: "t")}
69
+ when :file_uploads then {channel_id: "C1", io: StringIO.new(""), filename: "f.txt"}
70
+ when :reactions then {channel_id: "C1", message_id: "M1", emoji: "thumbsup"}
71
+ when :modals then {trigger_id: "T1", modal: Cards::Node.new(:modal)}
72
+ when :typing_indicator then {channel_id: "C1"}
73
+ when :direct_messages then {user_id: "U1"}
74
+ when :message_history then {channel_id: "C1"}
75
+ else {}
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end