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.
- checksums.yaml +7 -0
- data/lib/chat_sdk/adapter/base.rb +99 -0
- data/lib/chat_sdk/adapter/capabilities.rb +34 -0
- data/lib/chat_sdk/author.rb +28 -0
- data/lib/chat_sdk/cards/actions_context.rb +32 -0
- data/lib/chat_sdk/cards/builder.rb +52 -0
- data/lib/chat_sdk/cards/fields_context.rb +17 -0
- data/lib/chat_sdk/cards/node.rb +45 -0
- data/lib/chat_sdk/cards/renderer.rb +49 -0
- data/lib/chat_sdk/cards/select_context.rb +19 -0
- data/lib/chat_sdk/channel.rb +31 -0
- data/lib/chat_sdk/chat.rb +93 -0
- data/lib/chat_sdk/config.rb +41 -0
- data/lib/chat_sdk/dispatcher.rb +118 -0
- data/lib/chat_sdk/errors.rb +38 -0
- data/lib/chat_sdk/event_registry.rb +41 -0
- data/lib/chat_sdk/events/action.rb +19 -0
- data/lib/chat_sdk/events/base.rb +17 -0
- data/lib/chat_sdk/events/direct_message.rb +16 -0
- data/lib/chat_sdk/events/mention.rb +16 -0
- data/lib/chat_sdk/events/reaction.rb +27 -0
- data/lib/chat_sdk/events/slash_command.rb +18 -0
- data/lib/chat_sdk/events/subscribed_message.rb +16 -0
- data/lib/chat_sdk/log.rb +23 -0
- data/lib/chat_sdk/message.rb +30 -0
- data/lib/chat_sdk/modals/builder.rb +40 -0
- data/lib/chat_sdk/postable_message.rb +28 -0
- data/lib/chat_sdk/state/base.rb +47 -0
- data/lib/chat_sdk/state/memory.rb +105 -0
- data/lib/chat_sdk/streaming/stream.rb +71 -0
- data/lib/chat_sdk/testing/adapter_contract.rb +81 -0
- data/lib/chat_sdk/testing/fake_adapter.rb +195 -0
- data/lib/chat_sdk/testing/helpers.rb +31 -0
- data/lib/chat_sdk/testing/recorded_call.rb +22 -0
- data/lib/chat_sdk/testing/state_contract.rb +81 -0
- data/lib/chat_sdk/testing.rb +13 -0
- data/lib/chat_sdk/thread.rb +104 -0
- data/lib/chat_sdk/version.rb +5 -0
- data/lib/chat_sdk/webhook/endpoint.rb +35 -0
- data/lib/chat_sdk/webhook/router.rb +24 -0
- data/lib/chat_sdk.rb +26 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6bcc60c6b4443d14e0afb167cc7a6b8f5bbe60663fd97e275f87435313cf0705
|
|
4
|
+
data.tar.gz: 4f3820e9f8ae54789a15b89f351e035b942d741d7670accd509adf86e693b4c8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b60a52d3a1c878b74e57e57ee3ed2b40191f60ce2db45c3c80ec762324f54b738f6d4ea448f362f02f3e0a84026656e55090c1feef4abf96cfe6b67ba2c19675
|
|
7
|
+
data.tar.gz: f13771e3df25d44835659b04d424b6410eb2c9f8ffd5a53e3aaa24cd111446ca10b53a6f88adccccb6b261a7289597f0ee7c166795b6abb6fe69fccfc2fa7c4d
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Adapter
|
|
5
|
+
class Base
|
|
6
|
+
include Capabilities
|
|
7
|
+
|
|
8
|
+
def name
|
|
9
|
+
raise NotImplementedError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def client
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Inbound
|
|
17
|
+
def verify_request!(rack_request)
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ack_response(rack_request)
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def parse_events(rack_request)
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Outbound
|
|
30
|
+
def post_message(channel_id:, message:, thread_id: nil)
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def edit_message(channel_id:, message_id:, message:)
|
|
35
|
+
require_capability!(:edit_messages)
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def delete_message(channel_id:, message_id:)
|
|
40
|
+
require_capability!(:delete_messages)
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def post_ephemeral(channel_id:, user_id:, message:, thread_id: nil)
|
|
45
|
+
require_capability!(:ephemeral_messages)
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil)
|
|
50
|
+
require_capability!(:file_uploads)
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def add_reaction(channel_id:, message_id:, emoji:)
|
|
55
|
+
require_capability!(:reactions)
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def remove_reaction(channel_id:, message_id:, emoji:)
|
|
60
|
+
require_capability!(:reactions)
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def open_dm(user_id)
|
|
65
|
+
require_capability!(:direct_messages)
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fetch_messages(channel_id:, thread_id: nil, cursor: nil, limit: 50)
|
|
70
|
+
require_capability!(:message_history)
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def open_modal(trigger_id:, modal:)
|
|
75
|
+
require_capability!(:modals)
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def start_typing(channel_id:, thread_id: nil)
|
|
80
|
+
require_capability!(:typing_indicator)
|
|
81
|
+
raise NotImplementedError
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def mention(user_id)
|
|
85
|
+
raise NotImplementedError
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def render(postable_message)
|
|
89
|
+
Cards::Renderer.new.render(postable_message.card)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def require_capability!(cap)
|
|
95
|
+
raise NotSupportedError.new(cap, name) unless supports?(cap)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Adapter
|
|
5
|
+
module Capabilities
|
|
6
|
+
KNOWN = %i[
|
|
7
|
+
edit_messages delete_messages ephemeral_messages
|
|
8
|
+
file_uploads reactions modals typing_indicator
|
|
9
|
+
streaming_edit threads direct_messages
|
|
10
|
+
scheduled_messages message_history
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def self.included(base)
|
|
14
|
+
base.extend(ClassMethods)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module ClassMethods
|
|
18
|
+
def capabilities(*caps)
|
|
19
|
+
unknown = caps - KNOWN
|
|
20
|
+
raise ArgumentError, "Unknown capabilities: #{unknown.join(", ")}" if unknown.any?
|
|
21
|
+
@capabilities = caps
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def declared_capabilities
|
|
25
|
+
@capabilities || []
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def supports?(capability)
|
|
30
|
+
self.class.declared_capabilities.include?(capability)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
class Author
|
|
5
|
+
attr_reader :id, :name, :platform, :raw
|
|
6
|
+
|
|
7
|
+
def initialize(id:, name:, platform:, bot: false, raw: nil)
|
|
8
|
+
@id = id
|
|
9
|
+
@name = name
|
|
10
|
+
@platform = platform
|
|
11
|
+
@bot = bot
|
|
12
|
+
@raw = raw
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def bot?
|
|
16
|
+
@bot
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ==(other)
|
|
20
|
+
other.is_a?(Author) && id == other.id && platform == other.platform
|
|
21
|
+
end
|
|
22
|
+
alias_method :eql?, :==
|
|
23
|
+
|
|
24
|
+
def hash
|
|
25
|
+
[id, platform].hash
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Cards
|
|
5
|
+
class ActionsContext
|
|
6
|
+
attr_reader :nodes
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@nodes = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def button(text, id:, style: nil, value: nil)
|
|
13
|
+
attrs = {text: text, id: id}
|
|
14
|
+
attrs[:style] = style if style
|
|
15
|
+
attrs[:value] = value if value
|
|
16
|
+
@nodes << Node.new(:button, attributes: attrs)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def link_button(text, url:)
|
|
20
|
+
@nodes << Node.new(:link_button, attributes: {text: text, url: url})
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def select(id:, placeholder: nil, &block)
|
|
24
|
+
ctx = SelectContext.new
|
|
25
|
+
ctx.instance_eval(&block)
|
|
26
|
+
attrs = {id: id}
|
|
27
|
+
attrs[:placeholder] = placeholder if placeholder
|
|
28
|
+
@nodes << Node.new(:select, attributes: attrs, children: ctx.nodes)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Cards
|
|
5
|
+
class Builder
|
|
6
|
+
def initialize(title: nil, subtitle: nil, &block)
|
|
7
|
+
@title = title
|
|
8
|
+
@subtitle = subtitle
|
|
9
|
+
@children = []
|
|
10
|
+
instance_eval(&block) if block
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build
|
|
14
|
+
attrs = {}
|
|
15
|
+
attrs[:title] = @title if @title
|
|
16
|
+
attrs[:subtitle] = @subtitle if @subtitle
|
|
17
|
+
Node.new(:card, attributes: attrs, children: @children)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def text(content)
|
|
21
|
+
@children << Node.new(:text, attributes: {content: content})
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def divider
|
|
25
|
+
@children << Node.new(:divider)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def image(url:, alt: nil)
|
|
29
|
+
@children << Node.new(:image, attributes: {url: url, alt: alt})
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fields(&block)
|
|
33
|
+
ctx = FieldsContext.new
|
|
34
|
+
ctx.instance_eval(&block)
|
|
35
|
+
@children << Node.new(:fields, children: ctx.nodes)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def section(title = nil, &block)
|
|
39
|
+
ctx = Builder.new
|
|
40
|
+
ctx.instance_eval(&block)
|
|
41
|
+
attrs = title ? {title: title} : {}
|
|
42
|
+
@children << Node.new(:section, attributes: attrs, children: ctx.build.children)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def actions(&block)
|
|
46
|
+
ctx = ActionsContext.new
|
|
47
|
+
ctx.instance_eval(&block)
|
|
48
|
+
@children << Node.new(:actions, children: ctx.nodes)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Cards
|
|
5
|
+
class FieldsContext
|
|
6
|
+
attr_reader :nodes
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@nodes = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def field(label, value)
|
|
13
|
+
@nodes << Node.new(:field, attributes: {label: label, value: value})
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Cards
|
|
5
|
+
class Node
|
|
6
|
+
attr_reader :type, :attributes, :children
|
|
7
|
+
|
|
8
|
+
def initialize(type, attributes: {}, children: [])
|
|
9
|
+
@type = type
|
|
10
|
+
@attributes = attributes
|
|
11
|
+
@children = children.freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def fallback_text
|
|
15
|
+
collect_text.join("\n").strip
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def ==(other)
|
|
19
|
+
other.is_a?(Node) && type == other.type &&
|
|
20
|
+
attributes == other.attributes && children == other.children
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def collect_text(nodes = [self])
|
|
26
|
+
nodes.flat_map do |node|
|
|
27
|
+
case node.type
|
|
28
|
+
when :card
|
|
29
|
+
collect_text(node.children)
|
|
30
|
+
when :text
|
|
31
|
+
[node.attributes[:content]]
|
|
32
|
+
when :field
|
|
33
|
+
["#{node.attributes[:label]}: #{node.attributes[:value]}"]
|
|
34
|
+
when :section
|
|
35
|
+
collect_text(node.children)
|
|
36
|
+
when :button, :link_button
|
|
37
|
+
[node.attributes[:text]]
|
|
38
|
+
else
|
|
39
|
+
collect_text(node.children)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Cards
|
|
5
|
+
class Renderer
|
|
6
|
+
def render(node)
|
|
7
|
+
case node.type
|
|
8
|
+
when :card then render_card(node)
|
|
9
|
+
when :text then node.attributes[:content]
|
|
10
|
+
when :divider then "---"
|
|
11
|
+
when :image then "![#{node.attributes[:alt]}](#{node.attributes[:url]})"
|
|
12
|
+
when :fields then render_fields(node)
|
|
13
|
+
when :field then "**#{node.attributes[:label]}**: #{node.attributes[:value]}"
|
|
14
|
+
when :section then render_section(node)
|
|
15
|
+
when :actions then render_actions(node)
|
|
16
|
+
when :button then "[#{node.attributes[:text]}]"
|
|
17
|
+
when :link_button then "[#{node.attributes[:text]}](#{node.attributes[:url]})"
|
|
18
|
+
when :select then "_#{node.attributes[:placeholder] || "Select"}_"
|
|
19
|
+
else ""
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def render_card(node)
|
|
26
|
+
parts = []
|
|
27
|
+
parts << "**#{node.attributes[:title]}**" if node.attributes[:title]
|
|
28
|
+
parts << "*#{node.attributes[:subtitle]}*" if node.attributes[:subtitle]
|
|
29
|
+
parts.concat(node.children.map { |c| render(c) })
|
|
30
|
+
parts.reject(&:empty?).join("\n\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render_fields(node)
|
|
34
|
+
node.children.map { |c| render(c) }.join(" | ")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def render_section(node)
|
|
38
|
+
parts = []
|
|
39
|
+
parts << "### #{node.attributes[:title]}" if node.attributes[:title]
|
|
40
|
+
parts.concat(node.children.map { |c| render(c) })
|
|
41
|
+
parts.join("\n")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def render_actions(node)
|
|
45
|
+
node.children.map { |c| render(c) }.join(" | ")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Cards
|
|
5
|
+
class SelectContext
|
|
6
|
+
attr_reader :nodes
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@nodes = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def option(text, value:, description: nil)
|
|
13
|
+
attrs = {text: text, value: value}
|
|
14
|
+
attrs[:description] = description if description
|
|
15
|
+
@nodes << Node.new(:option, attributes: attrs)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
class Channel
|
|
5
|
+
attr_reader :id, :adapter, :chat
|
|
6
|
+
|
|
7
|
+
def initialize(id:, adapter:, chat:)
|
|
8
|
+
@id = id
|
|
9
|
+
@adapter = adapter
|
|
10
|
+
@chat = chat
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def post(content)
|
|
14
|
+
message = PostableMessage.from(content)
|
|
15
|
+
adapter.post_message(channel_id: id, message: message)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def thread(thread_id)
|
|
19
|
+
ChatSDK::Thread.new(id: thread_id, channel_id: id, adapter: adapter, chat: chat)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def ==(other)
|
|
23
|
+
other.is_a?(Channel) && id == other.id
|
|
24
|
+
end
|
|
25
|
+
alias_method :eql?, :==
|
|
26
|
+
|
|
27
|
+
def hash
|
|
28
|
+
id.hash
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
class Chat
|
|
5
|
+
attr_reader :config, :state
|
|
6
|
+
|
|
7
|
+
def initialize(user_name:, adapters:, state:, **options)
|
|
8
|
+
@config = Config.new(user_name: user_name, adapters: adapters, state: state, **options)
|
|
9
|
+
@state = state
|
|
10
|
+
@adapters = adapters
|
|
11
|
+
@registry = EventRegistry.new
|
|
12
|
+
@webhooks = {}
|
|
13
|
+
@dispatcher = Dispatcher.new(chat: self, config: @config, state: @state, registry: @registry)
|
|
14
|
+
|
|
15
|
+
ChatSDK::Log.level = @config.log_level
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Event registration
|
|
19
|
+
def on_new_mention(&block)
|
|
20
|
+
@registry.register(:mention, &block)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def on_subscribed_message(&block)
|
|
24
|
+
@registry.register(:subscribed_message, &block)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def on_new_message(pattern = nil, &block)
|
|
28
|
+
@registry.register(:mention, matcher: pattern, &block)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def on_direct_message(&block)
|
|
32
|
+
@registry.register(:direct_message, &block)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def on_reaction(emojis = nil, &block)
|
|
36
|
+
@registry.register(:reaction, matcher: emojis, &block)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_action(action_id, &block)
|
|
40
|
+
@registry.register(:action, matcher: action_id, &block)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def on_slash_command(command, &block)
|
|
44
|
+
@registry.register(:slash_command, matcher: command, &block)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Adapter access
|
|
48
|
+
def adapter(name)
|
|
49
|
+
@adapters.fetch(name) { raise ChatSDK::ConfigurationError, "Unknown adapter: #{name}" }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Channel/DM access
|
|
53
|
+
def channel(id, adapter_name: nil)
|
|
54
|
+
adp = adapter_name ? adapter(adapter_name) : @adapters.values.first
|
|
55
|
+
Channel.new(id: id, adapter: adp, chat: self)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def open_dm(user_id, adapter_name: nil)
|
|
59
|
+
adp = adapter_name ? adapter(adapter_name) : @adapters.values.first
|
|
60
|
+
channel_id = adp.open_dm(user_id)
|
|
61
|
+
Channel.new(id: channel_id, adapter: adp, chat: self)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Webhook endpoints (Rack apps)
|
|
65
|
+
def webhooks
|
|
66
|
+
@webhook_accessor ||= WebhookAccessor.new(self, @adapters)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Event dispatch (called by webhook endpoints)
|
|
70
|
+
def dispatch(event, adapter_name:)
|
|
71
|
+
adp = adapter(adapter_name)
|
|
72
|
+
@dispatcher.dispatch(event, adapter: adp, adapter_name: adapter_name)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class WebhookAccessor
|
|
79
|
+
def initialize(chat, adapters)
|
|
80
|
+
@chat = chat
|
|
81
|
+
@adapters = adapters
|
|
82
|
+
@endpoints = {}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def [](adapter_name)
|
|
86
|
+
@endpoints[adapter_name] ||= Webhook::Endpoint.new(chat: @chat, adapter: @chat.adapter(adapter_name), adapter_name: adapter_name)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def router
|
|
90
|
+
@router ||= Webhook::Router.new(@chat, @adapters)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
class Config
|
|
5
|
+
DEFAULTS = {
|
|
6
|
+
dedupe_ttl: 600,
|
|
7
|
+
streaming_update_interval: 0.5,
|
|
8
|
+
on_lock_conflict: :drop,
|
|
9
|
+
handler_executor: :inline,
|
|
10
|
+
log_level: :info
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :user_name, :adapters, :state, :on_lock_conflict,
|
|
14
|
+
:dedupe_ttl, :streaming_update_interval, :handler_executor, :log_level
|
|
15
|
+
|
|
16
|
+
def initialize(user_name:, adapters:, state:, **options)
|
|
17
|
+
raise ConfigurationError, "user_name is required" if user_name.nil? || user_name.empty?
|
|
18
|
+
raise ConfigurationError, "adapters hash is required" if adapters.nil? || adapters.empty?
|
|
19
|
+
raise ConfigurationError, "state adapter is required" if state.nil?
|
|
20
|
+
|
|
21
|
+
@user_name = user_name
|
|
22
|
+
@adapters = adapters
|
|
23
|
+
@state = state
|
|
24
|
+
merged = DEFAULTS.merge(options)
|
|
25
|
+
@on_lock_conflict = merged[:on_lock_conflict]
|
|
26
|
+
@dedupe_ttl = merged[:dedupe_ttl]
|
|
27
|
+
@streaming_update_interval = merged[:streaming_update_interval]
|
|
28
|
+
@handler_executor = merged[:handler_executor]
|
|
29
|
+
@log_level = merged[:log_level]
|
|
30
|
+
|
|
31
|
+
validate_lock_conflict!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validate_lock_conflict!
|
|
37
|
+
return if %i[drop force].include?(@on_lock_conflict) || @on_lock_conflict.respond_to?(:call)
|
|
38
|
+
raise ConfigurationError, "on_lock_conflict must be :drop, :force, or a callable"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
class Dispatcher
|
|
5
|
+
def initialize(chat:, config:, state:, registry:)
|
|
6
|
+
@chat = chat
|
|
7
|
+
@config = config
|
|
8
|
+
@state = state
|
|
9
|
+
@registry = registry
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def dispatch(event, adapter:, adapter_name:)
|
|
13
|
+
return unless dedupe(event, adapter_name)
|
|
14
|
+
|
|
15
|
+
thread = build_thread(event, adapter)
|
|
16
|
+
thread_key = thread_key_for(event, adapter_name)
|
|
17
|
+
|
|
18
|
+
return unless acquire_lock(thread_key, event)
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
handlers = @registry.handlers_for(event)
|
|
22
|
+
handlers.each { |handler| execute_handler(handler, event, thread) }
|
|
23
|
+
ensure
|
|
24
|
+
release_lock(thread_key)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def dedupe(event, adapter_name)
|
|
31
|
+
event_id = extract_event_id(event)
|
|
32
|
+
return true unless event_id
|
|
33
|
+
|
|
34
|
+
@state.set_if_absent(
|
|
35
|
+
"chat_sdk:dedupe:#{adapter_name}:#{event_id}",
|
|
36
|
+
true,
|
|
37
|
+
ttl: @config.dedupe_ttl
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def extract_event_id(event)
|
|
42
|
+
if event.respond_to?(:message) && event.message
|
|
43
|
+
event.message.id
|
|
44
|
+
elsif event.respond_to?(:raw) && event.raw.is_a?(Hash)
|
|
45
|
+
event.raw[:event_id] || event.raw["event_id"]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def thread_key_for(event, adapter_name)
|
|
50
|
+
channel_id = event.respond_to?(:channel_id) ? event.channel_id : nil
|
|
51
|
+
thread_id = event.respond_to?(:thread_id) ? event.thread_id : nil
|
|
52
|
+
"#{adapter_name}:#{channel_id}:#{thread_id}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def lock_owner
|
|
56
|
+
@lock_owner ||= "#{Process.pid}:#{::Thread.current.object_id}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def acquire_lock(thread_key, event)
|
|
60
|
+
lock_key = "chat_sdk:lock:#{thread_key}"
|
|
61
|
+
return true if @state.acquire_lock(lock_key, owner: lock_owner, ttl: 30)
|
|
62
|
+
|
|
63
|
+
case @config.on_lock_conflict
|
|
64
|
+
when :drop
|
|
65
|
+
ChatSDK::Log.debug("Lock conflict, dropping event for #{thread_key}")
|
|
66
|
+
false
|
|
67
|
+
when :force
|
|
68
|
+
@state.force_lock(lock_key, owner: lock_owner, ttl: 30)
|
|
69
|
+
true
|
|
70
|
+
else
|
|
71
|
+
if @config.on_lock_conflict.respond_to?(:call)
|
|
72
|
+
policy = @config.on_lock_conflict.call(thread_key, event)
|
|
73
|
+
case policy
|
|
74
|
+
when :force
|
|
75
|
+
@state.force_lock(lock_key, owner: lock_owner, ttl: 30)
|
|
76
|
+
true
|
|
77
|
+
else
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
else
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def release_lock(thread_key)
|
|
87
|
+
@state.release_lock("chat_sdk:lock:#{thread_key}", owner: lock_owner)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_thread(event, adapter)
|
|
91
|
+
thread_id = event.respond_to?(:thread_id) ? event.thread_id : nil
|
|
92
|
+
channel_id = event.respond_to?(:channel_id) ? event.channel_id : nil
|
|
93
|
+
return nil unless thread_id && channel_id
|
|
94
|
+
|
|
95
|
+
ChatSDK::Thread.new(id: thread_id, channel_id: channel_id, adapter: adapter, chat: @chat)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def execute_handler(handler, event, thread)
|
|
99
|
+
case event.type
|
|
100
|
+
when :mention, :subscribed_message, :direct_message
|
|
101
|
+
handler.block.call(thread, event.message)
|
|
102
|
+
when :reaction, :action, :slash_command
|
|
103
|
+
add_thread_to_event(event, thread)
|
|
104
|
+
handler.block.call(event)
|
|
105
|
+
end
|
|
106
|
+
rescue => e
|
|
107
|
+
ChatSDK::Log.error("Handler error (#{event.type}): #{e.message}")
|
|
108
|
+
ChatSDK::Log.debug(e.backtrace&.first(5)&.join("\n"))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def add_thread_to_event(event, thread)
|
|
112
|
+
event.instance_variable_set(:@thread, thread)
|
|
113
|
+
unless event.respond_to?(:thread)
|
|
114
|
+
event.define_singleton_method(:thread) { @thread }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|