onyxcord 2.0.13 → 2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '080907ed18fd9ca727ddeb4d6ee21935995b3027c53a864442b9ffb315a9fe1e'
4
- data.tar.gz: 4ad0ff3358ae5bde79a83f8e019a007661a1225d1bc752b06c2c1b992a1eb0a4
3
+ metadata.gz: df2a03d17ca81fd7a31574dbb8f94a53fc18b602a87ccd77d8390c472bb8a89b
4
+ data.tar.gz: 970063416e35a36c1f1f8c97d706a9e6a4895b5d7c1ec44eb19ab1e923b0fdcc
5
5
  SHA512:
6
- metadata.gz: c9098f8a981433ab24e3567bbdbb057d12ac0cf6d80358e2c99dfce84a04e7d68ed3fd59b5b71479580c6997cd17aaac52250b33179754565df0e95493cfc9b4
7
- data.tar.gz: 137922d8f76e11add880ec027167ce04bbfa3d18b8547a8ed5b726184a7448b4629e0875f49455193185ade34c119d87a49bbbff59070d56eb3cc21c80c39a6f
6
+ metadata.gz: 96fd63b818d4e7abcd493f747444692951ec8c2c45a17efdb7772ae5de0aee444f3a22dc410067b8585ad803baa300d52d54856dd3efeddd8938a207d0aa3037
7
+ data.tar.gz: 37d0a1e7c88edc3ced210a92a9bceefd9f980a9280f0802538efed8b4b0de17f92120924a1c2a0121a55d07a33aa9915a2cafca2b915f1630f47e4e7f3049828
data/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0 - 2026-07-01
4
+
5
+ ### Melhorias de REST e gateway
6
+
7
+ - Adiciona `OnyxCord::MessagePayload` e `OnyxCord::Upload` para centralizar payloads, anexos e multipart.
8
+ - Melhora rate limit global async para bloquear novas requests enquanto o limite global esta ativo.
9
+ - Adiciona retry REST para falhas temporarias `500`, `502`, `503`, `504` e erros de conexao.
10
+ - Adiciona erros HTTP tipados com `status`, `code`, `headers`, `route`, `body` e `response`.
11
+ - Torna o reconnect do gateway mais defensivo: quedas antes do `HELLO` voltam para `IDENTIFY`, quedas depois do `HELLO` tentam `RESUME`.
12
+ - Adiciona `AllowedMentions.none` e `AllowedMentions.all`.
13
+ - Ajusta edicao de mensagens para limpar `embeds` ao editar `content`, limpar `content` ao editar `embeds`, e preservar campos com `:keep`.
14
+ - Valida payloads antes da API para limites de embeds/anexos e combinacoes invalidas de Components V2.
15
+
16
+ ## 2.0.20 - 2026-06-30
17
+
18
+ ### Correcoes de REST
19
+
20
+ - Porta o formato multipart do disnake: lista de partes `files[n]` e `payload_json`, com reset do arquivo antes do envio.
21
+
22
+ ## 2.0.19 - 2026-06-30
23
+
24
+ ### Correcoes de REST
25
+
26
+ - Adiciona diagnostico de metodo, URL sanitizada, headers e formato do body em erros HTTP sem JSON.
27
+
28
+ ## 2.0.18 - 2026-06-30
29
+
30
+ ### Correcoes de REST
31
+
32
+ - Normaliza multipart de followup webhook com `payload_json` primeiro e sem campos vazios.
33
+
34
+ ## 2.0.17 - 2026-06-30
35
+
36
+ ### Correcoes de REST
37
+
38
+ - Envia `payload_json` com `Content-Type: application/json` e `.txt` como `text/plain` em multipart.
39
+
40
+ ## 2.0.16 - 2026-06-30
41
+
42
+ ### Correcoes de REST
43
+
44
+ - Forca HTTP/1.1 na sessao REST do HTTPX para evitar `protocol_error` em interacoes e followups.
45
+
46
+ ## 2.0.15 - 2026-06-30
47
+
48
+ ### Correcoes de REST
49
+
50
+ - Envia uploads `multipart/form-data` por `Net::HTTP` no adaptador REST para evitar rejeicao `HTTP 400` do Cloudflare em anexos.
51
+
52
+ ## 2.0.14 - 2026-06-30
53
+
54
+ ### Correcoes de REST
55
+
56
+ - Corrige uploads `multipart/form-data` no adaptador HTTPX usando `form:` nativo, evitando `HTTP 400` HTML do Cloudflare em anexos.
57
+
3
58
  ## 2.0.13 - 2026-06-28
4
59
 
5
60
  ### Correcoes de gateway
@@ -30,6 +30,14 @@ module OnyxCord
30
30
  @replied_user = replied_user
31
31
  end
32
32
 
33
+ def self.none
34
+ new(parse: [], users: [], roles: [], replied_user: false)
35
+ end
36
+
37
+ def self.all
38
+ new(parse: %w[users roles everyone], replied_user: true)
39
+ end
40
+
33
41
  # @!visibility private
34
42
  def to_hash
