onyxcord 2.0.8 → 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: de906b93116b1c325312db44d7373011c24b4ea6807cea759112af2846a9449a
4
- data.tar.gz: 3be86bc11a833fd3cb8b88874837d2cc732c916a4b6f4afbad340f1573531f89
3
+ metadata.gz: df2a03d17ca81fd7a31574dbb8f94a53fc18b602a87ccd77d8390c472bb8a89b
4
+ data.tar.gz: 970063416e35a36c1f1f8c97d706a9e6a4895b5d7c1ec44eb19ab1e923b0fdcc
5
5
  SHA512:
6
- metadata.gz: b7c429540a55956bb417841e610d9e8da946d18c4565c3177a45cb2ec4ba885988e02fade2c910edff015aee77970709198d450ba5719faa1862a182ab3507fb
7
- data.tar.gz: 8159656f82928ec348522857df842cfe27479c6ba147e090778ac26749533f7d0c32c80390b61df6e4b15ef485fa2e6dcb886355650cc2cb1f2598759f694da9
6
+ metadata.gz: 96fd63b818d4e7abcd493f747444692951ec8c2c45a17efdb7772ae5de0aee444f3a22dc410067b8585ad803baa300d52d54856dd3efeddd8938a207d0aa3037
7
+ data.tar.gz: 37d0a1e7c88edc3ced210a92a9bceefd9f980a9280f0802538efed8b4b0de17f92120924a1c2a0121a55d07a33aa9915a2cafca2b915f1630f47e4e7f3049828
data/CHANGELOG.md CHANGED
@@ -1,5 +1,94 @@
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
+
58
+ ## 2.0.13 - 2026-06-28
59
+
60
+ ### Correcoes de gateway
61
+
62
+ - Evita chamadas REST em `Bot#update_voice_state` ao processar `VOICE_STATE_UPDATE`.
63
+ - Torna `GUILD_UPDATE` tolerante a guild ainda nao cacheada.
64
+
65
+ ## 2.0.12 - 2026-06-28
66
+
67
+ ### Correcoes de gateway
68
+
69
+ - Evita chamadas REST durante a criação de eventos do Gateway para `VOICE_STATE_UPDATE` e `CHANNEL_CREATE`.
70
+ - Trata `Async::Cancel` durante dispatch do Gateway como cancelamento esperado, sem registrar erro fatal.
71
+
72
+ ## 2.0.11 - 2026-06-28
73
+
74
+ ### Correcoes de estabilidade
75
+
76
+ - Isola a sessao HTTP persistente por thread para evitar que tarefas em segundo plano disputem a mesma conexao REST das interacoes.
77
+
78
+ ## 2.0.10 - 2026-06-28
79
+
80
+ ### Correcoes de voz
81
+
82
+ - Mantem `channel_id` no cache de `VoiceState` mesmo quando o objeto do canal ainda nao foi resolvido.
83
+ - Expoe `channel_id` e `old_channel_id` em `VoiceStateUpdateEvent`, corrigindo entradas em call que pareciam `nil -> nil`.
84
+ - Permite que raw dispatch handlers filtrem pacotes com chaves string ou symbol.
85
+
86
+ ## 2.0.9 - 2026-06-28
87
+
88
+ ### Correcoes de estabilidade
89
+
90
+ - Tolera um heartbeat sem ACK antes de reconectar, evitando queda por ACK atrasado isolado.
91
+
3
92
  ## 2.0.8 - 2026-06-28
4
93
 
5
94
  ### Correcoes de gateway
@@ -166,4 +255,3 @@ bot.sync_application_commands!(server_id: ENV.fetch('DISCORD_SERVER_ID'))
166
255
  - `bundle exec rspec`: 456 exemplos, 0 falhas, 3 pendentes.
167
256
  - RuboCop nos arquivos alterados: sem offenses.
168
257
  - `gem build onyxcord.gemspec`: sucesso.
169
- - `gem build onyxcord-webhooks.gemspec`: sucesso.
@@ -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)
data/lib/onyxcord/bot.rb CHANGED
@@ -1119,12 +1119,12 @@ module OnyxCord
1119
1119
  @session_id = data['session_id']
1120
1120
 
1121
1121
  server_id = data['guild_id'].to_i
