onyxcord 1.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 +7 -0
- data/.devcontainer/Dockerfile +13 -0
- data/.devcontainer/devcontainer.json +29 -0
- data/.devcontainer/postcreate.sh +4 -0
- data/.github/CONTRIBUTING.md +13 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +24 -0
- data/.github/pull_request_template.md +37 -0
- data/.github/workflows/ci.yml +78 -0
- data/.github/workflows/codeql.yml +65 -0
- data/.github/workflows/deploy.yml +54 -0
- data/.github/workflows/release.yml +51 -0
- data/.gitignore +16 -0
- data/.markdownlint.json +4 -0
- data/.overcommit.yml +7 -0
- data/.rspec +2 -0
- data/.rubocop.yml +129 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +0 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +305 -0
- data/Rakefile +17 -0
- data/bin/console +15 -0
- data/bin/setup +7 -0
- data/lib/onyxcord/allowed_mentions.rb +43 -0
- data/lib/onyxcord/api/application.rb +316 -0
- data/lib/onyxcord/api/channel.rb +700 -0
- data/lib/onyxcord/api/interaction.rb +67 -0
- data/lib/onyxcord/api/invite.rb +44 -0
- data/lib/onyxcord/api/server.rb +775 -0
- data/lib/onyxcord/api/user.rb +158 -0
- data/lib/onyxcord/api/webhook.rb +163 -0
- data/lib/onyxcord/api.rb +335 -0
- data/lib/onyxcord/await.rb +51 -0
- data/lib/onyxcord/bot.rb +1971 -0
- data/lib/onyxcord/cache.rb +326 -0
- data/lib/onyxcord/colour_rgb.rb +43 -0
- data/lib/onyxcord/commands/command_bot.rb +511 -0
- data/lib/onyxcord/commands/container.rb +112 -0
- data/lib/onyxcord/commands/events.rb +11 -0
- data/lib/onyxcord/commands/parser.rb +327 -0
- data/lib/onyxcord/commands/rate_limiter.rb +144 -0
- data/lib/onyxcord/configuration.rb +125 -0
- data/lib/onyxcord/container.rb +988 -0
- data/lib/onyxcord/data/activity.rb +271 -0
- data/lib/onyxcord/data/application.rb +341 -0
- data/lib/onyxcord/data/attachment.rb +91 -0
- data/lib/onyxcord/data/audit_logs.rb +438 -0
- data/lib/onyxcord/data/avatar_decoration.rb +26 -0
- data/lib/onyxcord/data/call.rb +22 -0
- data/lib/onyxcord/data/channel.rb +1355 -0
- data/lib/onyxcord/data/channel_tag.rb +69 -0
- data/lib/onyxcord/data/collectibles.rb +47 -0
- data/lib/onyxcord/data/component.rb +583 -0
- data/lib/onyxcord/data/embed.rb +258 -0
- data/lib/onyxcord/data/emoji.rb +123 -0
- data/lib/onyxcord/data/install_params.rb +24 -0
- data/lib/onyxcord/data/integration.rb +144 -0
- data/lib/onyxcord/data/interaction.rb +1141 -0
- data/lib/onyxcord/data/invite.rb +137 -0
- data/lib/onyxcord/data/member.rb +528 -0
- data/lib/onyxcord/data/message.rb +612 -0
- data/lib/onyxcord/data/message_activity.rb +41 -0
- data/lib/onyxcord/data/overwrite.rb +109 -0
- data/lib/onyxcord/data/poll.rb +365 -0
- data/lib/onyxcord/data/primary_server.rb +60 -0
- data/lib/onyxcord/data/profile.rb +79 -0
- data/lib/onyxcord/data/reaction.rb +64 -0
- data/lib/onyxcord/data/recipient.rb +34 -0
- data/lib/onyxcord/data/role.rb +449 -0
- data/lib/onyxcord/data/role_connection_data.rb +69 -0
- data/lib/onyxcord/data/role_subscription.rb +41 -0
- data/lib/onyxcord/data/scheduled_event.rb +513 -0
- data/lib/onyxcord/data/server.rb +1614 -0
- data/lib/onyxcord/data/server_preview.rb +68 -0
- data/lib/onyxcord/data/snapshot.rb +112 -0
- data/lib/onyxcord/data/team.rb +98 -0
- data/lib/onyxcord/data/timestamp.rb +69 -0
- data/lib/onyxcord/data/user.rb +324 -0
- data/lib/onyxcord/data/voice_region.rb +46 -0
- data/lib/onyxcord/data/voice_state.rb +41 -0
- data/lib/onyxcord/data/webhook.rb +238 -0
- data/lib/onyxcord/data.rb +57 -0
- data/lib/onyxcord/errors.rb +246 -0
- data/lib/onyxcord/event_executor.rb +80 -0
- data/lib/onyxcord/events/await.rb +48 -0
- data/lib/onyxcord/events/bans.rb +60 -0
- data/lib/onyxcord/events/channels.rb +225 -0
- data/lib/onyxcord/events/generic.rb +129 -0
- data/lib/onyxcord/events/guilds.rb +269 -0
- data/lib/onyxcord/events/integrations.rb +100 -0
- data/lib/onyxcord/events/interactions.rb +624 -0
- data/lib/onyxcord/events/invites.rb +127 -0
- data/lib/onyxcord/events/lifetime.rb +31 -0
- data/lib/onyxcord/events/members.rb +110 -0
- data/lib/onyxcord/events/message.rb +399 -0
- data/lib/onyxcord/events/polls.rb +118 -0
- data/lib/onyxcord/events/presence.rb +131 -0
- data/lib/onyxcord/events/raw.rb +74 -0
- data/lib/onyxcord/events/reactions.rb +218 -0
- data/lib/onyxcord/events/roles.rb +87 -0
- data/lib/onyxcord/events/scheduled_events.rb +171 -0
- data/lib/onyxcord/events/threads.rb +100 -0
- data/lib/onyxcord/events/typing.rb +73 -0
- data/lib/onyxcord/events/voice_server_update.rb +48 -0
- data/lib/onyxcord/events/voice_state_update.rb +106 -0
- data/lib/onyxcord/events/webhooks.rb +65 -0
- data/lib/onyxcord/gateway.rb +890 -0
- data/lib/onyxcord/id_object.rb +39 -0
- data/lib/onyxcord/light/data.rb +62 -0
- data/lib/onyxcord/light/integrations.rb +73 -0
- data/lib/onyxcord/light/light_bot.rb +58 -0
- data/lib/onyxcord/light.rb +8 -0
- data/lib/onyxcord/logger.rb +120 -0
- data/lib/onyxcord/message_components.rb +70 -0
- data/lib/onyxcord/paginator.rb +60 -0
- data/lib/onyxcord/permissions.rb +255 -0
- data/lib/onyxcord/rate_limiter/gateway.rb +42 -0
- data/lib/onyxcord/rate_limiter/rest.rb +89 -0
- data/lib/onyxcord/version.rb +7 -0
- data/lib/onyxcord/voice/encoder.rb +115 -0
- data/lib/onyxcord/voice/network.rb +380 -0
- data/lib/onyxcord/voice/opcodes.rb +29 -0
- data/lib/onyxcord/voice/sodium.rb +157 -0
- data/lib/onyxcord/voice/timer.rb +19 -0
- data/lib/onyxcord/voice/voice_bot.rb +386 -0
- data/lib/onyxcord/webhooks.rb +14 -0
- data/lib/onyxcord/websocket.rb +62 -0
- data/lib/onyxcord.rb +180 -0
- data/onyxcord-webhooks.gemspec +30 -0
- data/onyxcord.gemspec +50 -0
- metadata +421 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'onyxcord/voice/encoder'
|
|
4
|
+
require 'onyxcord/voice/network'
|
|
5
|
+
require 'onyxcord/voice/timer'
|
|
6
|
+
require 'onyxcord/logger'
|
|
7
|
+
require 'ffi'
|
|
8
|
+
|
|
9
|
+
# Voice support
|
|
10
|
+
module OnyxCord::Voice
|
|
11
|
+
# How long one voice packet should ideally be (20ms as defined by Discord)
|
|
12
|
+
IDEAL_LENGTH = 20.0
|
|
13
|
+
|
|
14
|
+
# How many bytes of data to read (1920 bytes * 2 channels) from audio PCM data
|
|
15
|
+
DATA_LENGTH = 1920 * 2
|
|
16
|
+
|
|
17
|
+
# This class represents a connection to a Discord voice server and channel. It can be used to play audio files and
|
|
18
|
+
# streams and to control playback on currently playing tracks. The method {Bot#voice_connect} can be used to connect
|
|
19
|
+
# to a voice channel.
|
|
20
|
+
#
|
|
21
|
+
# onyxcord does latency adjustments every now and then to improve playback quality. I made sure to put useful
|
|
22
|
+
# defaults for the adjustment parameters, but if the sound is patchy or too fast (or the speed varies a lot) you
|
|
23
|
+
# should check the parameters and adjust them to your connection: {VoiceBot#adjust_interval},
|
|
24
|
+
# {VoiceBot#adjust_offset}, and {VoiceBot#adjust_average}.
|
|
25
|
+
class VoiceBot
|
|
26
|
+
# @return [Channel] the current voice channel
|
|
27
|
+
attr_reader :channel
|
|
28
|
+
|
|
29
|
+
# @!visibility private
|
|
30
|
+
attr_writer :channel
|
|
31
|
+
|
|
32
|
+
# @return [Integer, nil] the amount of time the stream has been playing, or `nil` if nothing has been played yet.
|
|
33
|
+
attr_reader :stream_time
|
|
34
|
+
|
|
35
|
+
# @return [Encoder] the encoder used to encode audio files into the format required by Discord.
|
|
36
|
+
attr_reader :encoder
|
|
37
|
+
|
|
38
|
+
# onyxcord will occasionally measure the time it takes to send a packet, and adjust future delay times based
|
|
39
|
+
# on that data. This makes voice playback more smooth, because if packets are sent too slowly, the audio will
|
|
40
|
+
# sound patchy, and if they're sent too quickly, packets will "pile up" and occasionally skip some data or
|
|
41
|
+
# play parts back too fast. How often these measurements should be done depends a lot on the system, and if it's
|
|
42
|
+
# done too quickly, especially on slow connections, the playback speed will vary wildly; if it's done too slowly
|
|
43
|
+
# however, small errors will cause quality problems for a longer time.
|
|
44
|
+
# @return [Integer] how frequently audio length adjustments should be done, in ideal packets (20ms).
|
|
45
|
+
attr_accessor :adjust_interval
|
|
46
|
+
|
|
47
|
+
# This particular value is also important because ffmpeg may take longer to process the first few packets. It is
|
|
48
|
+
# recommended to set this to 10 at maximum, otherwise it will take too long to make the first adjustment, but it
|
|
49
|
+
# shouldn't be any higher than {#adjust_interval}, otherwise no adjustments will take place. If {#adjust_interval}
|
|
50
|
+
# is at a value higher than 10, this value should not be changed at all.
|
|
51
|
+
# @see #adjust_interval
|
|
52
|
+
# @return [Integer] the packet number (1 packet = 20ms) at which length adjustments should start.
|
|
53
|
+
attr_accessor :adjust_offset
|
|
54
|
+
|
|
55
|
+
# This value determines whether or not the adjustment length should be averaged with the previous value. This may
|
|
56
|
+
# be useful on slower connections where latencies vary a lot. In general, it will make adjustments more smooth,
|
|
57
|
+
# but whether that is desired behaviour should be tried on a case-by-case basis.
|
|
58
|
+
# @see #adjust_interval
|
|
59
|
+
# @return [true, false] whether adjustment lengths should be averaged with the respective previous value.
|
|
60
|
+
attr_accessor :adjust_average
|
|
61
|
+
|
|
62
|
+
# Disable the debug message for length adjustment specifically, as it can get quite spammy with very low intervals
|
|
63
|
+
# @see #adjust_interval
|
|
64
|
+
# @return [true, false] whether length adjustment debug messages should be printed
|
|
65
|
+
attr_accessor :adjust_debug
|
|
66
|
+
|
|
67
|
+
# If this value is set, no length adjustments will ever be done and this value will always be used as the length
|
|
68
|
+
# (i.e. packets will be sent every N seconds). Be careful not to set it too low as to not spam Discord's servers.
|
|
69
|
+
# The ideal length is 20ms (accessible by the {OnyxCord::Voice::IDEAL_LENGTH} constant), this value should be
|
|
70
|
+
# slightly lower than that because encoding + sending takes time. Note that sending DCA files is significantly
|
|
71
|
+
# faster than sending regular audio files (usually about four times as fast), so you might want to set this value
|
|
72
|
+
# to something else if you're sending a DCA file.
|
|
73
|
+
# @return [Float] the packet length that should be used instead of calculating it during the adjustments, in ms.
|
|
74
|
+
attr_accessor :length_override
|
|
75
|
+
|
|
76
|
+
# The factor the audio's volume should be multiplied with. `1` is no change in volume, `0` is completely silent,
|
|
77
|
+
# `0.5` is half the default volume and `2` is twice the default.
|
|
78
|
+
# @return [Float] the volume for audio playback, `1.0` by default.
|
|
79
|
+
attr_accessor :volume
|
|
80
|
+
|
|
81
|
+
# @!visibility private
|
|
82
|
+
def initialize(channel, bot, token, session, endpoint)
|
|
83
|
+
@bot = bot
|
|
84
|
+
@channel = channel
|
|
85
|
+
|
|
86
|
+
@ws = VoiceWS.new(channel, bot, token, session, endpoint)
|
|
87
|
+
@udp = @ws.udp
|
|
88
|
+
|
|
89
|
+
@sequence = @time = 0
|
|
90
|
+
@skips = 0
|
|
91
|
+
|
|
92
|
+
@adjust_interval = 100
|
|
93
|
+
@adjust_offset = 10
|
|
94
|
+
@adjust_average = false
|
|
95
|
+
@adjust_debug = true
|
|
96
|
+
|
|
97
|
+
@volume = 1.0
|
|
98
|
+
@playing = false
|
|
99
|
+
|
|
100
|
+
@encoder = Encoder.new
|
|
101
|
+
@ws.connect
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
OnyxCord::LOGGER.log_exception(e)
|
|
104
|
+
raise
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [true, false] whether audio data sent will be encrypted.
|
|
108
|
+
# @deprecated Discord no longer supports unencrypted voice communication.
|
|
109
|
+
def encrypted?
|
|
110
|
+
true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Set the filter volume. This volume is applied as a filter for decoded audio data. It has the advantage that using
|
|
114
|
+
# it is much faster than regular volume, but it can only be changed before starting to play something.
|
|
115
|
+
# @param value [Integer] The value to set the volume to. For possible values, see {#volume}
|
|
116
|
+
def filter_volume=(value)
|
|
117
|
+
@encoder.filter_volume = value
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @see #filter_volume=
|
|
121
|
+
# @return [Integer] the volume used as a filter for ffmpeg/avconv.
|
|
122
|
+
def filter_volume
|
|
123
|
+
@encoder.filter_volume
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Pause playback. This is not instant; it may take up to 20 ms for this change to take effect. (This is usually
|
|
127
|
+
# negligible.)
|
|
128
|
+
def pause
|
|
129
|
+
@paused = true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @see #play
|
|
133
|
+
# @return [true, false] Whether it is playing sound or not.
|
|
134
|
+
def playing?
|
|
135
|
+
@playing
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
alias_method :isplaying?, :playing?
|
|
139
|
+
|
|
140
|
+
# Continue playback. This change may take up to 100ms to take effect, which is usually negligible.
|
|
141
|
+
def continue
|
|
142
|
+
@paused = false
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Skips to a later time in the song. It's impossible to go back without replaying the song.
|
|
146
|
+
# @param secs [Float] How many seconds to skip forwards. Skipping will always be done in discrete intervals of
|
|
147
|
+
# 0.05 seconds, so if the given amount is smaller than that, it will be rounded up.
|
|
148
|
+
def skip(secs)
|
|
149
|
+
@skips += (secs * (1000 / IDEAL_LENGTH)).ceil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Sets whether or not the bot is speaking (green circle around user).
|
|
153
|
+
# @param value [true, false, Integer] whether or not the bot should be speaking, or a bitmask denoting the audio type
|
|
154
|
+
# @note https://discord.com/developers/docs/topics/voice-connections#speaking for information on the speaking bitmask
|
|
155
|
+
def speaking=(value)
|
|
156
|
+
@playing = value
|
|
157
|
+
@ws.send_speaking(value)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Stops the current playback entirely.
|
|
161
|
+
# @param wait_for_confirmation [true, false] Whether the method should wait for confirmation from the playback
|
|
162
|
+
# method that the playback has actually stopped.
|
|
163
|
+
def stop_playing(wait_for_confirmation = false)
|
|
164
|
+
@was_playing_before = @playing
|
|
165
|
+
@speaking = false
|
|
166
|
+
@playing = false
|
|
167
|
+
sleep IDEAL_LENGTH / 1000.0 if @was_playing_before
|
|
168
|
+
|
|
169
|
+
return unless wait_for_confirmation
|
|
170
|
+
|
|
171
|
+
@has_stopped_playing = false
|
|
172
|
+
sleep IDEAL_LENGTH / 1000.0 until @has_stopped_playing
|
|
173
|
+
@has_stopped_playing = false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Permanently disconnects from the voice channel; to reconnect you will have to call {Bot#voice_connect} again.
|
|
177
|
+
def destroy
|
|
178
|
+
stop_playing
|
|
179
|
+
@bot.voice_destroy(@channel.server.id, false)
|
|
180
|
+
@ws.destroy
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Plays a stream of raw data to the channel. All playback methods are blocking, i.e. they wait for the playback to
|
|
184
|
+
# finish before exiting the method. This doesn't cause a problem if you just use onyxcord events/commands to
|
|
185
|
+
# play stuff, as these are fully threaded, but if you don't want this behaviour anyway, be sure to call these
|
|
186
|
+
# methods in separate threads.
|
|
187
|
+
# @param encoded_io [IO] A stream of raw PCM data (s16le)
|
|
188
|
+
def play(encoded_io)
|
|
189
|
+
stop_playing(true) if @playing
|
|
190
|
+
@retry_attempts = 3
|
|
191
|
+
@first_packet = true
|
|
192
|
+
|
|
193
|
+
play_internal do
|
|
194
|
+
buf = nil
|
|
195
|
+
|
|
196
|
+
# Read some data from the buffer
|
|
197
|
+
begin
|
|
198
|
+
buf = encoded_io.readpartial(DATA_LENGTH) if encoded_io
|
|
199
|
+
rescue EOFError
|
|
200
|
+
raise IOError, 'File or stream not found!' if @first_packet
|
|
201
|
+
|
|
202
|
+
@bot.debug('EOF while reading, breaking immediately')
|
|
203
|
+
next :stop
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Check whether the buffer has enough data
|
|
207
|
+
if !buf || buf.length != DATA_LENGTH
|
|
208
|
+
@bot.debug("No data is available! Retrying #{@retry_attempts} more times")
|
|
209
|
+
next :stop if @retry_attempts.zero?
|
|
210
|
+
|
|
211
|
+
@retry_attempts -= 1
|
|
212
|
+
next
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Adjust volume
|
|
216
|
+
buf = @encoder.adjust_volume(buf, @volume) if @volume != 1.0 # rubocop:disable Lint/FloatComparison
|
|
217
|
+
|
|
218
|
+
@first_packet = false
|
|
219
|
+
|
|
220
|
+
# Encode data
|
|
221
|
+
@encoder.encode(buf)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# If the stream is a process, kill it
|
|
225
|
+
if encoded_io&.pid
|
|
226
|
+
OnyxCord::LOGGER.debug("Killing ffmpeg process with pid #{encoded_io.pid.inspect}")
|
|
227
|
+
|
|
228
|
+
begin
|
|
229
|
+
pid = encoded_io.pid
|
|
230
|
+
# Windows does not support TERM as a kill signal, so we use KILL. `Process.waitpid` verifies that our
|
|
231
|
+
# child process has not already completed.
|
|
232
|
+
Process.kill(Gem.win_platform? ? 'KILL' : 'TERM', pid) if Process.waitpid(pid, Process::WNOHANG).nil?
|
|
233
|
+
rescue StandardError => e
|
|
234
|
+
OnyxCord::LOGGER.warn('Failed to kill ffmpeg process! You *might* have a process leak now.')
|
|
235
|
+
OnyxCord::LOGGER.warn("Reason: #{e}")
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Close the stream
|
|
240
|
+
encoded_io.close
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Plays an encoded audio file of arbitrary format to the channel.
|
|
244
|
+
# @see Encoder#encode_file
|
|
245
|
+
# @see #play
|
|
246
|
+
def play_file(file, options = '')
|
|
247
|
+
play @encoder.encode_file(file, options)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Plays a stream of encoded audio data of arbitrary format to the channel.
|
|
251
|
+
# @see Encoder#encode_io
|
|
252
|
+
# @see #play
|
|
253
|
+
def play_io(io, options = '')
|
|
254
|
+
play @encoder.encode_io(io, options)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Plays a stream of audio data in the DCA format. This format has the advantage that no recoding has to be
|
|
258
|
+
# done - the file contains the data exactly as Discord needs it.
|
|
259
|
+
# @note DCA playback will not be affected by the volume modifier ({#volume}) because the modifier operates on raw
|
|
260
|
+
# PCM, not opus data. Modifying the volume of DCA data would involve decoding it, multiplying the samples and
|
|
261
|
+
# re-encoding it, which defeats its entire purpose (no recoding).
|
|
262
|
+
# @see https://github.com/bwmarrin/dca
|
|
263
|
+
# @see #play
|
|
264
|
+
def play_dca(file)
|
|
265
|
+
stop_playing(true) if @playing
|
|
266
|
+
|
|
267
|
+
@bot.debug "Reading DCA file #{file}"
|
|
268
|
+
input_stream = File.open(file)
|
|
269
|
+
|
|
270
|
+
magic = input_stream.read(4)
|
|
271
|
+
raise ArgumentError, 'Not a DCA1 file! The file might have been corrupted, please recreate it.' unless magic == 'DCA1'
|
|
272
|
+
|
|
273
|
+
# Read the metadata header, then read the metadata and discard it as we don't care about it
|
|
274
|
+
metadata_header = input_stream.read(4).unpack1('l<')
|
|
275
|
+
input_stream.read(metadata_header)
|
|
276
|
+
|
|
277
|
+
# Play the data, without re-encoding it to opus
|
|
278
|
+
play_internal do
|
|
279
|
+
begin
|
|
280
|
+
# Read header
|
|
281
|
+
header_str = input_stream.read(2)
|
|
282
|
+
|
|
283
|
+
unless header_str
|
|
284
|
+
@bot.debug 'Finished DCA parsing (header is nil)'
|
|
285
|
+
next :stop
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
header = header_str.unpack1('s<')
|
|
289
|
+
|
|
290
|
+
raise 'Negative header in DCA file! Your file is likely corrupted.' if header.negative?
|
|
291
|
+
rescue EOFError
|
|
292
|
+
@bot.debug 'Finished DCA parsing (EOFError)'
|
|
293
|
+
next :stop
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Read bytes
|
|
297
|
+
input_stream.read(header)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
alias_method :play_stream, :play_io
|
|
302
|
+
|
|
303
|
+
private
|
|
304
|
+
|
|
305
|
+
# Plays the data from the IO stream as Discord requires it
|
|
306
|
+
def play_internal
|
|
307
|
+
count = 0
|
|
308
|
+
@playing = true
|
|
309
|
+
self.speaking = true
|
|
310
|
+
|
|
311
|
+
last_sent = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
312
|
+
|
|
313
|
+
loop do
|
|
314
|
+
# If paused, wait
|
|
315
|
+
sleep 0.1 while @paused
|
|
316
|
+
|
|
317
|
+
break unless @playing
|
|
318
|
+
|
|
319
|
+
# Get timestamp before encoding
|
|
320
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
321
|
+
|
|
322
|
+
# If we should skip, get some data, discard it and go to the next iteration
|
|
323
|
+
if @skips.positive?
|
|
324
|
+
@skips -= 1
|
|
325
|
+
yield
|
|
326
|
+
next
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Track packet count, sequence and time (Discord requires this)
|
|
330
|
+
count += 1
|
|
331
|
+
increment_packet_headers
|
|
332
|
+
|
|
333
|
+
# Get packet data
|
|
334
|
+
buf = yield
|
|
335
|
+
|
|
336
|
+
# Stop doing anything if the stop signal was sent
|
|
337
|
+
break if buf == :stop
|
|
338
|
+
|
|
339
|
+
# Proceed to the next packet if we got nil
|
|
340
|
+
next unless buf
|
|
341
|
+
|
|
342
|
+
# Track intermediate adjustment so we can measure how much encoding contributes to the total time
|
|
343
|
+
intermediate_adjust = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
344
|
+
|
|
345
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
346
|
+
|
|
347
|
+
if (last_sent + IDEAL_LENGTH) > Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
348
|
+
sleep_duration = (last_sent + IDEAL_LENGTH - Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)) / 1000.0
|
|
349
|
+
@bot.debug("Waiting for next frame: #{sleep_duration * 1000}ms (encoding #{intermediate_adjust - start_time}ms)") if @adjust_debug
|
|
350
|
+
sleep sleep_duration if sleep_duration.positive?
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Send the packet
|
|
354
|
+
@udp.send_audio(buf, @sequence, @time)
|
|
355
|
+
|
|
356
|
+
# Set the stream time (for tracking how long we've been playing)
|
|
357
|
+
@stream_time = count * IDEAL_LENGTH / 1000
|
|
358
|
+
last_sent = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
@bot.debug('Sending five silent frames to clear out buffers')
|
|
362
|
+
|
|
363
|
+
5.times do
|
|
364
|
+
increment_packet_headers
|
|
365
|
+
@udp.send_audio(Encoder::OPUS_SILENCE, @sequence, @time)
|
|
366
|
+
|
|
367
|
+
# Length adjustments don't matter here, we can just wait 20ms since nobody is going to hear it anyway
|
|
368
|
+
sleep IDEAL_LENGTH / 1000.0
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
@bot.debug('Performing final cleanup after stream ended')
|
|
372
|
+
|
|
373
|
+
# Final clean-up
|
|
374
|
+
stop_playing
|
|
375
|
+
|
|
376
|
+
# Notify any stop_playing methods running right now that we have actually stopped
|
|
377
|
+
@has_stopped_playing = true
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Increment sequence and time
|
|
381
|
+
def increment_packet_headers
|
|
382
|
+
@sequence + 10 < 65_535 ? @sequence += 1 : @sequence = 0
|
|
383
|
+
@time + 9600 < 4_294_967_295 ? @time += 960 : @time = 0
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'onyxcord/webhooks/version'
|
|
4
|
+
require 'onyxcord/webhooks/embeds'
|
|
5
|
+
require 'onyxcord/webhooks/client'
|
|
6
|
+
require 'onyxcord/webhooks/builder'
|
|
7
|
+
require 'onyxcord/webhooks/view'
|
|
8
|
+
require 'onyxcord/webhooks/modal'
|
|
9
|
+
|
|
10
|
+
module OnyxCord
|
|
11
|
+
# Webhook client
|
|
12
|
+
module Webhooks
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'websocket-client-simple'
|
|
4
|
+
|
|
5
|
+
module OnyxCord
|
|
6
|
+
# Utility wrapper class that abstracts an instance of WSCS. Useful should we decide that WSCS isn't good either -
|
|
7
|
+
# in that case we can just switch to something else
|
|
8
|
+
class WebSocket
|
|
9
|
+
attr_reader :open_handler, :message_handler, :close_handler, :error_handler
|
|
10
|
+
|
|
11
|
+
# Create a new WebSocket and connect to the given endpoint.
|
|
12
|
+
# @param endpoint [String] Where to connect to.
|
|
13
|
+
# @param open_handler [#call] The handler that should be called when the websocket has opened successfully.
|
|
14
|
+
# @param message_handler [#call] The handler that should be called when the websocket receives a message. The
|
|
15
|
+
# handler can take one parameter which will have a `data` attribute for normal messages and `code` and `data` for
|
|
16
|
+
# close frames.
|
|
17
|
+
# @param close_handler [#call] The handler that should be called when the websocket is closed due to an internal
|
|
18
|
+
# error. The error will be passed as the first parameter to the handler.
|
|
19
|
+
# @param error_handler [#call] The handler that should be called when an error occurs in another handler. The error
|
|
20
|
+
# will be passed as the first parameter to the handler.
|
|
21
|
+
def initialize(endpoint, open_handler, message_handler, close_handler, error_handler)
|
|
22
|
+
OnyxCord::LOGGER.debug "Using WSCS version: #{::WebSocket::Client::Simple::VERSION}"
|
|
23
|
+
|
|
24
|
+
@open_handler = open_handler
|
|
25
|
+
@message_handler = message_handler
|
|
26
|
+
@close_handler = close_handler
|
|
27
|
+
@error_handler = error_handler
|
|
28
|
+
|
|
29
|
+
instance = self # to work around WSCS's weird way of handling blocks
|
|
30
|
+
|
|
31
|
+
@client = ::WebSocket::Client::Simple.connect(endpoint) do |ws|
|
|
32
|
+
ws.on(:open) { instance.open_handler.call }
|
|
33
|
+
ws.on(:message) do |msg|
|
|
34
|
+
# If the message has a code attribute, it is in reality a close message
|
|
35
|
+
if msg.code
|
|
36
|
+
instance.close_handler.call(msg)
|
|
37
|
+
else
|
|
38
|
+
instance.message_handler.call(msg.data)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
ws.on(:close) { |err| instance.close_handler.call(err) }
|
|
42
|
+
ws.on(:error) { |err| instance.error_handler.call(err) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Send data over this WebSocket
|
|
47
|
+
# @param data [String] What to send
|
|
48
|
+
def send(data)
|
|
49
|
+
@client.send(data)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Close the WebSocket connection
|
|
53
|
+
def close
|
|
54
|
+
@client.close
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Thread] the internal WSCS thread
|
|
58
|
+
def thread
|
|
59
|
+
@client.thread
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/onyxcord.rb
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'onyxcord/version'
|
|
4
|
+
require 'onyxcord/configuration'
|
|
5
|
+
require 'onyxcord/event_executor'
|
|
6
|
+
require 'onyxcord/message_components'
|
|
7
|
+
require 'onyxcord/bot'
|
|
8
|
+
require 'onyxcord/commands/command_bot'
|
|
9
|
+
require 'onyxcord/logger'
|
|
10
|
+
|
|
11
|
+
# All onyxcord functionality, to be extended by other files
|
|
12
|
+
module OnyxCord
|
|
13
|
+
Thread.current[:onyxcord_name] = 'main'
|
|
14
|
+
|
|
15
|
+
# The default debug logger used by onyxcord.
|
|
16
|
+
LOGGER = Logger.new(ENV.fetch('ONYXCORD_FANCY_LOG', false))
|
|
17
|
+
|
|
18
|
+
# The Unix timestamp Discord IDs are based on
|
|
19
|
+
DISCORD_EPOCH = 1_420_070_400_000
|
|
20
|
+
|
|
21
|
+
# Used to declare what events you wish to recieve from Discord.
|
|
22
|
+
# @see https://discord.com/developers/docs/topics/gateway#gateway-intents
|
|
23
|
+
INTENTS = {
|
|
24
|
+
servers: 1 << 0,
|
|
25
|
+
server_members: 1 << 1,
|
|
26
|
+
server_bans: 1 << 2,
|
|
27
|
+
server_emojis: 1 << 3,
|
|
28
|
+
server_integrations: 1 << 4,
|
|
29
|
+
server_webhooks: 1 << 5,
|
|
30
|
+
server_invites: 1 << 6,
|
|
31
|
+
server_voice_states: 1 << 7,
|
|
32
|
+
server_presences: 1 << 8,
|
|
33
|
+
server_messages: 1 << 9,
|
|
34
|
+
server_message_reactions: 1 << 10,
|
|
35
|
+
server_message_typing: 1 << 11,
|
|
36
|
+
direct_messages: 1 << 12,
|
|
37
|
+
direct_message_reactions: 1 << 13,
|
|
38
|
+
direct_message_typing: 1 << 14,
|
|
39
|
+
message_content: 1 << 15,
|
|
40
|
+
server_scheduled_events: 1 << 16,
|
|
41
|
+
server_message_polls: 1 << 24,
|
|
42
|
+
direct_message_polls: 1 << 25
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
INTENT_ALIASES = {
|
|
46
|
+
guilds: :servers,
|
|
47
|
+
guild_members: :server_members,
|
|
48
|
+
guild_bans: :server_bans,
|
|
49
|
+
guild_emojis: :server_emojis,
|
|
50
|
+
guild_integrations: :server_integrations,
|
|
51
|
+
guild_webhooks: :server_webhooks,
|
|
52
|
+
guild_invites: :server_invites,
|
|
53
|
+
guild_voice_states: :server_voice_states,
|
|
54
|
+
guild_presences: :server_presences,
|
|
55
|
+
guild_messages: :server_messages,
|
|
56
|
+
guild_message_reactions: :server_message_reactions,
|
|
57
|
+
guild_message_typing: :server_message_typing,
|
|
58
|
+
guild_scheduled_events: :server_scheduled_events,
|
|
59
|
+
guild_message_polls: :server_message_polls
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
MINIMAL_INTENTS = INTENTS.values_at(:servers, :server_messages, :direct_messages).reduce(&:|)
|
|
63
|
+
|
|
64
|
+
# All available intents
|
|
65
|
+
ALL_INTENTS = INTENTS.values.reduce(&:|)
|
|
66
|
+
|
|
67
|
+
# All unprivileged intents
|
|
68
|
+
# @see https://discord.com/developers/docs/topics/gateway#privileged-intents
|
|
69
|
+
UNPRIVILEGED_INTENTS = ALL_INTENTS & ~(INTENTS[:server_members] | INTENTS[:server_presences] | INTENTS[:message_content])
|
|
70
|
+
|
|
71
|
+
# No intents
|
|
72
|
+
NO_INTENTS = 0
|
|
73
|
+
|
|
74
|
+
# Compares two objects based on IDs - either the objects' IDs are equal, or one object is equal to the other's ID.
|
|
75
|
+
def self.id_compare?(one_id, other)
|
|
76
|
+
other.respond_to?(:resolve_id) ? (one_id.resolve_id == other.resolve_id) : (one_id == other)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @deprecated Please use {OnyxCord.id_compare?}
|
|
80
|
+
singleton_class.alias_method :id_compare, :id_compare?
|
|
81
|
+
|
|
82
|
+
# The maximum length a Discord message can have
|
|
83
|
+
CHARACTER_LIMIT = 2000
|
|
84
|
+
|
|
85
|
+
# For creating timestamps with {timestamp}
|
|
86
|
+
# @see https://discord.com/developers/docs/reference#message-formatting-timestamp-styles
|
|
87
|
+
TIMESTAMP_STYLES = {
|
|
88
|
+
short_time: 't', # 16:20
|
|
89
|
+
long_time: 'T', # 16:20:30
|
|
90
|
+
short_date: 'd', # 20/04/2021
|
|
91
|
+
long_date: 'D', # 20 April 2021
|
|
92
|
+
short_datetime: 'f', # 20 April 2021 16:20
|
|
93
|
+
long_datetime: 'F', # Tuesday, 20 April 2021 16:20
|
|
94
|
+
relative: 'R', # 2 months ago
|
|
95
|
+
simple_datetime: 's', # 20/04/2021, 16:20
|
|
96
|
+
medium_datetime: 'S' # 20/04/2021, 16:20:30
|
|
97
|
+
}.freeze
|
|
98
|
+
|
|
99
|
+
# Splits a message into chunks of 2000 characters. Attempts to split by lines if possible.
|
|
100
|
+
# @param msg [String] The message to split.
|
|
101
|
+
# @return [Array<String>] the message split into chunks
|
|
102
|
+
def self.split_message(msg)
|
|
103
|
+
# If the messages is empty, return an empty array
|
|
104
|
+
return [] if msg.empty?
|
|
105
|
+
|
|
106
|
+
# Split the message into lines
|
|
107
|
+
lines = msg.lines
|
|
108
|
+
|
|
109
|
+
# Turn the message into a "triangle" of consecutively longer slices, for example the array [1,2,3,4] would become
|
|
110
|
+
# [
|
|
111
|
+
# [1],
|
|
112
|
+
# [1, 2],
|
|
113
|
+
# [1, 2, 3],
|
|
114
|
+
# [1, 2, 3, 4]
|
|
115
|
+
# ]
|
|
116
|
+
tri = (0...lines.length).map { |i| lines.combination(i + 1).first }
|
|
117
|
+
|
|
118
|
+
# Join the individual elements together to get an array of strings with consecutively more lines
|
|
119
|
+
joined = tri.map(&:join)
|
|
120
|
+
|
|
121
|
+
# Find the largest element that is still below the character limit, or if none such element exists return the first
|
|
122
|
+
ideal = joined.max_by { |e| e.length > CHARACTER_LIMIT ? -1 : e.length }
|
|
123
|
+
|
|
124
|
+
# If it's still larger than the character limit (none was smaller than it) split it into the largest chunk without
|
|
125
|
+
# cutting words apart, breaking on the nearest space within character limit, otherwise just return an array with one element
|
|
126
|
+
ideal_ary = ideal.length > CHARACTER_LIMIT ? ideal.split(/(.{1,#{CHARACTER_LIMIT}}\b|.{1,#{CHARACTER_LIMIT}})/o).reject(&:empty?) : [ideal]
|
|
127
|
+
|
|
128
|
+
# Slice off the ideal part and strip newlines
|
|
129
|
+
rest = msg[ideal.length..].strip
|
|
130
|
+
|
|
131
|
+
# If none remains, return an empty array -> we're done
|
|
132
|
+
return [] unless rest
|
|
133
|
+
|
|
134
|
+
# Otherwise, call the method recursively to split the rest of the string and add it onto the ideal array
|
|
135
|
+
ideal_ary + split_message(rest)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @param time [Time, Integer] The time to create the timestamp from, or a unix timestamp integer.
|
|
139
|
+
# @param style [Symbol, String] One of the keys from {TIMESTAMP_STYLES} or a string with the style.
|
|
140
|
+
# @return [String]
|
|
141
|
+
# @example
|
|
142
|
+
# OnyxCord.timestamp(Time.now, :short_time)
|
|
143
|
+
# # => "<t:1632146954:t>"
|
|
144
|
+
def self.timestamp(time, style = nil)
|
|
145
|
+
if style.nil?
|
|
146
|
+
"<t:#{time.to_i}>"
|
|
147
|
+
else
|
|
148
|
+
"<t:#{time.to_i}:#{TIMESTAMP_STYLES[style] || style}>"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A utility method to base64 encode a file like object using its mime type.
|
|
153
|
+
# @param file [File, #read] A file like object that responds to #read.
|
|
154
|
+
# @return [String] The file object encoded as base64 image data.
|
|
155
|
+
def self.encode64(file)
|
|
156
|
+
path_method = %i[original_filename path local_path].find { |method| file.respond_to?(method) }
|
|
157
|
+
|
|
158
|
+
raise ArgumentError, 'File object must respond to original_filename, path, or local path.' unless path_method
|
|
159
|
+
raise ArgumentError, 'File object must respond to read.' unless file.respond_to?(:read)
|
|
160
|
+
|
|
161
|
+
mime_type = MIME::Types.type_for(file.__send__(path_method)).first&.to_s || 'image/jpeg'
|
|
162
|
+
"data:#{mime_type};base64,#{Base64.encode64(file.read).strip}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# In onyxcord, Integer and {String} are monkey-patched to allow for easy resolution of IDs
|
|
167
|
+
class Integer
|
|
168
|
+
# @return [Integer] The Discord ID represented by this integer, i.e. the integer itself
|
|
169
|
+
def resolve_id
|
|
170
|
+
self
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# In onyxcord, {Integer} and String are monkey-patched to allow for easy resolution of IDs
|
|
175
|
+
class String
|
|
176
|
+
# @return [Integer] The Discord ID represented by this string, i.e. the string converted to an integer
|
|
177
|
+
def resolve_id
|
|
178
|
+
to_i
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/onyxcord/webhooks/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'onyxcord-webhooks'
|
|
7
|
+
spec.version = OnyxCord::Webhooks::VERSION
|
|
8
|
+
spec.authors = ['Gustavo Silva']
|
|
9
|
+
spec.email = ['gustavosilva8kt@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'Webhook client for onyxcord'
|
|
12
|
+
spec.description = "A webhook client for OnyxCord, a Ruby Discord library based on discordrb and updated with Components V2 support."
|
|
13
|
+
spec.homepage = 'https://github.com/kruldevb/OnyxCord'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
|
|
16
|
+
spec.files = `git ls-files -z lib/onyxcord/webhooks/`.split("\x0") + ['lib/onyxcord/webhooks.rb']
|
|
17
|
+
spec.bindir = 'exe'
|
|
18
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
19
|
+
spec.require_paths = ['lib']
|
|
20
|
+
|
|
21
|
+
spec.add_dependency 'rest-client', '>= 2.0.0'
|
|
22
|
+
|
|
23
|
+
spec.required_ruby_version = '>= 3.3'
|
|
24
|
+
spec.metadata = {
|
|
25
|
+
'bug_tracker_uri' => 'https://github.com/kruldevb/OnyxCord/issues',
|
|
26
|
+
'documentation_uri' => 'https://github.com/kruldevb/OnyxCord#readme',
|
|
27
|
+
'source_code_uri' => 'https://github.com/kruldevb/OnyxCord',
|
|
28
|
+
'rubygems_mfa_required' => 'true'
|
|
29
|
+
}
|
|
30
|
+
end
|