35
43
  {
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'onyxcord/message_components'
3
+ require 'onyxcord/message_payload'
4
4
 
5
5
  # API calls for Channel
6
6
  module OnyxCord::API::Channel
@@ -9,18 +9,12 @@ module OnyxCord::API::Channel
9
9
  # Build attachment metadata payload for multipart uploads.
10
10
  # Returns an array of { id:, filename: } hashes.
11
11
  def attachment_payload(attachments)
12
- Array(attachments).map.with_index do |attachment, index|
13
- { id: index, filename: File.basename(attachment.path) }
14
- end
12
+ OnyxCord::MessagePayload.attachment_payload(attachments)
15
13
  end
16
14
 
17
15
  # Build multipart body with named file fields and JSON payload.
18
16
  def multipart_body(body, attachments)
19
- files = Array(attachments).map.with_index.to_h do |attachment, index|
20
- ["files[#{index}]", attachment]
21
- end
22
-
23
- { **files, payload_json: body.to_json }
17
+ OnyxCord::MessagePayload.multipart_body(body, attachments)
24
18
  end
25
19
 
26
20
  # Get a channel's data
@@ -113,6 +107,7 @@ module OnyxCord::API::Channel
113
107
  tts = false unless [true, false].include?(tts)
114
108
  components = OnyxCord::MessageComponents.payload(components) unless components.nil?
115
109
  flags = OnyxCord::MessageComponents.apply_v2_flag(flags, components)
110
+ OnyxCord::MessagePayload.validate!(content: message, embeds: embeds, components: components, flags: flags, attachments: attachments, poll: poll)
116
111
  body = { content: message, tts: tts == true, embeds: embeds, nonce: nonce, allowed_mentions: allowed_mentions, message_reference: message_reference, components: components, attachments: attachments ? attachment_payload(attachments) : nil, flags: flags, enforce_nonce: enforce_nonce, poll: poll }.compact
117
112
  body = if attachments
118
113
  multipart_body(body, attachments)
@@ -151,12 +146,14 @@ module OnyxCord::API::Channel
151
146
  def edit_message(token, channel_id, message_id, message, mentions = nil, embeds = nil, components = nil, flags = nil)
152
147
  components = OnyxCord::MessageComponents.payload(components) unless components.nil? || components == :undef
153
148
  flags = OnyxCord::MessageComponents.apply_v2_flag(flags, components)
149
+ body = OnyxCord::MessagePayload.edit_body(message, embeds)
150
+ body.merge!(allowed_mentions: mentions, components: components, flags: flags)
154
151
  OnyxCord::API.request(
155
152
  :channels_cid_messages_mid,
156
153
  channel_id,
157
154
  :patch,
158
155
  "#{OnyxCord::API.api_base}/channels/#{channel_id}/messages/#{message_id}",
159
- { content: message, allowed_mentions: mentions, embeds: embeds, components: components, flags: flags }.reject { |_, v| v == :undef }.to_json,
156
+ body.reject { |_, v| v == :undef }.to_json,
160
157
  Authorization: token,
161
158
  content_type: :json
162
159
  )
@@ -655,6 +652,7 @@ module OnyxCord::API::Channel
655
652
  # Start a thread in a forum or media channel.
656
653
  # https://discord.com/developers/docs/resources/channel#start-thread-in-forum-or-media-channel
657
654
  def start_thread_in_forum_or_media_channel(token, channel_id, name, message, attachments = nil, rate_limit_per_user = nil, auto_archive_duration = nil, applied_tags = nil, reason = nil)
655
+ OnyxCord::MessagePayload.validate!(attachments: attachments)
658
656
  body = { name: name, message: message, rate_limit_per_user: rate_limit_per_user, auto_archive_duration: auto_archive_duration, applied_tags: applied_tags }.compact
659
657
 
660
658
  body = if attachments
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'onyxcord/message_components'
3
+ require 'onyxcord/message_payload'
4
4
 
5
5
  # API calls for interactions.
6
6
  module OnyxCord::API::Interaction
@@ -9,18 +9,12 @@ module OnyxCord::API::Interaction
9
9
  # Build attachment metadata payload for multipart uploads.
10
10
  # Returns an array of { id:, filename: } hashes.
11
11
  def attachment_payload(attachments)
12
- Array(attachments).map.with_index do |attachment, index|
13
- { id: index, filename: File.basename(attachment.path) }
14
- end
12
+ OnyxCord::MessagePayload.attachment_payload(attachments)
15
13
  end
16
14
 
17
15
  # Build multipart body with named file fields and JSON payload.
18
16
  def multipart_body(body, attachments)
19
- files = Array(attachments).map.with_index.to_h do |attachment, index|
20
- ["files[#{index}]", attachment]
21
- end
22
-
23
- { **files, payload_json: body.to_json }
17
+ OnyxCord::MessagePayload.multipart_body(body, attachments)
24
18
  end
25
19
 
26
20
  # Respond to an interaction.
@@ -28,6 +22,7 @@ module OnyxCord::API::Interaction
28
22
  def create_interaction_response(interaction_token, interaction_id, type, content = nil, tts = nil, embeds = nil, allowed_mentions = nil, flags = nil, components = nil, attachments = nil, choices = nil, with_response = nil, poll = nil)
29
23
  components = OnyxCord::MessageComponents.payload(components) unless components.nil?
30
24
  flags = OnyxCord::MessageComponents.apply_v2_flag(flags, components)
25
+ OnyxCord::MessagePayload.validate!(content: content, embeds: embeds, components: components, flags: flags, attachments: attachments, poll: poll)
31
26
  data = { tts: tts, content: content, embeds: embeds, allowed_mentions: allowed_mentions, flags: flags, components: components, attachments: attachments ? attachment_payload(attachments) : nil, choices: choices, poll: poll }.compact
32
27
 
33
28
  body = if attachments
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'onyxcord/message_components'
3
+ require 'onyxcord/message_payload'
4
4
 
5
5
  # API calls for Webhook object
6
6
  module OnyxCord::API::Webhook
@@ -9,18 +9,12 @@ module OnyxCord::API::Webhook
9
9
  # Build attachment metadata payload for multipart uploads.
10
10
  # Returns an array of { id:, filename: } hashes.
11
11
  def attachment_payload(attachments)
12
- Array(attachments).map.with_index do |attachment, index|
13
- { id: index, filename: File.basename(attachment.path) }
14
- end
12
+ OnyxCord::MessagePayload.attachment_payload(attachments)
15
13
  end
16
14
 
17
15
  # Build multipart body with named file fields and JSON payload.
18
16
  def multipart_body(body, attachments)
19
- files = Array(attachments).map.with_index.to_h do |attachment, index|
20
- ["files[#{index}]", attachment]
21
- end
22
-
23
- { **files, payload_json: body.to_json }
17
+ OnyxCord::MessagePayload.multipart_body(body, attachments)
24
18
  end
25
19
 
26
20
  # Get a webhook
@@ -49,9 +43,12 @@ module OnyxCord::API::Webhook
49
43
  # Execute a webhook via token.
50
44
  # https://discord.com/developers/docs/resources/webhook#execute-webhook
51
45
  def token_execute_webhook(webhook_token, webhook_id, wait = false, content = nil, username = nil, avatar_url = nil, tts = nil, file = nil, embeds = nil, allowed_mentions = nil, flags = nil, components = nil, attachments = nil, poll = nil)
46
+ raise ArgumentError, 'cannot mix file and attachments' if file && attachments
47
+
52
48
  components = OnyxCord::MessageComponents.payload(components) unless components.nil?
53
49
  flags = OnyxCord::MessageComponents.apply_v2_flag(flags, components)
54
- body = { content: content, username: username, avatar_url: avatar_url, tts: tts, embeds: embeds&.map(&:to_hash), allowed_mentions: allowed_mentions, flags: flags, components: components, attachments: attachments ? attachment_payload(attachments) : nil, poll: poll }.compact
50
+ OnyxCord::MessagePayload.validate!(content: content, embeds: embeds, components: components, flags: flags, attachments: attachments, poll: poll)
51
+ body = { content: content, username: username, avatar_url: avatar_url, tts: tts == true ? true : nil, embeds: embeds&.map(&:to_hash), allowed_mentions: allowed_mentions, flags: flags, components: components&.any? ? components : nil, attachments: attachments ? attachment_payload(attachments) : nil, poll: poll }.compact
55
52
 
56
53
  body = if file
57
54
  { file: file, payload_json: body.to_json }
@@ -145,7 +142,8 @@ module OnyxCord::API::Webhook
145
142
  def token_edit_message(webhook_token, webhook_id, message_id, content = nil, embeds = nil, allowed_mentions = nil, components = nil, attachments = nil, flags = nil, poll = nil)
146
143
  components = OnyxCord::MessageComponents.payload(components) unless components.nil?
147
144
  flags = OnyxCord::MessageComponents.apply_v2_flag(flags, components)
148
- body = { content: content, embeds: embeds, allowed_mentions: allowed_mentions, components: components, attachments: attachments ? attachment_payload(attachments) : nil, flags: flags, poll: poll }.compact
145
+ OnyxCord::MessagePayload.validate!(content: content, embeds: embeds, components: components, flags: flags, attachments: attachments, poll: poll)
146
+ body = OnyxCord::MessagePayload.edit_body(content, embeds).merge({ allowed_mentions: allowed_mentions, components: components, attachments: attachments ? attachment_payload(attachments) : nil, flags: flags, poll: poll }.compact)
149
147
 
150
148
  body = if attachments
151
149
  multipart_body(body, attachments)
data/lib/onyxcord/api.rb CHANGED
@@ -105,9 +105,17 @@ module OnyxCord::API
105
105
  response = OnyxCord::HTTP.request(type, url, body, **headers)
106
106
 
107
107
  if response.code == 403
108
- noprm = OnyxCord::Errors::NoPermission.new
108
+ route = request_diagnostic(type, url, body, headers)
109
+ noprm = OnyxCord::Errors::NoPermission.new(
110
+ "The bot doesn't have the required permission to do this!",
111
+ status: response.code,
112
+ headers: response.headers,
113
+ route: route,
114
+ body: response.body,
115
+ response: response
116
+ )
109
117
  noprm.define_singleton_method(:_response) { response }
110
- raise noprm, "The bot doesn't have the required permission to do this!"
118
+ raise noprm
111
119
  end
112
120
 
113
121
  # Retry on 502 Bad Gateway
@@ -157,20 +165,38 @@ module OnyxCord::API
157
165
 
158
166
  response = nil
159
167
  loop do
160
- response = OnyxCord::HTTP.request(type, url, body, **headers)
161
- break unless response.code == 502
168
+ begin
169
+ response = OnyxCord::HTTP.request(type, url, body, **headers)
170
+ rescue StandardError => e
171
+ retries += 1
172
+ raise unless retries < max_retries
173
+
174
+ OnyxCord::LOGGER.warn("Temporary HTTP failure while sending request (#{e.class}), retrying")
175
+ OnyxCord::AsyncRuntime.sleep(retries * 0.5)
176
+ next
177
+ end
178
+
179
+ break unless [500, 502, 503, 504].include?(response.code)
162
180
 
163
181
  retries += 1
164
182
  break unless retries < max_retries
165
183
 
166
- OnyxCord::LOGGER.warn('Got a 502 while sending a request! Not a big deal, retrying the request')
184
+ OnyxCord::LOGGER.warn("Got HTTP #{response.code} while sending a request, retrying")
167
185
  OnyxCord::AsyncRuntime.sleep(retries * 0.5)
168
186
  end
169
187
 
170
188
  if response.code == 403
171
- noprm = OnyxCord::Errors::NoPermission.new
189
+ route = request_diagnostic(type, url, body, headers)
190
+ noprm = OnyxCord::Errors::NoPermission.new(
191
+ "The bot doesn't have the required permission to do this!",
192
+ status: response.code,
193
+ headers: response.headers,
194
+ route: route,
195
+ body: response.body,
196
+ response: response
197
+ )
172
198
  noprm.define_singleton_method(:_response) { response }
173
- raise noprm, "The bot doesn't have the required permission to do this!"
199
+ raise noprm
174
200
  end
175
201
 
176
202
  if response.code >= 400 && response.code != 429
@@ -180,10 +206,20 @@ module OnyxCord::API
180
206
  nil
181
207
  end
182
208
 
183
- raise "HTTP #{response.code}: #{response.body}" unless data
209
+ route = request_diagnostic(type, url, body, headers)
210
+ unless data
211
+ raise OnyxCord::Errors::HTTPError.new(
212
+ "HTTP #{response.code} #{route}: #{response.body}",
213
+ status: response.code,
214
+ headers: response.headers,
215
+ route: route,
216
+ body: response.body,
217
+ response: response
218
+ )
219
+ end
184
220
 
185
221
  err_klass = OnyxCord::Errors.error_class_for(data['code'] || 0)
186
- e = err_klass.new(data['message'], data['errors'])
222
+ e = err_klass.new(data['message'], data['errors'], status: response.code, headers: response.headers, route: route, body: response.body, response: response)
187
223
  OnyxCord::LOGGER.error(e.full_message)
188
224
  raise e
189
225
  end
@@ -243,6 +279,25 @@ module OnyxCord::API
243
279
  end
244
280
  end
245
281
 
282
+ def request_diagnostic(type, url, body, headers)
283
+ clean_url = url.to_s.sub(%r{/webhooks/(\d+)/[^?]+}, '/webhooks/\1/[token]')
284
+ .sub(%r{/interactions/(\d+)/[^/]+}, '/interactions/\1/[token]')
285
+ header_keys = headers.keys.map(&:to_s).sort.join(',')
286
+ body_info = if body.is_a?(Hash)
287
+ body.map do |key, value|
288
+ path = value.path if value.respond_to?(:path)
289
+ detail = path ? "file:#{File.basename(path)}" : value.to_s.bytesize
290
+ "#{key}=#{detail}"
291
+ end.join(',')
292
+ else
293
+ body.nil? ? 'nil' : "#{body.class}:#{body.to_s.bytesize}"
294
+ end
295
+
296
+ "(#{type.to_s.upcase} #{clean_url} headers=#{header_keys} body=#{body_info})"
297
+ rescue StandardError => e
298
+ "(diagnostic_failed=#{e.class}: #{e.message})"
299
+ end
300
+
246
301
  # Make an icon URL from server and icon IDs
247
302
  def icon_url(server_id, icon_id, format = 'webp')
248
303
  "#{cdn_url}/icons/#{server_id}/#{icon_id}.#{format}"
@@ -10,7 +10,7 @@ module OnyxCord
10
10
  current = Async::Task.current?
11
11
  return yield current if current
12
12
 
13
- Async(&block).wait
13
+ Sync(&block)
14
14
  end
15
15
 
16
16
  def async(&block)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'onyxcord/webhooks'
4
- require 'onyxcord/message_components'
4
+ require 'onyxcord/message_payload'
5
5
 
6
6
  module OnyxCord
7
7
  # Base class for interaction objects.
@@ -260,7 +260,7 @@ module OnyxCord
260
260
  components ||= view
261
261
  flags = OnyxCord::MessageComponents.apply_v2_flag(flags, components, force: has_components || components_v2)
262
262
  data = builder.to_json_hash
263
- resp = OnyxCord::API::Interaction.edit_original_interaction_response(@token, @application_id, data[:content], data[:embeds], data[:allowed_mentions], components.to_a, attachments, flags, data[:poll])
263
+ resp = OnyxCord::API::Interaction.edit_original_interaction_response(@token, @application_id, edit_content(content, data), edit_embeds(embeds, data), data[:allowed_mentions], components.to_a, attachments, flags, data[:poll])
264
264
 
265
265
  Interactions::Message.new(JSON.parse(resp), @bot, self)
266
266
  end
@@ -324,7 +324,7 @@ module OnyxCord
324
324
  data = builder.to_json_hash
325
325
 
326
326
  resp = OnyxCord::API::Webhook.token_edit_message(
327
- @token, @application_id, message.resolve_id, data[:content], data[:embeds], data[:allowed_mentions], components.to_a, attachments, flags, data[:poll]
327
+ @token, @application_id, message.resolve_id, edit_content(content, data), edit_embeds(embeds, data), data[:allowed_mentions], components.to_a, attachments, flags, data[:poll]
328
328
  )
329
329
  Interactions::Message.new(JSON.parse(resp), @bot, self)
330
330
  end
@@ -392,9 +392,17 @@ module OnyxCord
392
392
  # @param poll [Poll, Poll::Builder, Hash, nil]
393
393
  def prepare_builder(builder, content, embeds, allowed_mentions, poll)
394
394
  builder.poll = poll
395
- builder.content = content
395
+ builder.content = content unless content == OnyxCord::MessagePayload::KEEP
396
396
  builder.allowed_mentions = allowed_mentions
397
- embeds&.each { |embed| builder << embed }
397
+ embeds&.each { |embed| builder << embed } unless embeds == OnyxCord::MessagePayload::KEEP
398
+ end
399
+
400
+ def edit_content(content, data)
401
+ content == OnyxCord::MessagePayload::KEEP ? OnyxCord::MessagePayload::KEEP : data[:content]
402
+ end
403
+
404
+ def edit_embeds(embeds, data)
405
+ embeds == OnyxCord::MessagePayload::KEEP ? OnyxCord::MessagePayload::KEEP : data[:embeds]
398
406
  end
399
407
 
400
408
  # @!visibility private
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'onyxcord/message_components'
3
+ require 'onyxcord/message_payload'
4
4
 
5
5
  module OnyxCord
6
6
  # A message on Discord that was sent to a text channel
@@ -304,7 +304,11 @@ module OnyxCord
304
304
  # @param flags [Integer] Flags for this message. Currently only SUPPRESS_EMBEDS (1 << 2) can be edited.
305
305
  # @return [Message] the resulting message.
306
306
  def edit(new_content, new_embeds = nil, new_components = nil, flags = 0)
307
- new_embeds = (new_embeds.instance_of?(Array) ? new_embeds.map(&:to_hash) : [new_embeds&.to_hash]).compact
307
+ new_embeds = if new_embeds == OnyxCord::MessagePayload::KEEP
308
+ new_embeds
309
+ else
310
+ (new_embeds.instance_of?(Array) ? new_embeds.map { |embed| embed.respond_to?(:to_hash) ? embed.to_hash : embed } : [new_embeds&.to_hash]).compact
311
+ end
308
312
  new_components = new_components.to_a
309
313
  flags = OnyxCord::MessageComponents.apply_v2_flag(flags, new_components)
310
314
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'onyxcord/webhooks/builder'
4
4
  require 'onyxcord/webhooks/view'
5
- require 'onyxcord/message_components'
5
+ require 'onyxcord/message_payload'
6
6
 
7
7
  module OnyxCord
8
8
  # A webhook on a server channel
@@ -178,7 +178,7 @@ module OnyxCord
178
178
  def edit_message(message, content: nil, embeds: nil, allowed_mentions: nil, builder: nil, components: nil, flags: 0, has_components: false, components_v2: false)
179
179
  raise OnyxCord::Errors::UnauthorizedWebhook unless @token
180
180
 
181
- params = { content: content, embeds: embeds, allowed_mentions: allowed_mentions }.compact
181
+ params = { allowed_mentions: allowed_mentions }.compact
182
182
 
183
183
  builder ||= Webhooks::Builder.new
184
184
  view ||= Webhooks::View.new
@@ -189,7 +189,9 @@ module OnyxCord
189
189
  components ||= view
190
190
  flags = OnyxCord::MessageComponents.apply_v2_flag(flags, components, force: has_components || components_v2)
191
191
 
192
- resp = API::Webhook.token_edit_message(@token, @id, message.resolve_id, data[:content], data[:embeds], data[:allowed_mentions], components.to_a, nil, flags)
192
+ edit_content = content == OnyxCord::MessagePayload::KEEP || !content.nil? ? content : data[:content]
193
+ edit_embeds = embeds == OnyxCord::MessagePayload::KEEP || !embeds.nil? ? embeds : data[:embeds]
194
+ resp = API::Webhook.token_edit_message(@token, @id, message.resolve_id, edit_content, edit_embeds, data[:allowed_mentions], components.to_a, nil, flags)
193
195
  Message.new(JSON.parse(resp), @bot)
194
196
  end
195
197
 
@@ -14,8 +14,22 @@ module OnyxCord
14
14
  # Raised when a message is over the character limit
15
15
  class MessageTooLong < RuntimeError; end
16
16
 
17
+ class HTTPError < RuntimeError
18
+ attr_reader :status, :code, :headers, :route, :body, :response
19
+
20
+ def initialize(message = nil, status: nil, code: 0, headers: {}, route: nil, body: nil, response: nil)
21
+ super(message)
22
+ @status = status
23
+ @code = code || 0
24
+ @headers = headers
25
+ @route = route
26
+ @body = body
27
+ @response = response
28
+ end
29
+ end
30
+
17
31
  # Raised when the bot can't do something because its permissions on the server are insufficient
18
- class NoPermission < RuntimeError; end
32
+ class NoPermission < HTTPError; end
19
33
 
20
34
  # Raised when the bot gets a HTTP 502 error, which is usually caused by Cloudflare.
21
35
  class CloudflareError < RuntimeError; end
@@ -24,7 +38,7 @@ module OnyxCord
24
38
  class UnauthorizedWebhook < RuntimeError; end
25
39
 
26
40
  # Generic class for errors denoted by API error codes
27
- class CodeError < RuntimeError
41
+ class CodeError < HTTPError
28
42
  class << self
29
43
  # @return [Integer] The error code represented by this error class.
30
44
  attr_reader :code
@@ -33,10 +47,11 @@ module OnyxCord
33
47
  # Create a new error with a particular message (the code should be defined by the class instance variable)
34
48
  # @param message [String] the message to use
35
49
  # @param errors [Hash] API errors
36
- def initialize(message, errors = nil)
50
+ def initialize(message, errors = nil, status: nil, headers: {}, route: nil, body: nil, response: nil)
37
51
  @message = message
38
52
 
39
53
  @errors = errors ? flatten_errors(errors) : []
54
+ super(full_message, status: status, code: code, headers: headers, route: route, body: body, response: response)
40
55
  end
41
56
 
42
57
  # @return [Integer] The error code represented by this error.
@@ -202,6 +202,7 @@ module OnyxCord
202
202
 
203
203
  def reconnect(attempt_resume = true)
204
204
  @session.suspend if @session && attempt_resume
205
+ @session.invalidate if attempt_resume && @session && !@received_hello
205
206
  @instant_reconnect = true
206
207
  @should_reconnect = true
207
208
  close(4000)
@@ -301,6 +302,7 @@ module OnyxCord
301
302
 
302
303
  @zlib_reader = Zlib::Inflate.new
303
304
  @pipe_broken = false
305
+ @received_hello = false
304
306
  @closed = false
305
307
 
306
308
  endpoint = websocket_endpoint(url)
@@ -316,9 +318,11 @@ module OnyxCord
316
318
  end
317
319
  end
318
320
  rescue StandardError => e
321
+ @session&.invalidate unless @received_hello
319
322
  LOGGER.error('Error connecting to gateway!')
320
323
  LOGGER.log_exception(e)
321
324
  ensure
325
+ @session&.invalidate unless @received_hello
322
326
  @closed = true
323
327
  @connection = nil
324
328
  end
@@ -424,6 +428,7 @@ module OnyxCord
424
428
 
425
429
  # Op 10
426
430
  def handle_hello(packet)
431
+ @received_hello = true
427
432
  LOGGER.debug('Hello!')
428
433
  interval = packet['d']['heartbeat_interval'].to_f / 1000.0
429
434
  setup_heartbeats(interval)
data/lib/onyxcord/http.rb CHANGED
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'httpx'
4
+ require 'net/http'
4
5
  require 'onyxcord/json'
6
+ require 'onyxcord/upload'
7
+ require 'securerandom'
8
+ require 'uri'
5
9
 
6
10
  module OnyxCord
7
11
  # Modern HTTP adapter wrapping HTTPX with persistent connections, automatic
@@ -22,8 +26,9 @@ module OnyxCord
22
26
  def initialize(httpx_response)
23
27
  @raw = httpx_response
24
28
  @body = httpx_response.body.to_s
25
- @code = httpx_response.status
26
- @headers = normalize_headers(httpx_response.headers)
29
+ @code = httpx_response.respond_to?(:status) ? httpx_response.status : httpx_response.code.to_i
30
+ response_headers = httpx_response.respond_to?(:headers) ? httpx_response.headers : httpx_response.to_hash
31
+ @headers = normalize_headers(response_headers)
27
32
  end
28
33
 
29
34
  def to_s
@@ -39,6 +44,8 @@ module OnyxCord
39
44
  def normalize_headers(headers)
40
45
  headers.to_h.transform_keys do |key|
41
46
  key.to_s.tr('-', '_').downcase.to_sym
47
+ end.transform_values do |value|
48
+ value.is_a?(Array) && value.size == 1 ? value.first : value
42
49
  end
43
50
  end
44
51
  end
@@ -49,6 +56,10 @@ module OnyxCord
49
56
  def session
50
57
  Thread.current[:onyxcord_http_session] ||= HTTPX.plugin(:persistent)
51
58
  .plugin(:follow_redirects)
59
+ .with(
60
+ fallback_protocol: 'http/1.1',
61
+ ssl: { alpn_protocols: ['http/1.1'] }
62
+ )
52
63
  end
53
64
 
54
65
  # Reset the HTTP session (useful for tests).
@@ -69,9 +80,9 @@ module OnyxCord
69
80
  when :get
70
81
  http.get(url)
71
82
  when :post
72
- if body.is_a?(Hash) && body.any? { |_, v| v.respond_to?(:read) || v.respond_to?(:path) }
83
+ if multipart?(body)
73
84
  # Multipart upload
74
- http.plugin(:multipart).post(url, form: body)
85
+ post_multipart(url, body, headers)
75
86
  else
76
87
  http.post(url, body: body)
77
88
  end
@@ -91,6 +102,66 @@ module OnyxCord
91
102
  Response.new(raw)
92
103
  end
93
104
 
105
+ def post_multipart(url, body, headers)
106
+ uri = URI(url)
107
+ boundary = "----OnyxCord#{SecureRandom.hex(12)}"
108
+ request = Net::HTTP::Post.new(uri)
109
+ headers.each { |key, value| request[key.to_s] = value }
110
+ request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
111
+ request.body = multipart_body(body, boundary)
112
+
113
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
114
+ http.request(request)
115
+ end
116
+ end
117
+
118
+ def multipart_body(body, boundary)
119
+ output = String.new.b
120
+
121
+ multipart_parts(body).each do |part|
122
+ key = part[:name]
123
+ value = part[:value]
124
+ output << "--#{boundary}\r\n"
125
+ if part[:filename]
126
+ value.rewind if value.respond_to?(:rewind)
127
+ filename = part[:filename]
128
+ output << "Content-Disposition: form-data; name=\"#{key}\"; filename=\"#{filename}\"\r\n"
129
+ output << "Content-Type: #{part[:content_type] || multipart_content_type(filename)}\r\n\r\n"
130
+ output << value.read.to_s
131
+ else
132
+ output << "Content-Disposition: form-data; name=\"#{key}\"\r\n"
133
+ output << "\r\n"
134
+ output << value.to_s
135
+ end
136
+ output << "\r\n"
137
+ end
138
+
139
+ output << "--#{boundary}--\r\n"
140
+ end
141
+
142
+ def multipart_content_type(filename)
143
+ File.extname(filename).casecmp('.txt').zero? ? 'text/plain' : 'application/octet-stream'
144
+ end
145
+
146
+ def multipart?(body)
147
+ return true if body.is_a?(Array) && body.all? { |part| part.is_a?(Hash) && part[:name] && part.key?(:value) }
148
+
149
+ body.is_a?(Hash) && body.any? { |_, value| value.respond_to?(:read) || value.respond_to?(:path) }
150
+ end
151
+
152
+ def multipart_parts(body)
153
+ return body if body.is_a?(Array)
154
+
155
+ body.map do |key, value|
156
+ if value.respond_to?(:read) || value.respond_to?(:path)
157
+ upload = OnyxCord::Upload.wrap(value)
158
+ { name: key, value: upload, filename: upload.filename, content_type: upload.content_type }
159
+ else
160
+ { name: key, value: value }
161
+ end
162
+ end
163
+ end
164
+
94
165
  # Convenience wrappers
95
166
  def get(url, **headers)
96
167
  request(:get, url, nil, **headers)
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'onyxcord/message_components'
4
+ require 'onyxcord/upload'
5
+
6
+ module OnyxCord
7
+ module MessagePayload
8
+ MAX_ATTACHMENTS = 10
9
+ MAX_EMBEDS = 10
10
+ KEEP = :keep
11
+
12
+ module_function
13
+
14
+ def attachment_payload(attachments)
15
+ uploads(attachments).map.with_index { |upload, index| { id: index, filename: upload.filename } }
16
+ end
17
+
18
+ def multipart_body(body, attachments)
19
+ parts = uploads(attachments).map.with_index do |upload, index|
20
+ {
21
+ name: "files[#{index}]",
22
+ value: upload,
23
+ filename: upload.filename,
24
+ content_type: upload.content_type || 'application/octet-stream'
25
+ }
26
+ end
27
+
28
+ parts << { name: 'payload_json', value: body.to_json }
29
+ end
30
+
31
+ def validate!(content: nil, embeds: nil, components: nil, flags: nil, attachments: nil, poll: nil)
32
+ validate_limit!('attachments', attachments, MAX_ATTACHMENTS)
33
+ validate_limit!('embeds', embeds, MAX_EMBEDS)
34
+ validate_components_v2!(content, embeds, components, flags, poll)
35
+ end
36
+
37
+ def edit_body(content, embeds)
38
+ content_keep = content == KEEP
39
+ embeds_keep = embeds == KEEP
40
+ content_given = !content_keep && !content.nil?
41
+ embeds_given = !embeds_keep && !embeds.nil?
42
+ body = {}
43
+ body[:content] = content if !content_keep && (content_given || embeds_given)
44
+ body[:embeds] = embeds_given ? embeds : [] if !embeds_keep && (embeds_given || content_given)
45
+ body
46
+ end
47
+
48
+ def uploads(attachments)
49
+ Array(attachments).map { |attachment| OnyxCord::Upload.wrap(attachment) }
50
+ end
51
+
52
+ def validate_limit!(name, values, max)
53
+ return if values.nil? || Array(values).length <= max
54
+
55
+ raise ArgumentError, "#{name} cannot exceed #{max} elements"
56
+ end
57
+
58
+ def validate_components_v2!(content, embeds, components, flags, poll)
59
+ return unless components_v2?(components, flags)
60
+ return unless present?(content) || present?(embeds) || present?(poll)
61
+
62
+ raise ArgumentError, 'Components V2 messages cannot include content, embeds, or poll'
63
+ end
64
+
65
+ def components_v2?(components, flags)
66
+ OnyxCord::MessageComponents.components_v2?(components) ||
67
+ (OnyxCord::MessageComponents.flag_value(flags) & OnyxCord::MessageComponents::IS_COMPONENTS_V2).positive?
68
+ end
69
+
70
+ def present?(value)
71
+ return false if value.nil?
72
+ return false if value == KEEP
73
+ return !value.empty? if value.respond_to?(:empty?)
74
+
75
+ true
76
+ end
77
+ end
78
+ end
@@ -21,6 +21,7 @@ module OnyxCord
21
21
  end
22
22
 
23
23
  def before_request(route, major_parameter)
24
+ wait_for(@global_lock)
24
25
  wait_for(mutex_for(route, major_parameter))
25
26
  end
26
27
 
@@ -142,8 +143,8 @@ module OnyxCord
142
143
  end
143
144
 
144
145
  def global_wait(time)
145
- OnyxCord::AsyncRuntime.sleep(time)
146
+ async_wait(time, @global_lock)
146
147
  end
147
148
  end
148
149
  end
149
- end
150
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OnyxCord
4
+ # Small wrapper for file uploads while keeping raw File/Tempfile support.
5
+ class Upload
6
+ attr_reader :io, :filename, :content_type
7
+
8
+ def self.wrap(file)
9
+ file.is_a?(self) ? file : new(file)
10
+ end
11
+
12
+ def initialize(io, filename: nil, content_type: nil)
13
+ @io = io
14
+ @filename = filename || upload_filename(io)
15
+ @content_type = content_type
16
+ end
17
+
18
+ def read(...)
19
+ @io.read(...)
20
+ end
21
+
22
+ def rewind
23
+ @io.rewind if @io.respond_to?(:rewind)
24
+ end
25
+
26
+ def path
27
+ @io.path if @io.respond_to?(:path)
28
+ end
29
+
30
+ private
31
+
32
+ def upload_filename(io)
33
+ path = io.path if io.respond_to?(:path)
34
+ return File.basename(path) if path && !path.empty?
35
+
36
+ 'upload.dat'
37
+ end
38
+ end
39
+ end
@@ -3,5 +3,5 @@
3
3
  # OnyxCord and all its functionality, in this case only the version.
4
4
  module OnyxCord
5
5
  # The current version of onyxcord.
6
- VERSION = '2.0.13'
6
+ VERSION = '2.1.0'
7
7
  end
@@ -4,6 +4,6 @@
4
4
  module OnyxCord
5
5
  module Webhooks
6
6
  # Kept for compatibility with code that checks the webhooks module version.
7
- VERSION = '2.0.13'
7
+ VERSION = '2.1.0'
8
8
  end
9
9
  end
data/lib/onyxcord.rb CHANGED
@@ -8,6 +8,8 @@ require 'onyxcord/version'
8
8
  require 'onyxcord/configuration'
9
9
  require 'onyxcord/event_executor'
10
10
  require 'onyxcord/message_components'
11
+ require 'onyxcord/message_payload'
12
+ require 'onyxcord/upload'
11
13
  require 'onyxcord/webhooks'
12
14
  require 'onyxcord/bot'
13
15
  require 'onyxcord/commands/command_bot'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: onyxcord
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.13
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gustavo Silva
@@ -486,11 +486,13 @@ files:
486
486
  - lib/onyxcord/light/light_bot.rb
487
487
  - lib/onyxcord/logger.rb
488
488
  - lib/onyxcord/message_components.rb
489
+ - lib/onyxcord/message_payload.rb
489
490
  - lib/onyxcord/paginator.rb
490
491
  - lib/onyxcord/permissions.rb
491
492
  - lib/onyxcord/rate_limiter/async_rest.rb
492
493
  - lib/onyxcord/rate_limiter/gateway.rb
493
494
  - lib/onyxcord/rate_limiter/rest.rb
495
+ - lib/onyxcord/upload.rb
494
496
  - lib/onyxcord/version.rb
495
497
  - lib/onyxcord/voice/encoder.rb
496
498
  - lib/onyxcord/voice/network.rb
@@ -530,7 +532,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
530
532
  - !ruby/object:Gem::Version
531
533
  version: '0'
532
534
  requirements: []
533
- rubygems_version: 4.0.15
535
+ rubygems_version: 3.6.9
534
536
  specification_version: 4
535
537
  summary: Discord API for Ruby with Components V2 support
536
538
  test_files: []