1122
- server = server(server_id)
1122
+ server = @servers&.[](server_id)
1123
1123
  return unless server
1124
1124
 
1125
1125
  user_id = data['user_id'].to_i
1126
1126
  old_voice_state = server.voice_states[user_id]
1127
- old_channel_id = old_voice_state.voice_channel&.id if old_voice_state
1127
+ old_channel_id = old_voice_state&.channel_id || old_voice_state&.voice_channel&.id
1128
1128
 
1129
1129
  server.update_voice_state(data)
1130
1130
 
@@ -1132,7 +1132,8 @@ module OnyxCord
1132
1132
  if user_id == @profile.id && existing_voice
1133
1133
  new_channel_id = data['channel_id']
1134
1134
  if new_channel_id
1135
- new_channel = channel(new_channel_id)
1135
+ channel_id = new_channel_id.to_i
1136
+ new_channel = @channels&.[](channel_id) || server.channels.find { |channel| channel.id == channel_id }
1136
1137
  existing_voice.channel = new_channel
1137
1138
  else
1138
1139
  voice_destroy(server_id)
@@ -1248,7 +1249,15 @@ module OnyxCord
1248
1249
 
1249
1250
  # Internal handler for GUILD_UPDATE
1250
1251
  def update_guild(data)
1251
- @servers[data['id'].to_i].update_data(data)
1252
+ server_id = data['id'].to_i
1253
+ server = @servers&.[](server_id)
1254
+
1255
+ if server
1256
+ server.update_data(data)
1257
+ else
1258
+ LOGGER.warn("GUILD_UPDATE received for uncached server #{server_id}; caching from payload")
1259
+ ensure_server(data, true)
1260
+ end
1252
1261
  end
1253
1262
 
1254
1263
  # Internal handler for GUILD_DELETE
@@ -1868,6 +1877,11 @@ module OnyxCord
1868
1877
  raise_event(event)
1869
1878
  end
1870
1879
  rescue Exception => e
1880
+ if defined?(Async::Cancel) && e.is_a?(Async::Cancel)
1881
+ LOGGER.debug('Gateway message handling was cancelled.')
1882
+ return
1883
+ end
1884
+
1871
1885
  LOGGER.error('Gateway message error!')
1872
1886
  log_exception(e)
1873
1887
  end
@@ -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
 
@@ -697,13 +697,15 @@ module OnyxCord
697
697
  end
698
698
 
699
699
  # Update the existing voice state (or the one we just created)
700
- channel = @channels_by_id[data['channel_id'].to_i]
700
+ channel_id = data['channel_id'].to_i
701
+ channel = @channels_by_id[channel_id]
701
702
  @voice_states[user_id].update(
702
703
  channel,
703
704
  data['mute'],
704
705
  data['deaf'],
705
706
  data['self_mute'],
706
- data['self_deaf']
707
+ data['self_deaf'],
708
+ channel_id
707
709
  )
708
710
  else
709
711
  # The user is not in a voice channel anymore, so delete its voice state
@@ -22,6 +22,9 @@ module OnyxCord
22
22
  # @return [Channel] the voice channel this voice state's member is in.
23
23
  attr_reader :voice_channel
24
24
 
25
+ # @return [Integer, nil] the ID of the voice channel this member is in.
26
+ attr_reader :channel_id
27
+
25
28
  # @!visibility private
26
29
  def initialize(user_id)
27
30
  @user_id = user_id
@@ -30,8 +33,9 @@ module OnyxCord
30
33
  # Update this voice state with new data from Discord
31
34
  # @note For internal use only.
32
35
  # @!visibility private
33
- def update(channel, mute, deaf, self_mute, self_deaf)
36
+ def update(channel, mute, deaf, self_mute, self_deaf, channel_id = nil)
34
37
  @voice_channel = channel
38
+ @channel_id = channel_id&.to_i || channel&.id
35
39
  @mute = mute
36
40
  @deaf = deaf
37
41
  @self_mute = self_mute
@@ -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.
@@ -32,7 +32,23 @@ module OnyxCord::Events
32
32
  # @!visibility private
33
33
  def initialize(data, bot)
34
34
  @bot = bot
