chat_sdk-discord 0.2.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f3c068b8a4ec22b48a3e677299e12b777dd836f60de1775a91c50b914c5fc574
4
+ data.tar.gz: 8201aba74defacec745d0096e46eede4dd9c5fd0efeb4fe595c2a2cd695b9913
5
+ SHA512:
6
+ metadata.gz: a50e09acc0f29e76140e1019db6044b46864064360aaecab313fd9a3781df41ddfce5f8a21787327a4927329c32ba727d891f62758a96dcde62d8c30c9946d02
7
+ data.tar.gz: af622c3cf1dbfd417fa85ea7f0313721e7580d43e365914246c6ae15f4abad0ae5aae77615a114fca29269545a68e218f71c263d2f2964247c045559b21fb53d
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module ChatSDK
6
+ module Discord
7
+ class Adapter < ChatSDK::Adapter::Base
8
+ capabilities :edit_messages, :delete_messages, :reactions, :file_uploads,
9
+ :threads, :direct_messages, :message_history, :streaming_edit
10
+
11
+ attr_reader :client
12
+
13
+ def initialize(bot_token: nil, public_key: nil, application_id: nil)
14
+ @bot_token = bot_token || ENV["DISCORD_BOT_TOKEN"]
15
+ @public_key = public_key || ENV["DISCORD_PUBLIC_KEY"]
16
+ @application_id = application_id || ENV["DISCORD_APPLICATION_ID"]
17
+
18
+ raise ChatSDK::ConfigurationError, "Discord bot_token required" unless @bot_token
19
+
20
+ @client = ApiClient.new(bot_token: @bot_token)
21
+ @renderer = EmbedRenderer.new
22
+ end
23
+
24
+ def name
25
+ :discord
26
+ end
27
+
28
+ # Inbound
29
+ def verify_request!(rack_request)
30
+ body = rack_request.body.read
31
+ rack_request.body.rewind
32
+
33
+ unless @public_key
34
+ raise ChatSDK::ConfigurationError, "Discord public_key required for signature verification"
35
+ end
36
+
37
+ signature = rack_request.get_header("HTTP_X_SIGNATURE_ED25519")
38
+ timestamp = rack_request.get_header("HTTP_X_SIGNATURE_TIMESTAMP")
39
+
40
+ unless signature && timestamp
41
+ raise ChatSDK::SignatureVerificationError, "Missing Discord signature headers"
42
+ end
43
+
44
+ Signature.verify!(@public_key, signature, timestamp, body)
45
+ end
46
+
47
+ def ack_response(rack_request)
48
+ body = rack_request.body.read
49
+ rack_request.body.rewind
50
+
51
+ payload = begin
52
+ JSON.parse(body)
53
+ rescue JSON::ParserError
54
+ return nil
55
+ end
56
+
57
+ return nil unless payload["type"] == 1
58
+
59
+ [200, {"content-type" => "application/json"}, ['{"type":1}']]
60
+ end
61
+
62
+ def parse_events(rack_request)
63
+ body = rack_request.body.read
64
+ rack_request.body.rewind
65
+
66
+ payload = JSON.parse(body)
67
+ EventParser.parse(payload)
68
+ rescue JSON::ParserError
69
+ []
70
+ end
71
+
72
+ # Outbound
73
+ def post_message(channel_id:, message:, thread_id: nil)
74
+ msg = ChatSDK::PostableMessage.from(message)
75
+
76
+ content = msg.text || msg.card&.fallback_text || ""
77
+ embeds = nil
78
+ components = nil
79
+
80
+ if msg.card?
81
+ rendered = @renderer.render(msg.card)
82
+ embeds = rendered["embeds"]
83
+ components = rendered["components"]
84
+ end
85
+
86
+ result = @client.create_message(
87
+ channel_id,
88
+ content: content,
89
+ embeds: embeds,
90
+ components: components
91
+ )
92
+
93
+ ChatSDK::Message.new(
94
+ id: result["id"],
95
+ text: content,
96
+ author: ChatSDK::Author.new(id: @application_id || "bot", name: "bot", platform: :discord, bot: true),
97
+ thread_id: thread_id || result["id"],
98
+ channel_id: channel_id,
99
+ platform: :discord,
100
+ raw: result
101
+ )
102
+ end
103
+
104
+ def edit_message(channel_id:, message_id:, message:)
105
+ require_capability!(:edit_messages)
106
+ msg = ChatSDK::PostableMessage.from(message)
107
+
108
+ content = msg.text || msg.card&.fallback_text || ""
109
+ embeds = nil
110
+ components = nil
111
+
112
+ if msg.card?
113
+ rendered = @renderer.render(msg.card)
114
+ embeds = rendered["embeds"]
115
+ components = rendered["components"]
116
+ end
117
+
118
+ @client.edit_message(
119
+ channel_id,
120
+ message_id,
121
+ content: content,
122
+ embeds: embeds,
123
+ components: components
124
+ )
125
+ end
126
+
127
+ def delete_message(channel_id:, message_id:)
128
+ require_capability!(:delete_messages)
129
+ @client.delete_message(channel_id, message_id)
130
+ end
131
+
132
+ def post_ephemeral(channel_id:, user_id:, message:, thread_id: nil)
133
+ super # raises NotSupportedError
134
+ end
135
+
136
+ def upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil)
137
+ require_capability!(:file_uploads)
138
+ @client.upload_file(channel_id, io, filename)
139
+ end
140
+
141
+ def add_reaction(channel_id:, message_id:, emoji:)
142
+ require_capability!(:reactions)
143
+ @client.add_reaction(channel_id, message_id, emoji)
144
+ end
145
+
146
+ def remove_reaction(channel_id:, message_id:, emoji:)
147
+ require_capability!(:reactions)
148
+ @client.remove_reaction(channel_id, message_id, emoji)
149
+ end
150
+
151
+ def open_dm(user_id)
152
+ require_capability!(:direct_messages)
153
+ result = @client.create_dm(user_id)
154
+ result["id"]
155
+ end
156
+
157
+ def fetch_messages(channel_id:, thread_id: nil, cursor: nil, limit: 50)
158
+ require_capability!(:message_history)
159
+
160
+ messages_data = @client.get_messages(channel_id, limit: limit, before: cursor)
161
+ messages_data = [] unless messages_data.is_a?(Array)
162
+
163
+ messages = messages_data.map do |msg|
164
+ ChatSDK::Message.new(
165
+ id: msg["id"],
166
+ text: msg["content"] || "",
167
+ author: ChatSDK::Author.new(
168
+ id: msg.dig("author", "id") || "unknown",
169
+ name: msg.dig("author", "username") || "unknown",
170
+ platform: :discord
171
+ ),
172
+ thread_id: msg["id"],
173
+ channel_id: channel_id,
174
+ platform: :discord,
175
+ raw: msg
176
+ )
177
+ end
178
+
179
+ next_cursor = messages_data.any? ? messages_data.last["id"] : nil
180
+ [messages, next_cursor]
181
+ end
182
+
183
+ def open_modal(trigger_id:, modal:)
184
+ super # raises NotSupportedError
185
+ end
186
+
187
+ def start_typing(channel_id:, thread_id: nil)
188
+ super # raises NotSupportedError
189
+ end
190
+
191
+ def mention(user_id)
192
+ "<@#{user_id}>"
193
+ end
194
+
195
+ def render(postable_message)
196
+ msg = ChatSDK::PostableMessage.from(postable_message)
197
+ if msg.card?
198
+ @renderer.render(msg.card)
199
+ else
200
+ msg.text
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Discord
5
+ class ApiClient
6
+ BASE_URL = "https://discord.com"
7
+ API_PREFIX = "/api/v10"
8
+
9
+ def initialize(bot_token:)
10
+ @bot_token = bot_token
11
+ end
12
+
13
+ # Messages
14
+ def create_message(channel_id, content: nil, embeds: nil, components: nil)
15
+ body = {}
16
+ body["content"] = content if content
17
+ body["embeds"] = embeds if embeds
18
+ body["components"] = components if components
19
+ request(:post, "#{API_PREFIX}/channels/#{channel_id}/messages", body)
20
+ end
21
+
22
+ def edit_message(channel_id, message_id, content: nil, embeds: nil, components: nil)
23
+ body = {}
24
+ body["content"] = content if content
25
+ body["embeds"] = embeds if embeds
26
+ body["components"] = components if components
27
+ request(:patch, "#{API_PREFIX}/channels/#{channel_id}/messages/#{message_id}", body)
28
+ end
29
+
30
+ def delete_message(channel_id, message_id)
31
+ request(:delete, "#{API_PREFIX}/channels/#{channel_id}/messages/#{message_id}")
32
+ end
33
+
34
+ # Reactions
35
+ def add_reaction(channel_id, message_id, emoji)
36
+ encoded = ERB::Util.url_encode(emoji)
37
+ request(:put, "#{API_PREFIX}/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me")
38
+ end
39
+
40
+ def remove_reaction(channel_id, message_id, emoji)
41
+ encoded = ERB::Util.url_encode(emoji)
42
+ request(:delete, "#{API_PREFIX}/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me")
43
+ end
44
+
45
+ # DMs
46
+ def create_dm(user_id)
47
+ request(:post, "#{API_PREFIX}/users/@me/channels", {"recipient_id" => user_id})
48
+ end
49
+
50
+ # Messages history
51
+ def get_messages(channel_id, limit: 50, before: nil)
52
+ path = "#{API_PREFIX}/channels/#{channel_id}/messages?limit=#{limit}"
53
+ path += "&before=#{before}" if before
54
+ request(:get, path)
55
+ end
56
+
57
+ # Typing
58
+ def trigger_typing(channel_id)
59
+ request(:post, "#{API_PREFIX}/channels/#{channel_id}/typing")
60
+ end
61
+
62
+ # File upload
63
+ def upload_file(channel_id, io, filename)
64
+ conn = Faraday.new(url: BASE_URL) do |f|
65
+ f.request :multipart
66
+ f.response :json
67
+ f.adapter :net_http
68
+ end
69
+
70
+ payload = {
71
+ "file[0]" => Faraday::Multipart::FilePart.new(io, "application/octet-stream", filename)
72
+ }
73
+
74
+ response = conn.post("#{API_PREFIX}/channels/#{channel_id}/messages", payload) do |req|
75
+ req.headers["Authorization"] = "Bot #{@bot_token}"
76
+ end
77
+
78
+ handle_response(response)
79
+ end
80
+
81
+ private
82
+
83
+ def connection
84
+ @connection ||= Faraday.new(url: BASE_URL) do |f|
85
+ f.request :json
86
+ f.response :json
87
+ f.adapter :net_http
88
+ end
89
+ end
90
+
91
+ def request(method, path, body = nil)
92
+ response = connection.public_send(method, path) do |req|
93
+ req.headers["Authorization"] = "Bot #{@bot_token}"
94
+ req.body = body if body && method != :get
95
+ end
96
+
97
+ handle_response(response)
98
+ end
99
+
100
+ def handle_response(response)
101
+ return response.body if response.success?
102
+
103
+ if response.status == 429
104
+ retry_after = response.body.is_a?(Hash) ? response.body["retry_after"]&.to_i : nil
105
+ raise ChatSDK::RateLimitedError.new(
106
+ "Discord API rate limited",
107
+ retry_after: retry_after,
108
+ status: response.status,
109
+ body: response.body,
110
+ adapter_name: :discord
111
+ )
112
+ end
113
+
114
+ raise ChatSDK::PlatformError.new(
115
+ "Discord API error: #{response.status}",
116
+ status: response.status,
117
+ body: response.body,
118
+ adapter_name: :discord
119
+ )
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Discord
5
+ class EmbedRenderer
6
+ def render(node)
7
+ case node.type
8
+ when :card then render_card(node)
9
+ else render_single(node)
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def render_card(node)
16
+ embed = {}
17
+ description_parts = []
18
+ fields = []
19
+ components = []
20
+
21
+ embed["title"] = node.attributes[:title] if node.attributes[:title]
22
+
23
+ if node.attributes[:subtitle]
24
+ description_parts << node.attributes[:subtitle]
25
+ end
26
+
27
+ node.children.each do |child|
28
+ case child.type
29
+ when :text
30
+ description_parts << child.attributes[:content]
31
+ when :divider
32
+ description_parts << "───"
33
+ when :image
34
+ embed["image"] = {"url" => child.attributes[:url]}
35
+ when :fields
36
+ child.children.each do |field|
37
+ fields << {
38
+ "name" => field.attributes[:label],
39
+ "value" => field.attributes[:value],
40
+ "inline" => true
41
+ }
42
+ end
43
+ when :section
44
+ render_section_into(child, description_parts, fields, components)
45
+ when :actions
46
+ row = render_action_row(child)
47
+ components << row if row
48
+ when :button, :link_button, :select
49
+ row = {"type" => 1, "components" => [render_component(child)].compact}
50
+ components << row
51
+ end
52
+ end
53
+
54
+ embed["description"] = description_parts.join("\n") unless description_parts.empty?
55
+ embed["fields"] = fields unless fields.empty?
56
+
57
+ result = {"embeds" => [embed]}
58
+ result["components"] = components unless components.empty?
59
+ result
60
+ end
61
+
62
+ def render_section_into(node, description_parts, fields, components)
63
+ description_parts << "**#{node.attributes[:title]}**" if node.attributes[:title]
64
+
65
+ node.children.each do |child|
66
+ case child.type
67
+ when :text
68
+ description_parts << child.attributes[:content]
69
+ when :fields
70
+ child.children.each do |field|
71
+ fields << {
72
+ "name" => field.attributes[:label],
73
+ "value" => field.attributes[:value],
74
+ "inline" => true
75
+ }
76
+ end
77
+ when :actions
78
+ row = render_action_row(child)
79
+ components << row if row
80
+ when :button, :link_button, :select
81
+ row = {"type" => 1, "components" => [render_component(child)].compact}
82
+ components << row
83
+ end
84
+ end
85
+ end
86
+
87
+ def render_action_row(node)
88
+ comps = node.children.filter_map { |child| render_component(child) }
89
+ return nil if comps.empty?
90
+
91
+ {"type" => 1, "components" => comps}
92
+ end
93
+
94
+ def render_component(node)
95
+ case node.type
96
+ when :button then render_button(node)
97
+ when :link_button then render_link_button(node)
98
+ when :select then render_select(node)
99
+ end
100
+ end
101
+
102
+ def render_button(node)
103
+ style = case node.attributes[:style]
104
+ when :primary then 1
105
+ when :danger then 4
106
+ else 2
107
+ end
108
+
109
+ {
110
+ "type" => 2,
111
+ "style" => style,
112
+ "label" => node.attributes[:text],
113
+ "custom_id" => node.attributes[:id] || "button"
114
+ }
115
+ end
116
+
117
+ def render_link_button(node)
118
+ {
119
+ "type" => 2,
120
+ "style" => 5,
121
+ "label" => node.attributes[:text],
122
+ "url" => node.attributes[:url]
123
+ }
124
+ end
125
+
126
+ def render_select(node)
127
+ options = node.children.map do |opt|
128
+ option = {
129
+ "label" => opt.attributes[:text],
130
+ "value" => opt.attributes[:value]
131
+ }
132
+ option["description"] = opt.attributes[:description] if opt.attributes[:description]
133
+ option
134
+ end
135
+
136
+ component = {
137
+ "type" => 3,
138
+ "custom_id" => node.attributes[:id] || "select",
139
+ "options" => options
140
+ }
141
+ component["placeholder"] = node.attributes[:placeholder] if node.attributes[:placeholder]
142
+ component
143
+ end
144
+
145
+ def render_single(node)
146
+ case node.type
147
+ when :text
148
+ {"embeds" => [{"description" => node.attributes[:content]}]}
149
+ else
150
+ {"embeds" => [{"description" => node.fallback_text}]}
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Discord
5
+ class EventParser
6
+ class << self
7
+ def parse(payload)
8
+ return [] unless payload.is_a?(Hash)
9
+
10
+ type = payload["type"]
11
+ data = payload["data"] || {}
12
+
13
+ case type
14
+ when 1 # PING
15
+ []
16
+ when 2 # APPLICATION_COMMAND
17
+ parse_application_command(payload, data)
18
+ when 3 # MESSAGE_COMPONENT
19
+ parse_message_component(payload, data)
20
+ else
21
+ []
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def parse_application_command(payload, data)
28
+ user_info = extract_user(payload)
29
+ channel_id = payload["channel_id"]
30
+ command = "/#{data["name"]}"
31
+ text = extract_options_text(data["options"])
32
+
33
+ [ChatSDK::Events::SlashCommand.new(
34
+ command: command,
35
+ text: text,
36
+ user_id: user_info[:id],
37
+ channel_id: channel_id,
38
+ trigger_id: payload["id"],
39
+ platform: :discord,
40
+ adapter_name: :discord,
41
+ raw: payload
42
+ )]
43
+ end
44
+
45
+ def parse_message_component(payload, data)
46
+ user_info = extract_user(payload)
47
+ channel_id = payload["channel_id"]
48
+ message_id = payload.dig("message", "id")
49
+
50
+ action_id = data["custom_id"]
51
+ value = if data["values"].is_a?(Array) && !data["values"].empty?
52
+ data["values"].first
53
+ else
54
+ data["custom_id"]
55
+ end
56
+
57
+ user = ChatSDK::Author.new(
58
+ id: user_info[:id],
59
+ name: user_info[:name],
60
+ platform: :discord,
61
+ bot: false
62
+ )
63
+
64
+ [ChatSDK::Events::Action.new(
65
+ action_id: action_id,
66
+ value: value,
67
+ user: user,
68
+ thread_id: message_id,
69
+ channel_id: channel_id,
70
+ platform: :discord,
71
+ adapter_name: :discord,
72
+ raw: payload
73
+ )]
74
+ end
75
+
76
+ def extract_user(payload)
77
+ user = payload.dig("member", "user") || payload["user"] || {}
78
+ {
79
+ id: user["id"] || "unknown",
80
+ name: user["username"] || user["id"] || "unknown"
81
+ }
82
+ end
83
+
84
+ def extract_options_text(options)
85
+ return "" unless options.is_a?(Array)
86
+
87
+ options.filter_map { |opt| opt["value"]&.to_s }.join(" ")
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module Discord
5
+ module Signature
6
+ def self.verify!(public_key_hex, signature_hex, timestamp, body)
7
+ verify_key = Ed25519::VerifyKey.new([public_key_hex].pack("H*"))
8
+ signature = [signature_hex].pack("H*")
9
+ message = "#{timestamp}#{body}"
10
+ verify_key.verify(signature, message)
11
+ true
12
+ rescue Ed25519::VerifyError, ArgumentError
13
+ raise ChatSDK::SignatureVerificationError, "Invalid Discord signature"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "chat_sdk"
4
+ require "faraday"
5
+ require "faraday/net_http"
6
+ require "ed25519"
7
+ require "zeitwerk"
8
+
9
+ module ChatSDK
10
+ module Discord
11
+ class << self
12
+ def loader
13
+ @loader ||= begin
14
+ loader = Zeitwerk::Loader.new
15
+ loader.tag = "chat_sdk-discord"
16
+ loader.inflector.inflect("chat_sdk" => "ChatSDK")
17
+ loader.push_dir("#{__dir__}/discord", namespace: ChatSDK::Discord)
18
+ loader.setup
19
+ loader
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ ChatSDK::Discord.loader
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: chat_sdk-discord
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Rootly
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: chat_sdk
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: ed25519
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.3'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.3'
54
+ description: Discord bot adapter for the ChatSDK framework
55
+ email:
56
+ - eng@rootly.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - lib/chat_sdk/discord.rb
62
+ - lib/chat_sdk/discord/adapter.rb
63
+ - lib/chat_sdk/discord/api_client.rb
64
+ - lib/chat_sdk/discord/embed_renderer.rb
65
+ - lib/chat_sdk/discord/event_parser.rb
66
+ - lib/chat_sdk/discord/signature.rb
67
+ homepage: https://github.com/rootlyhq/chat-sdk
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ rubygems_mfa_required: 'true'
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 3.2.0
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 4.0.16
87
+ specification_version: 4
88
+ summary: Discord adapter for ChatSDK
89
+ test_files: []