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
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