35
- @channel = data.is_a?(OnyxCord::Channel) ? data : bot.channel(data['id'].to_i)
35
+ @channel = if data.is_a?(OnyxCord::Channel)
36
+ data
37
+ else
38
+ cached_channel(bot, data['id']) || OnyxCord::Channel.new(data, bot, cached_server(bot, data['guild_id']))
39
+ end
40
+ end
41
+
42
+ def cached_channel(bot, channel_id)
43
+ channels = bot.instance_variable_get(:@channels)
44
+ channels&.[](channel_id.to_i)
45
+ end
46
+
47
+ def cached_server(bot, server_id)
48
+ return nil unless server_id
49
+
50
+ servers = bot.instance_variable_get(:@servers)
51
+ servers&.[](server_id.to_i)
36
52
  end
37
53
  end
38
54
 
@@ -57,7 +57,7 @@ module OnyxCord::Events
57
57
  def matches?(packet)
58
58
  return true unless @filter
59
59
 
60
- type = packet['t']
60
+ type = packet['t'] || packet[:t]
61
61
  return false unless type
62
62
 
63
63
  if @filter.is_a?(Regexp)
@@ -6,11 +6,17 @@ require 'onyxcord/data'
6
6
  module OnyxCord::Events
7
7
  # Event raised when a user's voice state updates
8
8
  class VoiceStateUpdateEvent < Event
9
- attr_reader :user, :token, :suppress, :session_id, :self_mute, :self_deaf, :mute, :deaf, :server, :channel
9
+ attr_reader :user, :user_id, :token, :suppress, :session_id, :self_mute, :self_deaf, :mute, :deaf, :server, :channel
10
10
 
11
11
  # @return [Channel, nil] the old channel this user was on, or nil if the user is newly joining voice.
12
12
  attr_reader :old_channel
13
13
 
14
+ # @return [Integer, nil] the current voice channel ID, or nil if the user left voice.
15
+ attr_reader :channel_id
16
+
17
+ # @return [Integer, nil] the previous voice channel ID, or nil if the user joined voice.
18
+ attr_reader :old_channel_id
19
+
14
20
  # @!visibility private
15
21
  def initialize(data, old_channel_id, bot)
16
22
  @bot = bot
@@ -22,12 +28,35 @@ module OnyxCord::Events
22
28
  @self_deaf = data['self_deaf']
23
29
  @mute = data['mute']
24
30
  @deaf = data['deaf']
25
- @server = bot.server(data['guild_id'].to_i)
31
+ @user_id = data['user_id']&.to_i
32
+ @channel_id = data['channel_id']&.to_i
33
+ @old_channel_id = old_channel_id&.to_i
34
+ @server = cached_server(bot, data['guild_id'])
26
35
  return unless @server
27
36
 
28
- @channel = bot.channel(data['channel_id'].to_i) if data['channel_id']
29
- @old_channel = bot.channel(old_channel_id) if old_channel_id
30
- @user = bot.user(data['user_id'].to_i)
37
+ @channel = cached_channel(bot, @channel_id, @server)
38
+ @old_channel = cached_channel(bot, @old_channel_id, @server)
39
+ @user = cached_user(bot, data)
40
+ end
41
+
42
+ def cached_server(bot, server_id)
43
+ servers = bot.instance_variable_get(:@servers)
44
+ servers&.[](server_id.to_i)
45
+ end
46
+
47
+ def cached_channel(bot, channel_id, server)
48
+ return nil unless channel_id
49
+
50
+ channels = bot.instance_variable_get(:@channels)
51
+ channels&.[](channel_id.to_i) || server.channels.find { |channel| channel.id == channel_id.to_i }
52
+ end
53
+
54
+ def cached_user(bot, data)
55
+ user_data = data.dig('member', 'user') || data['user']
56
+ return bot.ensure_user(user_data) if user_data
57
+
58
+ users = bot.instance_variable_get(:@users)
59
+ users&.[](data['user_id'].to_i)
31
60
  end
32
61
  end
33
62
 
@@ -39,6 +68,8 @@ module OnyxCord::Events
39
68
 
