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 +7 -0
- data/lib/chat_sdk/discord/adapter.rb +205 -0
- data/lib/chat_sdk/discord/api_client.rb +123 -0
- data/lib/chat_sdk/discord/embed_renderer.rb +155 -0
- data/lib/chat_sdk/discord/event_parser.rb +92 -0
- data/lib/chat_sdk/discord/signature.rb +17 -0
- data/lib/chat_sdk/discord.rb +26 -0
- metadata +89 -0
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: []
|