40
69
  [
41
70
  matches_all(@attributes[:from], event.user) do |a, e|
71
+ next unless e
72
+
42
73
  a == case a
43
74
  when String
44
75
  e.name
@@ -84,6 +84,7 @@ module OnyxCord
84
84
  @connection = nil
85
85
  @closed = true
86
86
  @pipe_broken = false
87
+ @missed_heartbeat_acks = 0
87
88
  end
88
89
 
89
90
  # Connect to the gateway server inside an Async reactor.
@@ -142,10 +143,17 @@ module OnyxCord
142
143
  def heartbeat
143
144
  if check_heartbeat_acks
144
145
  unless @last_heartbeat_acked
145
- LOGGER.warn('Last heartbeat was not acked — zombie connection! Reconnecting')
146
- @pipe_broken = true
147
- reconnect
148
- return
146
+ @missed_heartbeat_acks += 1
147
+ if @missed_heartbeat_acks >= 2
148
+ LOGGER.warn('Last heartbeats were not acked — zombie connection! Reconnecting')
149
+ @pipe_broken = true
150
+ reconnect
151
+ return
152
+ end
153
+
154
+ LOGGER.warn('Last heartbeat was not acked — waiting one more interval before reconnecting')
155
+ else
156
+ @missed_heartbeat_acks = 0
149
157
  end
150
158
  @last_heartbeat_acked = false
151
159
  end
@@ -194,6 +202,7 @@ module OnyxCord
194
202
 
195
203
  def reconnect(attempt_resume = true)
196
204
  @session.suspend if @session && attempt_resume
205
+ @session.invalidate if attempt_resume && @session && !@received_hello
197
206
  @instant_reconnect = true
198
207
  @should_reconnect = true
199
208
  close(4000)
@@ -219,6 +228,7 @@ module OnyxCord
219
228
 
220
229
  def setup_heartbeats(interval)
221
230
  @last_heartbeat_acked = true
231
+ @missed_heartbeat_acks = 0
222
232
  return if @heartbeat_task
223
233
 
224
234
  @heartbeat_interval = interval
@@ -292,6 +302,7 @@ module OnyxCord
292
302
 
293
303
  @zlib_reader = Zlib::Inflate.new
294
304
  @pipe_broken = false
305
+ @received_hello = false
295
306
  @closed = false
296
307
 
297
308
  endpoint = websocket_endpoint(url)
@@ -307,9 +318,11 @@ module OnyxCord
307
318
  end
308
319
  end
309
320
  rescue StandardError => e
321
+ @session&.invalidate unless @received_hello
310
322
  LOGGER.error('Error connecting to gateway!')
311
323
  LOGGER.log_exception(e)
312
324
  ensure
325
+ @session&.invalidate unless @received_hello
313
326
  @closed = true
314
327
  @connection = nil
315
328
  end
@@ -415,6 +428,7 @@ module OnyxCord
415
428
 
416
429
  # Op 10
417
430
  def handle_hello(packet)
431
+ @received_hello = true
418
432
  LOGGER.debug('Hello!')
419
433
  interval = packet['d']['heartbeat_interval'].to_f / 1000.0
420
434
  setup_heartbeats(interval)
@@ -432,7 +446,10 @@ module OnyxCord
432
446
  # Op 11
433
447
  def handle_heartbeat_ack(packet)
434
448
  LOGGER.debug("Heartbeat ACK: #{packet.inspect}")
435
- @last_heartbeat_acked = true if @check_heartbeat_acks
449
+ if @check_heartbeat_acks
450
+ @last_heartbeat_acked = true
451
+ @missed_heartbeat_acks = 0
452
+ end
436
453
  end
437
454
 
438
455
  def handle_internal_close(e)
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,21 +44,27 @@ 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
45
52
 
46
53
  module_function
47
54
 
48
- # The shared HTTPX session with persistent connections.
55
+ # The shared HTTPX session with persistent connections for the current thread.
49
56
  def session
50
- @session ||= HTTPX.plugin(:persistent)
51
- .plugin(:follow_redirects)
57
+ Thread.current[:onyxcord_http_session] ||= HTTPX.plugin(:persistent)
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).
55
66
  def reset!
56
- @session = nil
67
+ Thread.current[:onyxcord_http_session] = nil
57
68
  end
58
69
 
59
70
  # Perform a raw HTTP request and return a {Response}.
@@ -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.8'
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.8'
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.8
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: []