Started learning crystal and made an OOP example for a freind
This commit is contained in:
0
Crystal/First-Program/a.out
Normal file
0
Crystal/First-Program/a.out
Normal file
1
Crystal/First-Program/lib/discordcr.sha1
Normal file
1
Crystal/First-Program/lib/discordcr.sha1
Normal file
@@ -0,0 +1 @@
|
||||
5c722de5c25a6020466e80bc10d7904a0e30e7d5
|
||||
14
Crystal/First-Program/lib/discordcr/.gitignore
vendored
Normal file
14
Crystal/First-Program/lib/discordcr/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
.DS_Store
|
||||
/doc/
|
||||
/docs/
|
||||
/libs/
|
||||
/.crystal/
|
||||
/.shards/
|
||||
|
||||
|
||||
# Libraries don't need dependency lock
|
||||
# Dependencies will be locked in application that uses them
|
||||
/shard.lock
|
||||
|
||||
|
||||
deploy_key
|
||||
10
Crystal/First-Program/lib/discordcr/.travis.yml
Normal file
10
Crystal/First-Program/lib/discordcr/.travis.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
language: crystal
|
||||
script:
|
||||
- crystal spec
|
||||
- crystal tool format --check
|
||||
- find examples -name "*.cr" | xargs -L 1 crystal build --no-codegen
|
||||
- bash ./deploy.sh
|
||||
env:
|
||||
global:
|
||||
- ENCRYPTION_LABEL: "65183d8b3ae9"
|
||||
- COMMIT_AUTHOR_EMAIL: "blactbt@live.de"
|
||||
21
Crystal/First-Program/lib/discordcr/LICENSE
Normal file
21
Crystal/First-Program/lib/discordcr/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 meew0
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
88
Crystal/First-Program/lib/discordcr/README.md
Normal file
88
Crystal/First-Program/lib/discordcr/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
[](https://meew0.github.io/discordcr/doc/v0.1.0/)
|
||||
|
||||
# discordcr
|
||||
|
||||
(The "cr" stands for "creative name".)
|
||||
|
||||
discordcr is a minimalist [Discord](https://discordapp.com/) API library for
|
||||
[Crystal](https://crystal-lang.org/), designed to be a complement to
|
||||
[discordrb](https://github.com/meew0/discordrb) for users who want more control
|
||||
and performance and who care less about ease-of-use.
|
||||
|
||||
discordcr isn't designed for beginners to the Discord API - while experience
|
||||
with making bots isn't *required*, it's certainly recommended. If you feel
|
||||
overwhelmed by the complex documentation, try
|
||||
[discordrb](https://github.com/meew0/discordrb) first and then check back.
|
||||
|
||||
Unlike many other libs which handle a lot of stuff, like caching or resolving,
|
||||
themselves automatically, discordcr requires the user to do such things
|
||||
manually. It also doesn't provide any advanced abstractions for REST calls;
|
||||
the methods perform the HTTP request with the given data but nothing else.
|
||||
This means that the user has full control over them, but also full
|
||||
responsibility. discordcr does not support user accounts; it may work but
|
||||
likely doesn't.
|
||||
|
||||
## Installation
|
||||
|
||||
Add this to your application's `shard.yml`:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
discordcr:
|
||||
github: meew0/discordcr
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
An example bot can be found
|
||||
[here](https://github.com/meew0/discordcr/blob/master/examples/ping.cr). More
|
||||
examples will come in the future.
|
||||
|
||||
A short overview of library structure: the `Client` class includes the `REST`
|
||||
module, which handles the REST parts of Discord's API; the `Client` itself
|
||||
handles the gateway, i. e. the interactive parts such as receiving messages. It
|
||||
is possible to use only the REST parts by never calling the `#run` method on a
|
||||
`Client`, which is what does the actual gateway connection.
|
||||
|
||||
The example linked above has an example of an event (`on_message_create`) that
|
||||
is called through the gateway, and of a REST call (`client.create_message`).
|
||||
Other gateway events and REST calls work much in the same way - see the
|
||||
documentation for what specific events and REST calls do.
|
||||
|
||||
Caching is done using a separate `Cache` class that needs to be added into
|
||||
clients manually:
|
||||
|
||||
```cr
|
||||
client = Discord::Client.new # ...
|
||||
cache = Discord::Cache.new(client)
|
||||
client.cache = cache
|
||||
```
|
||||
|
||||
Resolution requests for objects can now be done on the `cache` object instead of
|
||||
directly over REST, this ensures that if an object is needed more than once
|
||||
there will still only be one request to Discord. (There may even be no request
|
||||
at all, if the requested data has already been obtained over the gateway.)
|
||||
An example of how to use the cache once it has been instantiated:
|
||||
|
||||
```cr
|
||||
# Get the username of the user with ID 66237334693085184
|
||||
user = cache.resolve_user(66237334693085184_u64)
|
||||
user = cache.resolve_user(66237334693085184_u64) # won't do a request to Discord
|
||||
puts user.username
|
||||
```
|
||||
|
||||
Apart from this, API documentation is also available, at
|
||||
https://meew0.github.io/discordcr/doc/v0.1.0/.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork it (https://github.com/meew0/discordcr/fork)
|
||||
2. Create your feature branch (`git checkout -b my-new-feature`)
|
||||
3. Commit your changes (`git commit -am 'Add some feature'`)
|
||||
4. Push to the branch (`git push origin my-new-feature`)
|
||||
5. Create a new Pull Request
|
||||
|
||||
## Contributors
|
||||
|
||||
- [meew0](https://github.com/meew0) - creator, maintainer
|
||||
- [RX14](https://github.com/RX14) - Crystal expert, maintainer
|
||||
78
Crystal/First-Program/lib/discordcr/deploy.sh
Normal file
78
Crystal/First-Program/lib/discordcr/deploy.sh
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script adapted from https://gist.github.com/domenic/ec8b0fc8ab45f39403dd
|
||||
|
||||
set -e # Exit with nonzero exit code if anything fails
|
||||
|
||||
SOURCE_BRANCH="master"
|
||||
TARGET_BRANCH="gh-pages"
|
||||
|
||||
function doCompile {
|
||||
crystal doc
|
||||
}
|
||||
|
||||
# Pull requests and commits to other branches shouldn't try to deploy, just build to verify
|
||||
if [ "$TRAVIS_PULL_REQUEST" != "false" ] || { [ "$TRAVIS_BRANCH" != "$SOURCE_BRANCH" ] && [ -z "$TRAVIS_TAG" ]; }; then
|
||||
echo "Skipping deploy; just doing a build."
|
||||
doCompile
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$TRAVIS_TAG" ]; then
|
||||
SOURCE_BRANCH=$TRAVIS_TAG
|
||||
fi
|
||||
|
||||
# Save some useful information
|
||||
REPO=`git config remote.origin.url`
|
||||
SSH_REPO=${REPO/https:\/\/github.com\//git@github.com:}
|
||||
SHA=`git rev-parse --verify HEAD`
|
||||
|
||||
# Clone the existing gh-pages for this repo into out/
|
||||
# Create a new empty branch if gh-pages doesn't exist yet (should only happen on first deply)
|
||||
git clone $REPO out
|
||||
cd out
|
||||
git checkout $TARGET_BRANCH || git checkout --orphan $TARGET_BRANCH
|
||||
cd ..
|
||||
|
||||
mkdir -p out/doc/$SOURCE_BRANCH
|
||||
|
||||
# Clean out existing contents
|
||||
rm -rf out/doc/$SOURCE_BRANCH/**/* || exit 0
|
||||
|
||||
# Run our compile script
|
||||
doCompile
|
||||
|
||||
# Move results
|
||||
mv docs/* out/doc/$SOURCE_BRANCH/
|
||||
|
||||
# Now let's go have some fun with the cloned repo
|
||||
cd out
|
||||
git config user.name "Travis CI"
|
||||
git config user.email "$COMMIT_AUTHOR_EMAIL"
|
||||
|
||||
git add -N doc/$SOURCE_BRANCH
|
||||
|
||||
# If there are no changes to the compiled out (e.g. this is a README update) then just bail.
|
||||
DIFF_RESULT=`git diff`
|
||||
if [ -z "$DIFF_RESULT" ]; then
|
||||
echo "No changes to the output on this push; exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Commit the "changes", i.e. the new version.
|
||||
# The delta will show diffs between new and old versions.
|
||||
git add .
|
||||
git commit -m "Deploy to GitHub Pages: ${SHA}"
|
||||
|
||||
# Get the deploy key by using Travis's stored variables to decrypt deploy_key.enc
|
||||
ENCRYPTED_KEY_VAR="encrypted_${ENCRYPTION_LABEL}_key"
|
||||
ENCRYPTED_IV_VAR="encrypted_${ENCRYPTION_LABEL}_iv"
|
||||
ENCRYPTED_KEY=${!ENCRYPTED_KEY_VAR}
|
||||
ENCRYPTED_IV=${!ENCRYPTED_IV_VAR}
|
||||
openssl aes-256-cbc -K $ENCRYPTED_KEY -iv $ENCRYPTED_IV -in deploy_key.enc -out deploy_key -d
|
||||
chmod 600 deploy_key
|
||||
eval `ssh-agent -s`
|
||||
ssh-add deploy_key
|
||||
|
||||
# Now that we're all set up, we can push.
|
||||
git push $SSH_REPO $TARGET_BRANCH
|
||||
BIN
Crystal/First-Program/lib/discordcr/deploy_key.enc
Normal file
BIN
Crystal/First-Program/lib/discordcr/deploy_key.enc
Normal file
Binary file not shown.
@@ -0,0 +1,50 @@
|
||||
# This example demonstrates usage of `Discord::Mention.parse` to parse
|
||||
# and handle different kinds of mentions appearing in a message.
|
||||
|
||||
require "../src/discordcr"
|
||||
|
||||
# Make sure to replace this fake data with actual data when running.
|
||||
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm")
|
||||
|
||||
client.on_message_create do |payload|
|
||||
next unless payload.content.starts_with?("parse:")
|
||||
|
||||
mentions = String.build do |string|
|
||||
index = 0
|
||||
Discord::Mention.parse(payload.content) do |mention|
|
||||
index += 1
|
||||
string << "`[" << index << " @ " << mention.start << "]` "
|
||||
case mention
|
||||
when Discord::Mention::User
|
||||
string.puts "**User:** #{mention.id}"
|
||||
when Discord::Mention::Role
|
||||
string.puts "**Role:** #{mention.id}"
|
||||
when Discord::Mention::Channel
|
||||
string.puts "**Channel:** #{mention.id}"
|
||||
when Discord::Mention::Emoji
|
||||
string << "**Emoji:** #{mention.name} #{mention.id}"
|
||||
string << " (animated)" if mention.animated
|
||||
string.puts
|
||||
when Discord::Mention::Everyone
|
||||
string.puts "**Everyone**"
|
||||
when Discord::Mention::Here
|
||||
string.puts "**Here**"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
mentions = "no mentions found in your message" if mentions.empty?
|
||||
|
||||
begin
|
||||
client.create_message(
|
||||
payload.channel_id,
|
||||
mentions)
|
||||
rescue ex
|
||||
client.create_message(
|
||||
payload.channel_id,
|
||||
"`#{ex.inspect}`")
|
||||
raise ex
|
||||
end
|
||||
end
|
||||
|
||||
client.run
|
||||
35
Crystal/First-Program/lib/discordcr/examples/multicommand.cr
Normal file
35
Crystal/First-Program/lib/discordcr/examples/multicommand.cr
Normal file
@@ -0,0 +1,35 @@
|
||||
# multicommand.cr is an example that uses a simple command "dispatcher"
|
||||
# via a case statement.
|
||||
# This example features a few commands:
|
||||
# » !help ==> sends a dm (direct message) to the user
|
||||
# with information
|
||||
# » !about ==> prints about information in a code block
|
||||
# » !echo <args> ==> echos args
|
||||
# » !date ==> prints the current date
|
||||
|
||||
require "../src/discordcr"
|
||||
|
||||
# Make sure to replace this fake data with actual data when running.
|
||||
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64)
|
||||
|
||||
# Command Prefix
|
||||
PREFIX = "!"
|
||||
|
||||
client.on_message_create do |payload|
|
||||
command = payload.content
|
||||
case command
|
||||
when PREFIX + "help"
|
||||
client.create_message(client.create_dm(payload.author.id).id, "Help is on the way!")
|
||||
when PREFIX + "about"
|
||||
block = "```\nBot developed by discordcr\n```"
|
||||
client.create_message(payload.channel_id, block)
|
||||
when .starts_with? PREFIX + "echo"
|
||||
# !echo is a good example of a command with arguments (suffix)
|
||||
suffix = command.split(' ')[1..-1].join(" ")
|
||||
client.create_message(payload.channel_id, suffix)
|
||||
when PREFIX + "date"
|
||||
client.create_message(payload.channel_id, Time.now.to_s("%D"))
|
||||
end
|
||||
end
|
||||
|
||||
client.run
|
||||
14
Crystal/First-Program/lib/discordcr/examples/ping.cr
Normal file
14
Crystal/First-Program/lib/discordcr/examples/ping.cr
Normal file
@@ -0,0 +1,14 @@
|
||||
# This simple example bot replies to every "!ping" message with "Pong!".
|
||||
|
||||
require "../src/discordcr"
|
||||
|
||||
# Make sure to replace this fake data with actual data when running.
|
||||
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64)
|
||||
|
||||
client.on_message_create do |payload|
|
||||
if payload.content.starts_with? "!ping"
|
||||
client.create_message(payload.channel_id, "Pong!")
|
||||
end
|
||||
end
|
||||
|
||||
client.run
|
||||
@@ -0,0 +1,18 @@
|
||||
# This example is nearly the same as the normal ping example, but rather than simply
|
||||
# responding with "Pong!", it also responds with the time it took to send the message.
|
||||
|
||||
require "../src/discordcr"
|
||||
|
||||
# Make sure to replace this fake data with actual data when running.
|
||||
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64)
|
||||
|
||||
client.on_message_create do |payload|
|
||||
if payload.content.starts_with? "!ping"
|
||||
# We first create a new Message, and then we check how long it took to send the message by comparing it to the current time
|
||||
m = client.create_message(payload.channel_id, "Pong!")
|
||||
time = Time.utc_now - payload.timestamp
|
||||
client.edit_message(m.channel_id, m.id, "Pong! Time taken: #{time.total_milliseconds} ms.")
|
||||
end
|
||||
end
|
||||
|
||||
client.run
|
||||
130
Crystal/First-Program/lib/discordcr/examples/voice_send.cr
Normal file
130
Crystal/First-Program/lib/discordcr/examples/voice_send.cr
Normal file
@@ -0,0 +1,130 @@
|
||||
# This is a simple music bot that can connect to a voice channel and play back
|
||||
# some music in DCA format. It demonstrates how to use VoiceClient and
|
||||
# DCAParser.
|
||||
#
|
||||
# For more information on the DCA file format, see
|
||||
# https://github.com/bwmarrin/dca.
|
||||
|
||||
require "../src/discordcr"
|
||||
|
||||
# Make sure to replace this fake data with actual data when running.
|
||||
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64)
|
||||
|
||||
# ID of the current user, required to create a voice client
|
||||
current_user_id = nil
|
||||
|
||||
# The ID of the (text) channel in which the connect command was run, so the
|
||||
# "Voice connected." message is sent to the correct channel
|
||||
connect_channel_id = nil
|
||||
|
||||
# Where the created voice client will eventually be stored
|
||||
voice_client = nil
|
||||
|
||||
client.on_ready do |payload|
|
||||
current_user_id = payload.user.id
|
||||
end
|
||||
|
||||
client.on_message_create do |payload|
|
||||
if payload.content.starts_with? "!connect "
|
||||
# Used as:
|
||||
# !connect <guild ID> <channel ID>
|
||||
|
||||
# Parse the command arguments
|
||||
ids = payload.content[9..-1].split(' ').map(&.to_u64)
|
||||
|
||||
client.create_message(payload.channel_id, "Connecting...")
|
||||
connect_channel_id = payload.channel_id
|
||||
client.voice_state_update(ids[0].to_u64, ids[1].to_u64, false, false)
|
||||
elsif payload.content.starts_with? "!play_dca "
|
||||
# Used as:
|
||||
# !play_dca <filename>
|
||||
#
|
||||
# Make sure the DCA file you play back is valid according to the spec
|
||||
# (including metadata), otherwise playback will fail.
|
||||
|
||||
unless voice_client
|
||||
client.create_message(payload.channel_id, "Voice client is nil!")
|
||||
next
|
||||
end
|
||||
|
||||
filename = payload.content[10..-1]
|
||||
file = File.open(filename)
|
||||
|
||||
# The DCAParser class handles parsing of the DCA file. It doesn't do any
|
||||
# sending of audio data to Discord itself – that has to be done by
|
||||
# VoiceClient.
|
||||
parser = Discord::DCAParser.new(file)
|
||||
|
||||
# A proper DCA(1) file contains metadata, which is exposed by DCAParser.
|
||||
# This metadata may be of interest, so here is some example code that uses
|
||||
# it.
|
||||
if metadata = parser.metadata
|
||||
tool = metadata.dca.tool
|
||||
client.create_message(payload.channel_id, "DCA file was created by #{tool.name}, version #{tool.version}.")
|
||||
|
||||
if info = metadata.info
|
||||
client.create_message(payload.channel_id, "Song info: #{info.title} by #{info.artist}.") if info.title && info.artist
|
||||
end
|
||||
else
|
||||
client.create_message(payload.channel_id, "DCA file metadata is invalid!")
|
||||
end
|
||||
|
||||
# Set the bot as speaking (green circle). This is important and has to be
|
||||
# done at least once in every voice connection, otherwise the Discord client
|
||||
# will not know who the packets we're sending belongs to.
|
||||
voice_client.not_nil!.send_speaking(true)
|
||||
|
||||
client.create_message(payload.channel_id, "Playing DCA file `#{filename}`.")
|
||||
|
||||
# For smooth audio streams Discord requires one packet every
|
||||
# 20 milliseconds. The `every` method measures the time it takes to run the
|
||||
# block and then sleeps 20 milliseconds minus that time before moving on to
|
||||
# the next iteration, ensuring accurate timing.
|
||||
#
|
||||
# When simply reading from DCA, the time it takes to read, process and
|
||||
# send the frame is small enough that `every` doesn't make much of a
|
||||
# difference (in fact, some users report that it actually makes things
|
||||
# worse). If the processing time is not negligibly slow because you're
|
||||
# doing something else than DCA parsing, or because you're reading from a
|
||||
# slow source, or for any other reason, then it is recommended to use
|
||||
# `every`. Otherwise, simply using a loop and `sleep`ing `20.milliseconds`
|
||||
# each time may suffice.
|
||||
Discord.every(20.milliseconds) do
|
||||
frame = parser.next_frame(reuse_buffer: true)
|
||||
break unless frame
|
||||
|
||||
# Perform the actual sending of the frame to Discord.
|
||||
voice_client.not_nil!.play_opus(frame)
|
||||
end
|
||||
|
||||
# Alternatively, the above code can be realised as the following:
|
||||
#
|
||||
# parser.parse do |frame|
|
||||
# Discord.timed_run(20.milliseconds) do
|
||||
# voice_client.not_nil!.play_opus(frame)
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# (The `parse` method reads the frames consecutively and passes them to the
|
||||
# block.)
|
||||
|
||||
file.close
|
||||
end
|
||||
end
|
||||
|
||||
# The VOICE_SERVER_UPDATE dispatch is sent by Discord once the op4 packet sent
|
||||
# by voice_state_update has been processed. It tells the client the endpoint
|
||||
# to connect to.
|
||||
client.on_voice_server_update do |payload|
|
||||
begin
|
||||
vc = voice_client = Discord::VoiceClient.new(payload, client.session.not_nil!, current_user_id.not_nil!)
|
||||
vc.on_ready do
|
||||
client.create_message(connect_channel_id.not_nil!, "Voice connected.")
|
||||
end
|
||||
vc.run
|
||||
rescue e
|
||||
e.inspect_with_backtrace(STDOUT)
|
||||
end
|
||||
end
|
||||
|
||||
client.run
|
||||
17
Crystal/First-Program/lib/discordcr/examples/welcome.cr
Normal file
17
Crystal/First-Program/lib/discordcr/examples/welcome.cr
Normal file
@@ -0,0 +1,17 @@
|
||||
# This simple example bot creates a message whenever a new user joins the server
|
||||
|
||||
require "../src/discordcr"
|
||||
|
||||
# Make sure to replace this fake data with actual data when running.
|
||||
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64)
|
||||
cache = Discord::Cache.new(client)
|
||||
client.cache = cache
|
||||
|
||||
client.on_guild_member_add do |payload|
|
||||
# get the guild/server information
|
||||
guild = cache.resolve_guild(payload.guild_id)
|
||||
|
||||
client.create_message(guild.id, "Please welcome <@#{payload.user.id}> to #{guild.name}.")
|
||||
end
|
||||
|
||||
client.run
|
||||
7
Crystal/First-Program/lib/discordcr/shard.yml
Normal file
7
Crystal/First-Program/lib/discordcr/shard.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
name: discordcr
|
||||
version: 0.3.0
|
||||
|
||||
authors:
|
||||
- meew0 <blactbt@live.de>
|
||||
|
||||
license: MIT
|
||||
171
Crystal/First-Program/lib/discordcr/spec/discordcr_spec.cr
Normal file
171
Crystal/First-Program/lib/discordcr/spec/discordcr_spec.cr
Normal file
@@ -0,0 +1,171 @@
|
||||
require "yaml"
|
||||
require "./spec_helper"
|
||||
|
||||
struct StructWithSnowflake
|
||||
JSON.mapping(
|
||||
data: {type: UInt64, converter: Discord::SnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct StructWithMaybeSnowflake
|
||||
JSON.mapping(
|
||||
data: {type: UInt64?, converter: Discord::MaybeSnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct StructWithSnowflakeArray
|
||||
JSON.mapping(
|
||||
data: {type: Array(UInt64), converter: Discord::SnowflakeArrayConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct StructWithTime
|
||||
JSON.mapping(
|
||||
data: {type: Time, converter: Discord::TimestampConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct StructWithMessageType
|
||||
JSON.mapping(
|
||||
data: {type: Discord::MessageType, converter: Discord::MessageTypeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct StructWithChannelType
|
||||
JSON.mapping(
|
||||
data: {type: Discord::ChannelType, converter: Discord::ChannelTypeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
describe Discord do
|
||||
describe "VERSION" do
|
||||
it "matches shards.yml" do
|
||||
version = YAML.parse(File.read(File.join(__DIR__, "..", "shard.yml")))["version"].as_s
|
||||
version.should eq(Discord::VERSION)
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::TimestampConverter do
|
||||
it "parses a time with floating point accuracy" do
|
||||
json = %({"data":"2017-11-16T13:09:18.291000+00:00"})
|
||||
|
||||
obj = StructWithTime.from_json(json)
|
||||
obj.data.should be_a Time
|
||||
end
|
||||
|
||||
it "parses a time without floating point accuracy" do
|
||||
json = %({"data":"2017-11-15T02:23:35+00:00"})
|
||||
|
||||
obj = StructWithTime.from_json(json)
|
||||
obj.data.should be_a Time
|
||||
end
|
||||
|
||||
it "serializes" do
|
||||
json = %({"data":"2017-11-16T13:09:18.291000+00:00"})
|
||||
obj = StructWithTime.from_json(json)
|
||||
obj.to_json.should eq json
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::SnowflakeConverter do
|
||||
it "converts a string to u64" do
|
||||
json = %({"data":"10000000000"})
|
||||
|
||||
obj = StructWithSnowflake.from_json(json)
|
||||
obj.data.should eq 10000000000
|
||||
obj.data.should be_a UInt64
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::MaybeSnowflakeConverter do
|
||||
it "converts a string to u64" do
|
||||
json = %({"data":"10000000000"})
|
||||
|
||||
obj = StructWithMaybeSnowflake.from_json(json)
|
||||
obj.data.should eq 10000000000
|
||||
obj.data.should be_a UInt64
|
||||
end
|
||||
|
||||
it "converts null to nil" do
|
||||
json = %({"data":null})
|
||||
|
||||
obj = StructWithMaybeSnowflake.from_json(json)
|
||||
obj.data.should eq nil
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::SnowflakeArrayConverter do
|
||||
it "converts an array of strings to u64s" do
|
||||
json = %({"data":["1", "2", "10000000000"]})
|
||||
|
||||
obj = StructWithSnowflakeArray.from_json(json)
|
||||
obj.data.should be_a Array(UInt64)
|
||||
obj.data[0].should eq 1
|
||||
obj.data[1].should eq 2
|
||||
obj.data[2].should eq 10000000000
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::REST::ModifyChannelPositionPayload do
|
||||
describe "#to_json" do
|
||||
context "parent_id is ChannelParent::Unchanged" do
|
||||
it "doesn't emit parent_id" do
|
||||
payload = {Discord::REST::ModifyChannelPositionPayload.new(0_u64, 0, Discord::REST::ChannelParent::Unchanged, true)}
|
||||
payload.to_json.should eq %([{"id":"0","position":0,"lock_permissions":true}])
|
||||
end
|
||||
end
|
||||
|
||||
context "parent_id is ChannelParent::None" do
|
||||
it "emits null for parent_id" do
|
||||
payload = {Discord::REST::ModifyChannelPositionPayload.new(0_u64, 0, Discord::REST::ChannelParent::None, true)}
|
||||
payload.to_json.should eq %([{"id":"0","position":0,"parent_id":null,"lock_permissions":true}])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::MessageTypeConverter do
|
||||
it "converts an integer into a MessageType" do
|
||||
json = %({"data": 0})
|
||||
|
||||
obj = StructWithMessageType.from_json(json)
|
||||
obj.data.should eq Discord::MessageType::Default
|
||||
end
|
||||
|
||||
context "with an invalid json value" do
|
||||
it "raises" do
|
||||
json = %({"data":"foo"})
|
||||
|
||||
expect_raises(Exception, %(Unexpected message type value: "foo")) do
|
||||
StructWithMessageType.from_json(json)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::WebSocket::Packet do
|
||||
it "inspects" do
|
||||
packet = Discord::WebSocket::Packet.new(0_i64, 1_i64, IO::Memory.new("foo"), "test")
|
||||
packet.inspect.should eq %(Discord::WebSocket::Packet(@opcode=0_i64 @sequence=1_i64 @data="foo" @event_type="test"))
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::ChannelTypeConverter do
|
||||
it "converts an integer into a ChannelType" do
|
||||
json = %({"data": 0})
|
||||
|
||||
obj = StructWithChannelType.from_json(json)
|
||||
obj.data.should eq Discord::ChannelType::GuildText
|
||||
end
|
||||
|
||||
context "with an invalid json value" do
|
||||
it "raises" do
|
||||
json = %({"data":"foo"})
|
||||
|
||||
expect_raises(Exception, %(Unexpected channel type value: "foo")) do
|
||||
StructWithChannelType.from_json(json)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
50
Crystal/First-Program/lib/discordcr/spec/mention_spec.cr
Normal file
50
Crystal/First-Program/lib/discordcr/spec/mention_spec.cr
Normal file
@@ -0,0 +1,50 @@
|
||||
require "./spec_helper"
|
||||
|
||||
def it_parses_message(string, into expected)
|
||||
it "parses #{string.inspect} into #{expected}" do
|
||||
parsed = Discord::Mention.parse(string)
|
||||
parsed.should eq expected
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::Mention do
|
||||
describe ".parse" do
|
||||
it_parses_message(
|
||||
"<@123><@!456>",
|
||||
into: [
|
||||
Discord::Mention::User.new(123_u64, 0, 6),
|
||||
Discord::Mention::User.new(456_u64, 6, 7),
|
||||
]
|
||||
)
|
||||
|
||||
it_parses_message(
|
||||
"<@&123>",
|
||||
into: [Discord::Mention::Role.new(123_u64, 0, 6)])
|
||||
|
||||
it_parses_message(
|
||||
"<#123>",
|
||||
into: [Discord::Mention::Channel.new(123_u64, 0, 6)])
|
||||
|
||||
it_parses_message(
|
||||
"<:foo:123><a:bar:456>",
|
||||
into: [
|
||||
Discord::Mention::Emoji.new(false, "foo", 123_u64, 0, 10),
|
||||
Discord::Mention::Emoji.new(true, "bar", 456_u64, 10, 11),
|
||||
]
|
||||
)
|
||||
|
||||
it_parses_message(
|
||||
"@everyone@here",
|
||||
into: [
|
||||
Discord::Mention::Everyone.new(0),
|
||||
Discord::Mention::Here.new(9),
|
||||
]
|
||||
)
|
||||
|
||||
context "with invalid mentions" do
|
||||
it_parses_message(
|
||||
"<<@123<@?123><#123<:foo:123<b:foo:123><@abc><@!abc>",
|
||||
into: [] of Discord::Mention)
|
||||
end
|
||||
end
|
||||
end
|
||||
10
Crystal/First-Program/lib/discordcr/spec/rest_spec.cr
Normal file
10
Crystal/First-Program/lib/discordcr/spec/rest_spec.cr
Normal file
@@ -0,0 +1,10 @@
|
||||
require "./spec_helper"
|
||||
|
||||
describe Discord::REST do
|
||||
describe "#encode_tuple" do
|
||||
it "doesn't emit null values" do
|
||||
client = Discord::Client.new("foo", 0_u64)
|
||||
client.encode_tuple(foo: ["bar", 1, 2], baz: nil).should eq(%({"foo":["bar",1,2]}))
|
||||
end
|
||||
end
|
||||
end
|
||||
2
Crystal/First-Program/lib/discordcr/spec/spec_helper.cr
Normal file
2
Crystal/First-Program/lib/discordcr/spec/spec_helper.cr
Normal file
@@ -0,0 +1,2 @@
|
||||
require "spec"
|
||||
require "../src/discordcr"
|
||||
5
Crystal/First-Program/lib/discordcr/src/discordcr.cr
Normal file
5
Crystal/First-Program/lib/discordcr/src/discordcr.cr
Normal file
@@ -0,0 +1,5 @@
|
||||
require "./discordcr/*"
|
||||
|
||||
module Discord
|
||||
# TODO Put your code here
|
||||
end
|
||||
252
Crystal/First-Program/lib/discordcr/src/discordcr/cache.cr
Normal file
252
Crystal/First-Program/lib/discordcr/src/discordcr/cache.cr
Normal file
@@ -0,0 +1,252 @@
|
||||
require "./mappings/*"
|
||||
|
||||
module Discord
|
||||
# A cache is a utility class that stores various kinds of Discord objects,
|
||||
# like `User`s, `Role`s etc. Its purpose is to reduce both the load on
|
||||
# Discord's servers and reduce the latency caused by having to do an API call.
|
||||
# It is recommended to use caching for bots that interact heavily with
|
||||
# Discord-provided data, like for example administration bots, as opposed to
|
||||
# bots that only interact by sending and receiving messages. For that latter
|
||||
# kind, caching is usually even counter-productive as it only unnecessarily
|
||||
# increases memory usage.
|
||||
#
|
||||
# Caching can either be used standalone, in a purely REST-based way:
|
||||
# ```
|
||||
# client = Discord::Client.new(token: "Bot token", client_id: 123_u64)
|
||||
# cache = Discord::Cache.new(client)
|
||||
#
|
||||
# puts cache.resolve_user(66237334693085184) # will perform API call
|
||||
# puts cache.resolve_user(66237334693085184) # will not perform an API call, as the data is now cached
|
||||
# ```
|
||||
#
|
||||
# It can also be integrated more deeply into a `Client` (specifically one that
|
||||
# uses a gateway connection) to reduce cache misses even more by automatically
|
||||
# caching data received over the gateway:
|
||||
# ```
|
||||
# client = Discord::Client.new(token: "Bot token", client_id: 123_u64)
|
||||
# cache = Discord::Cache.new(client)
|
||||
# client.cache = cache # Integrate the cache into the client
|
||||
# ```
|
||||
#
|
||||
# Note that if a cache is *not* used this way, its data will slowly go out of
|
||||
# sync with Discord, and unless it is used in an environment with few changes
|
||||
# likely to occur, a client without a gateway connection should probably
|
||||
# refrain from caching at all.
|
||||
class Cache
|
||||
# A map of cached users. These aren't necessarily all the users in servers
|
||||
# the bot has access to, but rather all the users that have been seen by
|
||||
# the bot in the past (and haven't been deleted by means of `delete_user`).
|
||||
getter users
|
||||
|
||||
# A map of cached channels, i. e. all channels on all servers the bot is on,
|
||||
# as well as all DM channels.
|
||||
getter channels
|
||||
|
||||
# A map of guilds (servers) the bot is on. Doesn't ignore guilds temporarily
|
||||
# deleted due to an outage; so if an outage is going on right now the
|
||||
# affected guilds would be missing here too.
|
||||
getter guilds
|
||||
|
||||
# A double map of members on servers, represented as {guild ID => {user ID
|
||||
# => member}}. Will only contain previously and currently online members as
|
||||
# well as all members that have been chunked (see
|
||||
# `Client#request_guild_members`).
|
||||
getter members
|
||||
|
||||
# A map of all roles on servers the bot is on. Does not discriminate by
|
||||
# guild, as role IDs are unique even across guilds.
|
||||
getter roles
|
||||
|
||||
# Mapping of users to the respective DM channels the bot has open with them,
|
||||
# represented as {user ID => channel ID}.
|
||||
getter dm_channels
|
||||
|
||||
# Mapping of guilds to the roles on them, represented as {guild ID =>
|
||||
# [role IDs]}.
|
||||
getter guild_roles
|
||||
|
||||
# Mapping of guilds to the channels on them, represented as {guild ID =>
|
||||
# [channel IDs]}.
|
||||
getter guild_channels
|
||||
|
||||
# Creates a new cache with a *client* that requests (in case of cache
|
||||
# misses) should be done on.
|
||||
def initialize(@client : Client)
|
||||
@users = Hash(UInt64, User).new
|
||||
@channels = Hash(UInt64, Channel).new
|
||||
@guilds = Hash(UInt64, Guild).new
|
||||
@members = Hash(UInt64, Hash(UInt64, GuildMember)).new
|
||||
@roles = Hash(UInt64, Role).new
|
||||
|
||||
@dm_channels = Hash(UInt64, UInt64).new
|
||||
|
||||
@guild_roles = Hash(UInt64, Array(UInt64)).new
|
||||
@guild_channels = Hash(UInt64, Array(UInt64)).new
|
||||
end
|
||||
|
||||
# Resolves a user by its *ID*. If the requested object is not cached, it
|
||||
# will do an API call.
|
||||
def resolve_user(id : UInt64) : User
|
||||
@users.fetch(id) { @users[id] = @client.get_user(id) }
|
||||
end
|
||||
|
||||
# Resolves a channel by its *ID*. If the requested object is not cached, it
|
||||
# will do an API call.
|
||||
def resolve_channel(id : UInt64) : Channel
|
||||
@channels.fetch(id) { @channels[id] = @client.get_channel(id) }
|
||||
end
|
||||
|
||||
# Resolves a guild by its *ID*. If the requested object is not cached, it
|
||||
# will do an API call.
|
||||
def resolve_guild(id : UInt64) : Guild
|
||||
@guilds.fetch(id) { @guilds[id] = @client.get_guild(id) }
|
||||
end
|
||||
|
||||
# Resolves a member by the *guild_id* of the guild the member is on, and the
|
||||
# *user_id* of the member itself. An API request will be performed if the
|
||||
# object is not cached.
|
||||
def resolve_member(guild_id : UInt64, user_id : UInt64) : GuildMember
|
||||
local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new
|
||||
local_members.fetch(user_id) { local_members[user_id] = @client.get_guild_member(guild_id, user_id) }
|
||||
end
|
||||
|
||||
# Resolves a role by its *ID*. No API request will be performed if the role
|
||||
# is not cached, because there is no endpoint for individual roles; however
|
||||
# all roles should be cached at all times so it won't be a problem.
|
||||
def resolve_role(id : UInt64) : Role
|
||||
@roles[id] # There is no endpoint for getting an individual role, so we will have to ignore that case for now.
|
||||
end
|
||||
|
||||
# Resolves the ID of a DM channel with a particular user by the recipient's
|
||||
# *recipient_id*. If there is no such channel cached, one will be created.
|
||||
def resolve_dm_channel(recipient_id : UInt64) : UInt64
|
||||
@dm_channels.fetch(recipient_id) do
|
||||
channel = @client.create_dm(recipient_id)
|
||||
cache(Channel.new(channel))
|
||||
channel.id
|
||||
end
|
||||
end
|
||||
|
||||
# Resolves the current user's profile. Requires no parameters since the
|
||||
# endpoint has none either. If there is a gateway connection this should
|
||||
# always be cached.
|
||||
def resolve_current_user : User
|
||||
@current_user ||= @client.get_current_user
|
||||
end
|
||||
|
||||
# Deletes a user from the cache given its *ID*.
|
||||
def delete_user(id : UInt64)
|
||||
@users.delete(id)
|
||||
end
|
||||
|
||||
# Deletes a channel from the cache given its *ID*.
|
||||
def delete_channel(id : UInt64)
|
||||
@channels.delete(id)
|
||||
end
|
||||
|
||||
# Deletes a guild from the cache given its *ID*.
|
||||
def delete_guild(id : UInt64)
|
||||
@guilds.delete(id)
|
||||
end
|
||||
|
||||
# Deletes a member from the cache given its *user_id* and the *guild_id* it
|
||||
# is on.
|
||||
def delete_member(guild_id : UInt64, user_id : UInt64)
|
||||
@members[guild_id]?.try &.delete(user_id)
|
||||
end
|
||||
|
||||
# Deletes a role from the cache given its *ID*.
|
||||
def delete_role(id : UInt64)
|
||||
@roles.delete(id)
|
||||
end
|
||||
|
||||
# Deletes a DM channel with a particular user given the *recipient_id*.
|
||||
def delete_dm_channel(recipient_id : UInt64)
|
||||
@dm_channels.delete(recipient_id)
|
||||
end
|
||||
|
||||
# Deletes the current user from the cache, if that will ever be necessary.
|
||||
def delete_current_user
|
||||
@current_user = nil
|
||||
end
|
||||
|
||||
# Adds a specific *user* to the cache.
|
||||
def cache(user : User)
|
||||
@users[user.id] = user
|
||||
end
|
||||
|
||||
# Adds a specific *channel* to the cache.
|
||||
def cache(channel : Channel)
|
||||
@channels[channel.id] = channel
|
||||
end
|
||||
|
||||
# Adds a specific *guild* to the cache.
|
||||
def cache(guild : Guild)
|
||||
@guilds[guild.id] = guild
|
||||
end
|
||||
|
||||
# Adds a specific *member* to the cache, given the *guild_id* it is on.
|
||||
def cache(member : GuildMember, guild_id : UInt64)
|
||||
local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new
|
||||
local_members[member.user.id] = member
|
||||
end
|
||||
|
||||
# Adds a specific *role* to the cache.
|
||||
def cache(role : Role)
|
||||
@roles[role.id] = role
|
||||
end
|
||||
|
||||
# Adds a particular DM channel to the cache, given the *channel_id* and the
|
||||
# *recipient_id*.
|
||||
def cache_dm_channel(channel_id : UInt64, recipient_id : UInt64)
|
||||
@dm_channels[recipient_id] = channel_id
|
||||
end
|
||||
|
||||
# Caches the current user.
|
||||
def cache_current_user(@current_user : User); end
|
||||
|
||||
# Adds multiple *members* at once to the cache, given the *guild_id* they
|
||||
# all share. This method exists to slightly reduce the overhead of
|
||||
# processing chunks; outside of that it is likely not of much use.
|
||||
def cache_multiple_members(members : Array(GuildMember), guild_id : UInt64)
|
||||
local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new
|
||||
members.each do |member|
|
||||
local_members[member.user.id] = member
|
||||
end
|
||||
end
|
||||
|
||||
# Returns all roles of a guild, identified by its *guild_id*.
|
||||
def guild_roles(guild_id : UInt64) : Array(UInt64)
|
||||
@guild_roles[guild_id]
|
||||
end
|
||||
|
||||
# Marks a role, identified by the *role_id*, as belonging to a particular
|
||||
# guild, identified by the *guild_id*.
|
||||
def add_guild_role(guild_id : UInt64, role_id : UInt64)
|
||||
local_roles = @guild_roles[guild_id] ||= [] of UInt64
|
||||
local_roles << role_id
|
||||
end
|
||||
|
||||
# Marks a role as not belonging to a particular guild anymore.
|
||||
def remove_guild_role(guild_id : UInt64, role_id : UInt64)
|
||||
@guild_roles[guild_id]?.try { |local_roles| local_roles.delete(role_id) }
|
||||
end
|
||||
|
||||
# Returns all channels of a guild, identified by its *guild_id*.
|
||||
def guild_channels(guild_id : UInt64) : Array(UInt64)
|
||||
@guild_channels[guild_id]
|
||||
end
|
||||
|
||||
# Marks a channel, identified by the *channel_id*, as belonging to a particular
|
||||
# guild, identified by the *guild_id*.
|
||||
def add_guild_channel(guild_id : UInt64, channel_id : UInt64)
|
||||
local_channels = @guild_channels[guild_id] ||= [] of UInt64
|
||||
local_channels << channel_id
|
||||
end
|
||||
|
||||
# Marks a channel as not belonging to a particular guild anymore.
|
||||
def remove_guild_channel(guild_id : UInt64, channel_id : UInt64)
|
||||
@guild_channels[guild_id]?.try { |local_channels| local_channels.delete(channel_id) }
|
||||
end
|
||||
end
|
||||
end
|
||||
843
Crystal/First-Program/lib/discordcr/src/discordcr/client.cr
Normal file
843
Crystal/First-Program/lib/discordcr/src/discordcr/client.cr
Normal file
@@ -0,0 +1,843 @@
|
||||
require "json"
|
||||
|
||||
require "./logger"
|
||||
require "./rest"
|
||||
require "./cache"
|
||||
|
||||
module Discord
|
||||
# The basic client class that is used to connect to Discord, send REST
|
||||
# requests, or send or receive gateway messages. It is required for doing any
|
||||
# sort of interaction with Discord.
|
||||
#
|
||||
# A new simple client that does nothing yet can be created like this:
|
||||
# ```
|
||||
# client = Discord::Client.new(token: "Bot token", client_id: 123_u64)
|
||||
# ```
|
||||
#
|
||||
# With this client, REST requests can now be sent. (See the `Discord::REST`
|
||||
# module.) A gateway connection can also be started using the `#run` method.
|
||||
class Client
|
||||
include REST
|
||||
|
||||
# If this is set to any `Cache`, the data in the cache will be updated as
|
||||
# the client receives the corresponding gateway dispatches.
|
||||
property cache : Cache?
|
||||
|
||||
# The internal *session* the client is currently using, necessary to create
|
||||
# a voice client, for example
|
||||
getter session : Gateway::Session?
|
||||
|
||||
# The internal websocket the client is currently using
|
||||
getter websocket : Discord::WebSocket do
|
||||
initialize_websocket
|
||||
end
|
||||
|
||||
@backoff : Float64
|
||||
|
||||
# Default analytics properties sent in IDENTIFY
|
||||
DEFAULT_PROPERTIES = Gateway::IdentifyProperties.new(
|
||||
os: "Crystal",
|
||||
browser: "discordcr",
|
||||
device: "discordcr",
|
||||
referrer: "",
|
||||
referring_domain: ""
|
||||
)
|
||||
|
||||
# Creates a new bot with the given *token* and optionally the *client_id*.
|
||||
# Both of these things can be found on a bot's application page; the token
|
||||
# will need to be revealed using the "click to reveal" thing on the token
|
||||
# (**not** the OAuth2 secret!)
|
||||
#
|
||||
# If the *shard* key is set, the gateway will operate in sharded mode. This
|
||||
# means that this client's gateway connection will only receive packets from
|
||||
# a part of the guilds the bot is connected to. See
|
||||
# [here](https://discordapp.com/developers/docs/topics/gateway#sharding)
|
||||
# for more information.
|
||||
#
|
||||
# The *large_threshold* defines the minimum member count that, if a guild
|
||||
# has at least that many members, the client will only receive online
|
||||
# members in GUILD_CREATE. The default value 100 is what the Discord client
|
||||
# uses; the maximum value is 250. To get a list of offline members as well,
|
||||
# the `#request_guild_members` method can be used.
|
||||
#
|
||||
# If *compress* is true, packets will be sent in a compressed manner.
|
||||
# discordcr doesn't currently handle packet decompression, so until that is
|
||||
# implemented, setting this to true will cause the client to fail to parse
|
||||
# anything.
|
||||
#
|
||||
# The *properties* define what values are sent to Discord as analytics
|
||||
# properties. It's not recommended to change these from the default values,
|
||||
# but if you desire to do so, you can.
|
||||
def initialize(@token : String, @client_id : UInt64? = nil,
|
||||
@shard : Gateway::ShardKey? = nil,
|
||||
@large_threshold : Int32 = 100,
|
||||
@compress : Bool = false,
|
||||
@properties : Gateway::IdentifyProperties = DEFAULT_PROPERTIES)
|
||||
@backoff = 1.0
|
||||
|
||||
# Set some default value for the heartbeat interval. This should never
|
||||
# actually be used as a delay between heartbeats because it will have an
|
||||
# actual value before heartbeating starts.
|
||||
@heartbeat_interval = 1000_u32
|
||||
@send_heartbeats = false
|
||||
|
||||
# Initially, this flag is set to true so the client doesn't immediately
|
||||
# try to reconnect at the next heartbeat.
|
||||
@last_heartbeat_acked = true
|
||||
|
||||
# If the websocket is closed, whether we should immediately try and reconnect
|
||||
@should_reconnect = true
|
||||
|
||||
setup_heartbeats
|
||||
end
|
||||
|
||||
# Returns this client's ID as provided in its associated Oauth2 application.
|
||||
# A getter for @client_id, this will make a REST call to obtain it
|
||||
# if it was not provided in the initializer.
|
||||
def client_id
|
||||
@client_id ||= get_oauth2_application.id
|
||||
end
|
||||
|
||||
# Connects this client to the gateway. This is required if the bot needs to
|
||||
# do anything beyond making REST API calls. Calling this method will block
|
||||
# execution until the bot is forcibly stopped.
|
||||
def run
|
||||
loop do
|
||||
begin
|
||||
websocket.run
|
||||
rescue ex
|
||||
LOGGER.error <<-LOG
|
||||
Received exception from WebSocket#run:
|
||||
#{ex.inspect_with_backtrace}
|
||||
LOG
|
||||
end
|
||||
|
||||
@send_heartbeats = false
|
||||
@session.try &.suspend
|
||||
|
||||
break unless @should_reconnect
|
||||
|
||||
wait_for_reconnect
|
||||
|
||||
LOGGER.info "Reconnecting"
|
||||
@websocket = initialize_websocket
|
||||
end
|
||||
end
|
||||
|
||||
# Closes the gateway connection permanently
|
||||
def stop(message = nil)
|
||||
@should_reconnect = false
|
||||
websocket.close(message)
|
||||
end
|
||||
|
||||
# Separate method to wait an ever-increasing amount of time before reconnecting after being disconnected in an
|
||||
# unexpected way
|
||||
def wait_for_reconnect
|
||||
# Wait before reconnecting so we don't spam Discord's servers.
|
||||
LOGGER.debug "Attempting to reconnect in #{@backoff} seconds."
|
||||
sleep @backoff.seconds
|
||||
|
||||
# Calculate new backoff
|
||||
@backoff = 1.0 if @backoff < 1.0
|
||||
@backoff *= 1.5
|
||||
@backoff = 115 + (rand * 10) if @backoff > 120 # Cap the backoff at 120 seconds and then add some random jitter
|
||||
end
|
||||
|
||||
private def initialize_websocket : Discord::WebSocket
|
||||
url = URI.parse(get_gateway.url)
|
||||
websocket = Discord::WebSocket.new(
|
||||
host: url.host.not_nil!,
|
||||
path: "#{url.path}/?encoding=json&v=6",
|
||||
port: 443,
|
||||
tls: true
|
||||
)
|
||||
|
||||
websocket.on_message(&->on_message(Discord::WebSocket::Packet))
|
||||
websocket.on_close(&->on_close(String))
|
||||
|
||||
websocket
|
||||
end
|
||||
|
||||
private def on_close(message : String)
|
||||
# TODO: make more sophisticated
|
||||
LOGGER.warn "Closed with: " + message
|
||||
|
||||
@send_heartbeats = false
|
||||
@session.try &.suspend
|
||||
nil
|
||||
end
|
||||
|
||||
OP_DISPATCH = 0
|
||||
OP_HEARTBEAT = 1
|
||||
OP_IDENTIFY = 2
|
||||
OP_STATUS_UPDATE = 3
|
||||
OP_VOICE_STATE_UPDATE = 4
|
||||
OP_VOICE_SERVER_PING = 5
|
||||
OP_RESUME = 6
|
||||
OP_RECONNECT = 7
|
||||
OP_REQUEST_GUILD_MEMBERS = 8
|
||||
OP_INVALID_SESSION = 9
|
||||
OP_HELLO = 10
|
||||
OP_HEARTBEAT_ACK = 11
|
||||
|
||||
private def on_message(packet : Discord::WebSocket::Packet)
|
||||
spawn do
|
||||
begin
|
||||
case packet.opcode
|
||||
when OP_HELLO
|
||||
payload = Gateway::HelloPayload.from_json(packet.data)
|
||||
handle_hello(payload.heartbeat_interval)
|
||||
when OP_DISPATCH
|
||||
handle_dispatch(packet.event_type.not_nil!, packet.data)
|
||||
when OP_RECONNECT
|
||||
handle_reconnect
|
||||
when OP_INVALID_SESSION
|
||||
handle_invalid_session
|
||||
when OP_HEARTBEAT
|
||||
# We got a received heartbeat, reply with the same sequence
|
||||
LOGGER.debug "Heartbeat received"
|
||||
websocket.send({op: 1, d: packet.sequence}.to_json)
|
||||
when OP_HEARTBEAT_ACK
|
||||
handle_heartbeat_ack
|
||||
else
|
||||
LOGGER.warn "Unsupported payload: #{packet}"
|
||||
end
|
||||
rescue ex : JSON::ParseException
|
||||
LOGGER.error <<-LOG
|
||||
An exception occurred during message parsing! Please report this.
|
||||
#{ex.inspect_with_backtrace}
|
||||
(pertaining to previous exception) Raised with packet:
|
||||
#{packet}
|
||||
LOG
|
||||
rescue ex
|
||||
LOGGER.error <<-LOG
|
||||
A miscellaneous exception occurred during message handling.
|
||||
#{ex.inspect_with_backtrace}
|
||||
LOG
|
||||
end
|
||||
|
||||
# Set the sequence to confirm that we have handled this packet, in case
|
||||
# we need to resume
|
||||
seq = packet.sequence
|
||||
@session.try &.sequence = seq if seq
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
# Injects a *packet* into the packet handler.
|
||||
def inject(packet : Discord::WebSocket::Packet)
|
||||
on_message(packet)
|
||||
end
|
||||
|
||||
private def handle_hello(heartbeat_interval)
|
||||
@heartbeat_interval = heartbeat_interval
|
||||
@send_heartbeats = true
|
||||
@last_heartbeat_acked = true
|
||||
|
||||
# If it seems like we can resume, we will - worst case we get an op9
|
||||
if @session.try &.should_resume?
|
||||
resume
|
||||
else
|
||||
identify
|
||||
end
|
||||
end
|
||||
|
||||
private def setup_heartbeats
|
||||
spawn do
|
||||
loop do
|
||||
if @send_heartbeats
|
||||
unless @last_heartbeat_acked
|
||||
LOGGER.warn "Last heartbeat not acked, reconnecting"
|
||||
|
||||
# Give the new connection another chance by resetting the last
|
||||
# acked flag; otherwise it would try to reconnect again at the
|
||||
# first heartbeat
|
||||
@last_heartbeat_acked = true
|
||||
|
||||
reconnect(should_suspend: true)
|
||||
next
|
||||
end
|
||||
|
||||
LOGGER.debug "Sending heartbeat"
|
||||
|
||||
begin
|
||||
seq = @session.try &.sequence || 0
|
||||
websocket.send({op: 1, d: seq}.to_json)
|
||||
@last_heartbeat_acked = false
|
||||
rescue ex
|
||||
LOGGER.error <<-LOG
|
||||
Heartbeat failed!
|
||||
#{ex.inspect_with_backtrace}
|
||||
LOG
|
||||
end
|
||||
end
|
||||
|
||||
sleep @heartbeat_interval.milliseconds
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private def identify
|
||||
if shard = @shard
|
||||
shard_tuple = shard.values
|
||||
end
|
||||
|
||||
packet = Gateway::IdentifyPacket.new(@token, @properties, @compress, @large_threshold, shard_tuple)
|
||||
websocket.send(packet.to_json)
|
||||
end
|
||||
|
||||
# Sends a resume packet from the given *sequence* number, or alternatively
|
||||
# the current session's last received sequence if none is given. This will
|
||||
# make Discord replay all events since that sequence.
|
||||
def resume(sequence : Int64? = nil)
|
||||
session = @session.not_nil!
|
||||
sequence ||= session.sequence
|
||||
|
||||
packet = Gateway::ResumePacket.new(@token, session.session_id, sequence)
|
||||
websocket.send(packet.to_json)
|
||||
end
|
||||
|
||||
# Reconnects the websocket connection entirely. If *should_suspend* is set,
|
||||
# the session will be suspended, which means (unless other factors prevent
|
||||
# this) that the session will be resumed after reconnection. If
|
||||
# *backoff_override* is set to anything other than `nil`, the reconnection
|
||||
# backoff will not use the standard formula and instead wait the value
|
||||
# provided; use `0.0` to skip waiting entirely.
|
||||
def reconnect(should_suspend = false, backoff_override = nil)
|
||||
@backoff = backoff_override if backoff_override
|
||||
@send_heartbeats = false
|
||||
websocket.close
|
||||
|
||||
# Suspend the session so we resume, if desired
|
||||
@session.try &.suspend if should_suspend
|
||||
end
|
||||
|
||||
# Sends a status update to Discord. The *status* can be `"online"`,
|
||||
# `"idle"`, `"dnd"`, or `"invisible"`. Setting the *game* to a `GamePlaying`
|
||||
# object makes the bot appear as playing some game on Discord. *since* and
|
||||
# *afk* can be used in conjunction to signify to Discord that the status
|
||||
# change is due to inactivity on the bot's part – this fulfills no cosmetic
|
||||
# purpose.
|
||||
def status_update(status : String? = nil, game : GamePlaying? = nil, afk : Bool = false, since : Int64? = nil)
|
||||
packet = Gateway::StatusUpdatePacket.new(status, game, afk, since)
|
||||
websocket.send(packet.to_json)
|
||||
end
|
||||
|
||||
# Sends a voice state update to Discord. This will create a new voice
|
||||
# connection on the given *guild_id* and *channel_id*, update an existing
|
||||
# one with new *self_mute* and *self_deaf* status, or disconnect from voice
|
||||
# if the *channel_id* is `nil`.
|
||||
#
|
||||
# discordcr doesn't support sending or receiving any data from voice
|
||||
# connections yet - this will have to be done externally until that happens.
|
||||
def voice_state_update(guild_id : UInt64, channel_id : UInt64?, self_mute : Bool, self_deaf : Bool)
|
||||
packet = Gateway::VoiceStateUpdatePacket.new(guild_id, channel_id, self_mute, self_deaf)
|
||||
websocket.send(packet.to_json)
|
||||
end
|
||||
|
||||
# Requests a full list of members to be sent for a specific guild. This is
|
||||
# necessary to get the entire members list for guilds considered large (what
|
||||
# is considered large can be changed using the large_threshold parameter
|
||||
# in `#initialize`).
|
||||
#
|
||||
# The list will arrive in the form of GUILD_MEMBERS_CHUNK dispatch events,
|
||||
# which can be listened to using `#on_guild_members_chunk`. If a cache
|
||||
# is set up, arriving members will be cached automatically.
|
||||
def request_guild_members(guild_id : UInt64, query : String = "", limit : Int32 = 0)
|
||||
packet = Gateway::RequestGuildMembersPacket.new(guild_id, query, limit)
|
||||
websocket.send(packet.to_json)
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
macro call_event(name, payload)
|
||||
@on_{{name}}_handlers.try &.each do |handler|
|
||||
begin
|
||||
handler.call({{payload}})
|
||||
rescue ex
|
||||
LOGGER.error <<-LOG
|
||||
An exception occurred in a user-defined event handler!
|
||||
#{ex.inspect_with_backtrace}
|
||||
LOG
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
macro cache(object)
|
||||
@cache.try &.cache {{object}}
|
||||
end
|
||||
|
||||
private def handle_dispatch(type, data)
|
||||
call_event dispatch, {type, data}
|
||||
|
||||
case type
|
||||
when "READY"
|
||||
payload = Gateway::ReadyPayload.from_json(data)
|
||||
|
||||
@session = Gateway::Session.new(payload.session_id)
|
||||
|
||||
# Reset the backoff, because READY means we successfully achieved a
|
||||
# connection and don't have to wait next time
|
||||
@backoff = 1.0
|
||||
|
||||
@cache.try &.cache_current_user(payload.user)
|
||||
|
||||
payload.private_channels.each do |channel|
|
||||
cache Channel.new(channel)
|
||||
|
||||
if channel.type == 1 # DM channel, not group
|
||||
recipient_id = channel.recipients[0].id
|
||||
@cache.try &.cache_dm_channel(channel.id, recipient_id)
|
||||
end
|
||||
end
|
||||
|
||||
LOGGER.info "Received READY, v: #{payload.v}"
|
||||
call_event ready, payload
|
||||
when "RESUMED"
|
||||
# RESUMED also means a connection was achieved, so reset the
|
||||
# reconnection backoff here too
|
||||
@backoff = 1.0
|
||||
|
||||
payload = Gateway::ResumedPayload.from_json(data)
|
||||
call_event resumed, payload
|
||||
when "CHANNEL_CREATE"
|
||||
payload = Channel.from_json(data)
|
||||
|
||||
cache payload
|
||||
guild_id = payload.guild_id
|
||||
recipients = payload.recipients
|
||||
if guild_id
|
||||
@cache.try &.add_guild_channel(guild_id, payload.id)
|
||||
elsif payload.type == 1 && recipients
|
||||
@cache.try &.cache_dm_channel(payload.id, recipients[0].id)
|
||||
end
|
||||
|
||||
call_event channel_create, payload
|
||||
when "CHANNEL_UPDATE"
|
||||
payload = Channel.from_json(data)
|
||||
|
||||
cache payload
|
||||
|
||||
call_event channel_update, payload
|
||||
when "CHANNEL_DELETE"
|
||||
payload = Channel.from_json(data)
|
||||
|
||||
@cache.try &.delete_channel(payload.id)
|
||||
guild_id = payload.guild_id
|
||||
@cache.try &.remove_guild_channel(guild_id, payload.id) if guild_id
|
||||
|
||||
call_event channel_delete, payload
|
||||
when "CHANNEL_PINS_UPDATE"
|
||||
payload = Gateway::ChannelPinsUpdatePayload.from_json(data)
|
||||
call_event channel_pins_update, payload
|
||||
when "GUILD_CREATE"
|
||||
payload = Gateway::GuildCreatePayload.from_json(data)
|
||||
|
||||
guild = Guild.new(payload)
|
||||
cache guild
|
||||
|
||||
payload.channels.each do |channel|
|
||||
channel.guild_id = guild.id
|
||||
cache channel
|
||||
@cache.try &.add_guild_channel(guild.id, channel.id)
|
||||
end
|
||||
|
||||
payload.roles.each do |role|
|
||||
cache role
|
||||
@cache.try &.add_guild_role(guild.id, role.id)
|
||||
end
|
||||
|
||||
payload.members.each do |member|
|
||||
cache member.user
|
||||
@cache.try &.cache(member, guild.id)
|
||||
end
|
||||
|
||||
call_event guild_create, payload
|
||||
when "GUILD_UPDATE"
|
||||
payload = Guild.from_json(data)
|
||||
|
||||
cache payload
|
||||
|
||||
call_event guild_update, payload
|
||||
when "GUILD_DELETE"
|
||||
payload = Gateway::GuildDeletePayload.from_json(data)
|
||||
|
||||
@cache.try &.delete_guild(payload.id)
|
||||
|
||||
call_event guild_delete, payload
|
||||
when "GUILD_BAN_ADD"
|
||||
payload = Gateway::GuildBanPayload.from_json(data)
|
||||
call_event guild_ban_add, payload
|
||||
when "GUILD_BAN_REMOVE"
|
||||
payload = Gateway::GuildBanPayload.from_json(data)
|
||||
call_event guild_ban_remove, payload
|
||||
when "GUILD_EMOJIS_UPDATE"
|
||||
payload = Gateway::GuildEmojiUpdatePayload.from_json(data)
|
||||
call_event guild_emoji_update, payload
|
||||
when "GUILD_INTEGRATIONS_UPDATE"
|
||||
payload = Gateway::GuildIntegrationsUpdatePayload.from_json(data)
|
||||
call_event guild_integrations_update, payload
|
||||
when "GUILD_MEMBER_ADD"
|
||||
payload = Gateway::GuildMemberAddPayload.from_json(data)
|
||||
|
||||
cache payload.user
|
||||
member = GuildMember.new(payload)
|
||||
@cache.try &.cache(member, payload.guild_id)
|
||||
|
||||
call_event guild_member_add, payload
|
||||
when "GUILD_MEMBER_UPDATE"
|
||||
payload = Gateway::GuildMemberUpdatePayload.from_json(data)
|
||||
|
||||
cache payload.user
|
||||
@cache.try do |c|
|
||||
member = c.resolve_member(payload.guild_id, payload.user.id)
|
||||
new_member = GuildMember.new(member, payload.roles, payload.nick)
|
||||
c.cache(new_member, payload.guild_id)
|
||||
end
|
||||
|
||||
call_event guild_member_update, payload
|
||||
when "GUILD_MEMBER_REMOVE"
|
||||
payload = Gateway::GuildMemberRemovePayload.from_json(data)
|
||||
|
||||
cache payload.user
|
||||
@cache.try &.delete_member(payload.guild_id, payload.user.id)
|
||||
|
||||
call_event guild_member_remove, payload
|
||||
when "GUILD_MEMBERS_CHUNK"
|
||||
payload = Gateway::GuildMembersChunkPayload.from_json(data)
|
||||
|
||||
@cache.try &.cache_multiple_members(payload.members, payload.guild_id)
|
||||
|
||||
call_event guild_members_chunk, payload
|
||||
when "GUILD_ROLE_CREATE"
|
||||
payload = Gateway::GuildRolePayload.from_json(data)
|
||||
|
||||
cache payload.role
|
||||
@cache.try &.add_guild_role(payload.guild_id, payload.role.id)
|
||||
|
||||
call_event guild_role_create, payload
|
||||
when "GUILD_ROLE_UPDATE"
|
||||
payload = Gateway::GuildRolePayload.from_json(data)
|
||||
|
||||
cache payload.role
|
||||
|
||||
call_event guild_role_update, payload
|
||||
when "GUILD_ROLE_DELETE"
|
||||
payload = Gateway::GuildRoleDeletePayload.from_json(data)
|
||||
|
||||
@cache.try &.delete_role(payload.role_id)
|
||||
@cache.try &.remove_guild_role(payload.guild_id, payload.role_id)
|
||||
|
||||
call_event guild_role_delete, payload
|
||||
when "MESSAGE_CREATE"
|
||||
payload = Message.from_json(data)
|
||||
LOGGER.debug "Received message with content #{payload.content}"
|
||||
call_event message_create, payload
|
||||
when "MESSAGE_REACTION_ADD"
|
||||
payload = Gateway::MessageReactionPayload.from_json(data)
|
||||
call_event message_reaction_add, payload
|
||||
when "MESSAGE_REACTION_REMOVE"
|
||||
payload = Gateway::MessageReactionPayload.from_json(data)
|
||||
call_event message_reaction_remove, payload
|
||||
when "MESSAGE_REACTION_REMOVE_ALL"
|
||||
payload = Gateway::MessageReactionRemoveAllPayload.from_json(data)
|
||||
call_event message_reaction_remove_all, payload
|
||||
when "MESSAGE_UPDATE"
|
||||
payload = Gateway::MessageUpdatePayload.from_json(data)
|
||||
call_event message_update, payload
|
||||
when "MESSAGE_DELETE"
|
||||
payload = Gateway::MessageDeletePayload.from_json(data)
|
||||
call_event message_delete, payload
|
||||
when "MESSAGE_DELETE_BULK"
|
||||
payload = Gateway::MessageDeleteBulkPayload.from_json(data)
|
||||
call_event message_delete_bulk, payload
|
||||
when "PRESENCE_UPDATE"
|
||||
payload = Gateway::PresenceUpdatePayload.from_json(data)
|
||||
|
||||
if payload.user.full?
|
||||
member = GuildMember.new(payload)
|
||||
@cache.try &.cache(member, payload.guild_id)
|
||||
end
|
||||
|
||||
call_event presence_update, payload
|
||||
when "TYPING_START"
|
||||
payload = Gateway::TypingStartPayload.from_json(data)
|
||||
call_event typing_start, payload
|
||||
when "USER_UPDATE"
|
||||
payload = User.from_json(data)
|
||||
call_event user_update, payload
|
||||
when "VOICE_STATE_UPDATE"
|
||||
payload = VoiceState.from_json(data)
|
||||
call_event voice_state_update, payload
|
||||
when "VOICE_SERVER_UPDATE"
|
||||
payload = Gateway::VoiceServerUpdatePayload.from_json(data)
|
||||
call_event voice_server_update, payload
|
||||
when "WEBHOOKS_UPDATE"
|
||||
payload = Gateway::WebhooksUpdatePayload.from_json(data)
|
||||
call_event webhooks_update, payload
|
||||
else
|
||||
LOGGER.warn "Unsupported dispatch: #{type} #{data}"
|
||||
end
|
||||
end
|
||||
|
||||
private def handle_reconnect
|
||||
# We want the reconnection to happen instantly, and we want a resume to be
|
||||
# attempted, so set the respective parameters
|
||||
reconnect(should_suspend: true, backoff_override: 0.0)
|
||||
end
|
||||
|
||||
private def handle_invalid_session
|
||||
@session.try &.invalidate
|
||||
identify
|
||||
end
|
||||
|
||||
private def handle_heartbeat_ack
|
||||
LOGGER.debug "Heartbeat ACK received"
|
||||
@last_heartbeat_acked = true
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
macro event(name, payload_type)
|
||||
def on_{{name}}(&handler : {{payload_type}} ->)
|
||||
(@on_{{name}}_handlers ||= [] of {{payload_type}} ->) << handler
|
||||
end
|
||||
end
|
||||
|
||||
# Called when the bot receives any kind of dispatch at all, even one that
|
||||
# is otherwise unsupported. This can be useful for statistics, e. g. how
|
||||
# many gateway events are received per second. It can also be useful to
|
||||
# handle new API changes not yet supported by the lib.
|
||||
#
|
||||
# The parameter passed to the event will be a tuple of `{type, data}`, where
|
||||
# `type` is the event type (e.g. "MESSAGE_CREATE") and `data` is the
|
||||
# unprocessed JSON event data.
|
||||
event dispatch, {String, IO::Memory}
|
||||
|
||||
# Called when the bot has successfully initiated a session with Discord. It
|
||||
# marks the point when gateway packets can be set (e. g. `#status_update`).
|
||||
#
|
||||
# Note that this event may be called multiple times over the course of a
|
||||
# bot lifetime, as it is also called when the client reconnects with a new
|
||||
# session.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#ready)
|
||||
event ready, Gateway::ReadyPayload
|
||||
|
||||
# Called when the client has successfully resumed an existing connection
|
||||
# after reconnecting.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#resumed)
|
||||
event resumed, Gateway::ResumedPayload
|
||||
|
||||
# Called when a channel has been created on a server the bot has access to,
|
||||
# or when somebody has started a DM channel with the bot.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#channel-create)
|
||||
event channel_create, Channel
|
||||
|
||||
# Called when a channel's properties are updated, like the name or
|
||||
# permission overwrites.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#channel-update)
|
||||
event channel_update, Channel
|
||||
|
||||
# Called when a channel the bot has access to is deleted. This is not called
|
||||
# for other users closing the DM channel with the bot, only for the bot
|
||||
# closing the DM channel with a user.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#channel-delete)
|
||||
event channel_delete, Channel
|
||||
|
||||
# Called when a channel's pinned messages are updated, where a pin was
|
||||
# either added or removed.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#channel-pins-update)
|
||||
event channel_pins_update, Gateway::ChannelPinsUpdatePayload
|
||||
|
||||
# Called when the bot is added to a guild, a guild unavailable due to an
|
||||
# outage becomes available again, or the guild is streamed after READY.
|
||||
# To verify that it is the first case, you can check the `unavailable`
|
||||
# property in `Gateway::GuildCreatePayload`.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-create)
|
||||
event guild_create, Gateway::GuildCreatePayload
|
||||
|
||||
# Called when a guild's properties, like name or verification level, are
|
||||
# updated.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-update)
|
||||
event guild_update, Guild
|
||||
|
||||
# Called when the bot leaves a guild or a guild becomes unavailable due to
|
||||
# an outage. To verify that it is the former case, you can check the
|
||||
# `unavailable` property.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-delete)
|
||||
event guild_delete, Gateway::GuildDeletePayload
|
||||
|
||||
# Called when somebody is banned from a guild. A `#on_guild_member_remove`
|
||||
# event is also called.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-ban-add)
|
||||
event guild_ban_add, Gateway::GuildBanPayload
|
||||
|
||||
# Called when somebody is unbanned from a guild.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-ban-remove)
|
||||
event guild_ban_remove, Gateway::GuildBanPayload
|
||||
|
||||
# Called when a guild's emoji are updated.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-emoji-update)
|
||||
event guild_emoji_update, Gateway::GuildEmojiUpdatePayload
|
||||
|
||||
# Called when a guild's integrations (Twitch, YouTube) are updated.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-integrations-update)
|
||||
event guild_integrations_update, Gateway::GuildIntegrationsUpdatePayload
|
||||
|
||||
# Called when somebody other than the bot joins a guild.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-member-add)
|
||||
event guild_member_add, Gateway::GuildMemberAddPayload
|
||||
|
||||
# Called when a member object is updated. This happens when somebody
|
||||
# changes their nickname or has their roles changed.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-member-update)
|
||||
event guild_member_update, Gateway::GuildMemberUpdatePayload
|
||||
|
||||
# Called when somebody other than the bot leaves a guild.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-member-remove)
|
||||
event guild_member_remove, Gateway::GuildMemberRemovePayload
|
||||
|
||||
# Called when Discord sends a chunk of member objects after a
|
||||
# `#request_guild_members` call. If a `Cache` is set up, this is handled
|
||||
# automatically.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-members-chunk)
|
||||
event guild_members_chunk, Gateway::GuildMembersChunkPayload
|
||||
|
||||
# Called when a role is created on a guild.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-role-create)
|
||||
event guild_role_create, Gateway::GuildRolePayload
|
||||
|
||||
# Called when a role's properties are updated, for example name or colour.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-role-update)
|
||||
event guild_role_update, Gateway::GuildRolePayload
|
||||
|
||||
# Called when a role is deleted.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-role-delete)
|
||||
event guild_role_delete, Gateway::GuildRoleDeletePayload
|
||||
|
||||
# Called when a message is sent to a channel the bot has access to. This
|
||||
# may be any sort of text channel, no matter private or guild.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#message-create)
|
||||
event message_create, Message
|
||||
|
||||
# Called when a reaction is added to a message.
|
||||
event message_reaction_add, Gateway::MessageReactionPayload
|
||||
|
||||
# Called when a reaction is removed from a message.
|
||||
event message_reaction_remove, Gateway::MessageReactionPayload
|
||||
|
||||
# Called when all reactions are removed at once from a message.
|
||||
event message_reaction_remove_all, Gateway::MessageReactionRemoveAllPayload
|
||||
|
||||
# Called when a message is updated. Most commonly this is done for edited
|
||||
# messages, but the event is also sent when embed information for an
|
||||
# existing message is updated.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#message-update)
|
||||
event message_update, Gateway::MessageUpdatePayload
|
||||
|
||||
# Called when a single message is deleted.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#message-delete)
|
||||
event message_delete, Gateway::MessageDeletePayload
|
||||
|
||||
# Called when multiple messages are deleted at once, due to a bot using the
|
||||
# bulk_delete endpoint.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#message-delete-bulk)
|
||||
event message_delete_bulk, Gateway::MessageDeleteBulkPayload
|
||||
|
||||
# Called when a user updates their status (online/idle/offline), the game
|
||||
# they are playing, or their streaming status. Also called when a user's
|
||||
# properties (user/avatar/discriminator) are changed.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#presence-update)
|
||||
event presence_update, Gateway::PresenceUpdatePayload
|
||||
|
||||
# Called when somebody starts typing in a channel the bot has access to.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#typing-start)
|
||||
event typing_start, Gateway::TypingStartPayload
|
||||
|
||||
# Called when the user properties of the bot itself are changed.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#user-update)
|
||||
event user_update, User
|
||||
|
||||
# Called when somebody joins or leaves a voice channel, moves to a different
|
||||
# one, or is muted/unmuted/deafened/undeafened.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#voice-state-update)
|
||||
event voice_state_update, VoiceState
|
||||
|
||||
# Called when a guild's voice server changes. This event is called with
|
||||
# the current voice server when initially connecting to voice, and it is
|
||||
# called again with the new voice server when the current server fails over
|
||||
# to a new one, or when the guild's voice region changes.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#voice-server-update)
|
||||
event voice_server_update, Gateway::VoiceServerUpdatePayload
|
||||
|
||||
# Sent when a guild channel's webhook is created, updated, or deleted.
|
||||
#
|
||||
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#webhooks-update)
|
||||
event webhooks_update, Gateway::WebhooksUpdatePayload
|
||||
end
|
||||
|
||||
module Gateway
|
||||
alias ShardKey = {shard_id: Int32, num_shards: Int32}
|
||||
|
||||
class Session
|
||||
getter session_id
|
||||
property sequence
|
||||
|
||||
def initialize(@session_id : String)
|
||||
@sequence = 0_i64
|
||||
|
||||
@suspended = false
|
||||
@invalid = false
|
||||
end
|
||||
|
||||
def suspend
|
||||
@suspended = true
|
||||
end
|
||||
|
||||
def suspended? : Bool
|
||||
@suspended
|
||||
end
|
||||
|
||||
def invalidate
|
||||
@invalid = true
|
||||
end
|
||||
|
||||
def invalid? : Bool
|
||||
@invalid
|
||||
end
|
||||
|
||||
def should_resume? : Bool
|
||||
suspended? && !invalid?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
150
Crystal/First-Program/lib/discordcr/src/discordcr/dca.cr
Normal file
150
Crystal/First-Program/lib/discordcr/src/discordcr/dca.cr
Normal file
@@ -0,0 +1,150 @@
|
||||
require "json"
|
||||
|
||||
module Discord
|
||||
# Parser for the DCA file format, a simple wrapper around Opus made
|
||||
# specifically for Discord bots.
|
||||
class DCAParser
|
||||
# Magic string that identifies a DCA1 file
|
||||
DCA1_MAGIC = "DCA1"
|
||||
|
||||
# The parsed metadata, or nil if it could not be parsed.
|
||||
getter metadata : DCA1Mappings::Metadata?
|
||||
|
||||
# Create a new parser. It will read from the given *io*. If *raw* is set,
|
||||
# the file is assumed to be a DCA0 file, without any metadata. If the file's
|
||||
# metadata doesn't conform to the DCA1 specification and *strict_metadata*
|
||||
# is set, then the parsing will fail with an error; if it is not set then
|
||||
# the metadata will silently be `nil`.
|
||||
def initialize(@io : IO, raw = false, @strict_metadata = true)
|
||||
unless raw
|
||||
verify_magic
|
||||
parse_metadata
|
||||
end
|
||||
end
|
||||
|
||||
# Reads the next frame from the IO. If there is nothing left to read, it
|
||||
# will return `nil`.
|
||||
#
|
||||
# If *reuse_buffer* is true, a large buffer will be allocated once and
|
||||
# reused for future calls of this method, reducing the load on the GC and
|
||||
# potentially reusing memory use overall; if it is false, a new buffer of
|
||||
# just the correct size will be allocated every time. Note that if the
|
||||
# buffer is reused, the returned data is only valid until the next call to
|
||||
# `next_frame`.
|
||||
def next_frame(reuse_buffer = false) : Bytes?
|
||||
begin
|
||||
header = @io.read_bytes(Int16, IO::ByteFormat::LittleEndian)
|
||||
raise "Negative frame header (#{header} < 0)" if header < 0
|
||||
|
||||
buf = if reuse_buffer
|
||||
full_buf = @reused_buffer ||= Bytes.new(Int16::MAX)
|
||||
full_buf[0, header]
|
||||
else
|
||||
Bytes.new(header)
|
||||
end
|
||||
|
||||
@io.read_fully(buf)
|
||||
buf
|
||||
rescue IO::EOFError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Continually reads frames from the IO until there are none left. Each frame
|
||||
# is passed to the given *block*.
|
||||
def parse(&block : Bytes ->)
|
||||
loop do
|
||||
buf = next_frame
|
||||
|
||||
if buf
|
||||
block.call(buf)
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private def verify_magic
|
||||
magic = @io.read_string(4)
|
||||
if magic != DCA1_MAGIC
|
||||
raise "File is not a DCA1 file (magic is #{magic}, should be DCA1)"
|
||||
end
|
||||
end
|
||||
|
||||
private def parse_metadata
|
||||
# The header of the metadata part is the four-byte size of the following
|
||||
# metadata payload.
|
||||
metadata_size = @io.read_bytes(Int32, IO::ByteFormat::LittleEndian)
|
||||
metadata_io = IO::Sized.new(@io, read_size: metadata_size)
|
||||
|
||||
begin
|
||||
@metadata = DCA1Mappings::Metadata.from_json(metadata_io)
|
||||
rescue e : JSON::ParseException
|
||||
raise e if @strict_metadata
|
||||
end
|
||||
|
||||
metadata_io.skip_to_end
|
||||
end
|
||||
end
|
||||
|
||||
# Mappings for DCA1 metadata
|
||||
module DCA1Mappings
|
||||
struct Metadata
|
||||
JSON.mapping(
|
||||
dca: DCA,
|
||||
opus: Opus,
|
||||
info: Info?,
|
||||
origin: Origin?,
|
||||
extra: JSON::Any
|
||||
)
|
||||
end
|
||||
|
||||
struct DCA
|
||||
JSON.mapping(
|
||||
version: Int32,
|
||||
tool: Tool
|
||||
)
|
||||
end
|
||||
|
||||
struct Tool
|
||||
JSON.mapping(
|
||||
name: String,
|
||||
version: String,
|
||||
url: String?,
|
||||
author: String?
|
||||
)
|
||||
end
|
||||
|
||||
struct Opus
|
||||
JSON.mapping(
|
||||
mode: String,
|
||||
sample_rate: Int32,
|
||||
frame_size: Int32,
|
||||
abr: Int32?,
|
||||
vbr: Bool,
|
||||
channels: Int32
|
||||
)
|
||||
end
|
||||
|
||||
struct Info
|
||||
JSON.mapping(
|
||||
title: String?,
|
||||
artist: String?,
|
||||
album: String?,
|
||||
genre: String?,
|
||||
comments: String?,
|
||||
cover: String?
|
||||
)
|
||||
end
|
||||
|
||||
struct Origin
|
||||
JSON.mapping(
|
||||
source: String?,
|
||||
abr: Int32?,
|
||||
channels: Int32?,
|
||||
encoding: String?,
|
||||
url: String?
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
71
Crystal/First-Program/lib/discordcr/src/discordcr/errors.cr
Normal file
71
Crystal/First-Program/lib/discordcr/src/discordcr/errors.cr
Normal file
@@ -0,0 +1,71 @@
|
||||
require "http/client/response"
|
||||
require "json"
|
||||
|
||||
module Discord
|
||||
# This exception is raised in `REST#request` when a request fails in general,
|
||||
# without returning a special error response.
|
||||
class StatusException < Exception
|
||||
getter response : HTTP::Client::Response
|
||||
|
||||
def initialize(@response : HTTP::Client::Response)
|
||||
end
|
||||
|
||||
# The status code of the response that caused this exception, for example
|
||||
# 500 or 418.
|
||||
def status_code : Int32
|
||||
@response.status_code
|
||||
end
|
||||
|
||||
# The status message of the response that caused this exception, for example
|
||||
# "Internal Server Error" or "I'm A Teapot".
|
||||
def status_message : String
|
||||
@response.status_message
|
||||
end
|
||||
|
||||
def message
|
||||
"#{@response.status_code} #{@response.status_message}"
|
||||
end
|
||||
|
||||
def to_s(io)
|
||||
io << @response.status_code << " " << @response.status_message
|
||||
end
|
||||
end
|
||||
|
||||
# An API error response.
|
||||
struct APIError
|
||||
JSON.mapping(
|
||||
code: Int32,
|
||||
message: String
|
||||
)
|
||||
end
|
||||
|
||||
# This exception is raised in `REST#request` when a request fails with an
|
||||
# API error response that has a code and a descriptive message.
|
||||
class CodeException < StatusException
|
||||
getter error : APIError
|
||||
|
||||
def initialize(@response : HTTP::Client::Response, @error : APIError)
|
||||
end
|
||||
|
||||
# The API error code that was returned by Discord, for example 20001 or
|
||||
# 50016.
|
||||
def error_code : Int32
|
||||
@error.code
|
||||
end
|
||||
|
||||
# The API error message that was returned by Discord, for example "Bots
|
||||
# cannot use this endpoint" or "Provided too few or too many messages to
|
||||
# delete. Must provide at least 2 and fewer than 100 messages to delete.".
|
||||
def error_message : String
|
||||
@error.message
|
||||
end
|
||||
|
||||
def message
|
||||
"#{@response.status_code} #{@response.status_message}: Code #{@error.code} - #{@error.message}"
|
||||
end
|
||||
|
||||
def to_s(io)
|
||||
io << @response.status_code << " " << @response.status_message << ": Code " << @error.code << " - " << @error.message
|
||||
end
|
||||
end
|
||||
end
|
||||
12
Crystal/First-Program/lib/discordcr/src/discordcr/logger.cr
Normal file
12
Crystal/First-Program/lib/discordcr/src/discordcr/logger.cr
Normal file
@@ -0,0 +1,12 @@
|
||||
require "logger"
|
||||
|
||||
# The logger class is monkey patched to have a property for the IO.
|
||||
class Logger
|
||||
property io
|
||||
end
|
||||
|
||||
module Discord
|
||||
# The built in logger.
|
||||
LOGGER = Logger.new(STDOUT)
|
||||
LOGGER.progname = "discordcr"
|
||||
end
|
||||
@@ -0,0 +1,234 @@
|
||||
require "./converters"
|
||||
|
||||
module Discord
|
||||
enum MessageType : UInt8
|
||||
Default = 0
|
||||
RecipientAdd = 1
|
||||
RecipientRemove = 2
|
||||
Call = 3
|
||||
ChannelNameChange = 4
|
||||
ChannelIconChange = 5
|
||||
ChannelPinnedMessage = 6
|
||||
GuildMemberJoin = 7
|
||||
end
|
||||
|
||||
struct Message
|
||||
JSON.mapping(
|
||||
type: {type: MessageType, converter: MessageTypeConverter},
|
||||
content: String,
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
channel_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
author: User,
|
||||
timestamp: {type: Time, converter: TimestampConverter},
|
||||
tts: Bool,
|
||||
mention_everyone: Bool,
|
||||
mentions: Array(User),
|
||||
mention_roles: {type: Array(UInt64), converter: SnowflakeArrayConverter},
|
||||
attachments: Array(Attachment),
|
||||
embeds: Array(Embed),
|
||||
pinned: Bool?,
|
||||
reactions: Array(Reaction)?,
|
||||
activity: Activity?
|
||||
)
|
||||
end
|
||||
|
||||
enum ActivityType : UInt8
|
||||
Join = 1
|
||||
Spectate = 2
|
||||
Listen = 3
|
||||
JoinRequest = 5
|
||||
end
|
||||
|
||||
struct Activity
|
||||
JSON.mapping(
|
||||
type: ActivityType,
|
||||
party_id: String?
|
||||
)
|
||||
end
|
||||
|
||||
enum ChannelType : UInt8
|
||||
GuildText = 0
|
||||
DM = 1
|
||||
Voice = 2
|
||||
GroupDM = 3
|
||||
end
|
||||
|
||||
struct Channel
|
||||
# :nodoc:
|
||||
def initialize(private_channel : PrivateChannel)
|
||||
@id = private_channel.id
|
||||
@type = private_channel.type
|
||||
@recipients = private_channel.recipients
|
||||
@last_message_id = private_channel.last_message_id
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
type: {type: ChannelType, converter: ChannelTypeConverter},
|
||||
guild_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
|
||||
name: String?,
|
||||
permission_overwrites: Array(Overwrite)?,
|
||||
topic: String?,
|
||||
last_message_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
|
||||
bitrate: UInt32?,
|
||||
user_limit: UInt32?,
|
||||
recipients: Array(User)?,
|
||||
nsfw: Bool?,
|
||||
icon: Bool?,
|
||||
owner_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
|
||||
application_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
|
||||
position: Int32?,
|
||||
parent_id: {type: UInt64?, converter: MaybeSnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct PrivateChannel
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
type: {type: ChannelType, converter: ChannelTypeConverter},
|
||||
recipients: Array(User),
|
||||
last_message_id: {type: UInt64?, converter: MaybeSnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct Overwrite
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
type: String,
|
||||
allow: Permissions,
|
||||
deny: Permissions
|
||||
)
|
||||
end
|
||||
|
||||
struct Reaction
|
||||
JSON.mapping(
|
||||
emoji: ReactionEmoji,
|
||||
count: UInt32,
|
||||
me: Bool
|
||||
)
|
||||
end
|
||||
|
||||
struct ReactionEmoji
|
||||
JSON.mapping(
|
||||
id: {type: UInt64?, converter: MaybeSnowflakeConverter},
|
||||
name: String
|
||||
)
|
||||
end
|
||||
|
||||
struct Embed
|
||||
def initialize(@title : String? = nil, @type : String = "rich",
|
||||
@description : String? = nil, @url : String? = nil,
|
||||
@timestamp : Time? = nil, @colour : UInt32? = nil,
|
||||
@footer : EmbedFooter? = nil, @image : EmbedImage? = nil,
|
||||
@thumbnail : EmbedThumbnail? = nil, @author : EmbedAuthor? = nil,
|
||||
@fields : Array(EmbedField)? = nil)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
title: String?,
|
||||
type: String,
|
||||
description: String?,
|
||||
url: String?,
|
||||
timestamp: {type: Time?, converter: TimestampConverter},
|
||||
colour: {type: UInt32?, key: "color"},
|
||||
footer: EmbedFooter?,
|
||||
image: EmbedImage?,
|
||||
thumbnail: EmbedThumbnail?,
|
||||
video: EmbedVideo?,
|
||||
provider: EmbedProvider?,
|
||||
author: EmbedAuthor?,
|
||||
fields: Array(EmbedField)?
|
||||
)
|
||||
|
||||
{% unless flag?(:correct_english) %}
|
||||
def color
|
||||
colour
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
struct EmbedThumbnail
|
||||
def initialize(@url : String)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
url: String,
|
||||
proxy_url: String?,
|
||||
height: UInt32?,
|
||||
width: UInt32?
|
||||
)
|
||||
end
|
||||
|
||||
struct EmbedVideo
|
||||
JSON.mapping(
|
||||
url: String,
|
||||
height: UInt32,
|
||||
width: UInt32
|
||||
)
|
||||
end
|
||||
|
||||
struct EmbedImage
|
||||
def initialize(@url : String)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
url: String,
|
||||
proxy_url: String?,
|
||||
height: UInt32?,
|
||||
width: UInt32?
|
||||
)
|
||||
end
|
||||
|
||||
struct EmbedProvider
|
||||
JSON.mapping(
|
||||
name: String,
|
||||
url: String?
|
||||
)
|
||||
end
|
||||
|
||||
struct EmbedAuthor
|
||||
def initialize(@name : String? = nil, @url : String? = nil, @icon_url : String? = nil)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
name: String?,
|
||||
url: String?,
|
||||
icon_url: String?,
|
||||
proxy_icon_url: String?
|
||||
)
|
||||
end
|
||||
|
||||
struct EmbedFooter
|
||||
def initialize(@text : String? = nil, @icon_url : String? = nil)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
text: String?,
|
||||
icon_url: String?,
|
||||
proxy_icon_url: String?
|
||||
)
|
||||
end
|
||||
|
||||
struct EmbedField
|
||||
def initialize(@name : String, @value : String, @inline : Bool = false)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
name: String,
|
||||
value: String,
|
||||
inline: Bool
|
||||
)
|
||||
end
|
||||
|
||||
struct Attachment
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
filename: String,
|
||||
size: UInt32,
|
||||
url: String,
|
||||
proxy_url: String,
|
||||
height: UInt32?,
|
||||
width: UInt32?
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,90 @@
|
||||
require "json"
|
||||
require "time/format"
|
||||
|
||||
module Discord
|
||||
# :nodoc:
|
||||
module TimestampConverter
|
||||
def self.from_json(parser : JSON::PullParser)
|
||||
time_str = parser.read_string
|
||||
|
||||
begin
|
||||
Time::Format.new("%FT%T.%6N%:z").parse(time_str)
|
||||
rescue Time::Format::Error
|
||||
Time::Format.new("%FT%T%:z").parse(time_str)
|
||||
end
|
||||
end
|
||||
|
||||
def self.to_json(value : Time, builder : JSON::Builder)
|
||||
Time::Format.new("%FT%T.%6N%:z").to_json(value, builder)
|
||||
end
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
module SnowflakeConverter
|
||||
def self.from_json(parser : JSON::PullParser) : UInt64
|
||||
parser.read_string.to_u64
|
||||
end
|
||||
|
||||
def self.to_json(value : UInt64, builder : JSON::Builder)
|
||||
builder.scalar(value.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
module MaybeSnowflakeConverter
|
||||
def self.from_json(parser : JSON::PullParser) : UInt64?
|
||||
str = parser.read_string_or_null
|
||||
|
||||
if str
|
||||
str.to_u64
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def self.to_json(value : UInt64?, builder : JSON::Builder)
|
||||
if value
|
||||
builder.scalar(value.to_s)
|
||||
else
|
||||
builder.null
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
module SnowflakeArrayConverter
|
||||
def self.from_json(parser : JSON::PullParser) : Array(UInt64)
|
||||
Array(String).new(parser).map &.to_u64
|
||||
end
|
||||
|
||||
def self.to_json(value : Array(UInt64), builder : JSON::Builder)
|
||||
value.map(&.to_s).to_json(builder)
|
||||
end
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
module MessageTypeConverter
|
||||
def self.from_json(parser : JSON::PullParser)
|
||||
if value = parser.read?(UInt8)
|
||||
MessageType.new(value)
|
||||
else
|
||||
raise "Unexpected message type value: #{parser.read_raw}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.to_json(value : MessageType, builder : JSON::Builder)
|
||||
value.to_json(builder)
|
||||
end
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
module ChannelTypeConverter
|
||||
def self.from_json(parser : JSON::PullParser)
|
||||
if value = parser.read?(UInt8)
|
||||
ChannelType.new(value)
|
||||
else
|
||||
raise "Unexpected channel type value: #{parser.read_raw}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
module Discord::REST
|
||||
# Enum for `parent_id` null significance in
|
||||
# `REST#modify_guild_channel_positions`.
|
||||
enum ChannelParent
|
||||
None
|
||||
Unchanged
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,374 @@
|
||||
require "./converters"
|
||||
require "./user"
|
||||
require "./channel"
|
||||
require "./guild"
|
||||
|
||||
module Discord
|
||||
module Gateway
|
||||
struct ReadyPayload
|
||||
JSON.mapping(
|
||||
v: UInt8,
|
||||
user: User,
|
||||
private_channels: Array(PrivateChannel),
|
||||
guilds: Array(UnavailableGuild),
|
||||
session_id: String
|
||||
)
|
||||
end
|
||||
|
||||
struct ResumedPayload
|
||||
JSON.mapping(
|
||||
_trace: Array(String)
|
||||
)
|
||||
end
|
||||
|
||||
struct IdentifyPacket
|
||||
def initialize(token, properties, large_threshold, compress, shard)
|
||||
@op = Discord::Client::OP_IDENTIFY
|
||||
@d = IdentifyPayload.new(token, properties, large_threshold, compress, shard)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
op: Int32,
|
||||
d: IdentifyPayload
|
||||
)
|
||||
end
|
||||
|
||||
struct IdentifyPayload
|
||||
def initialize(@token, @properties, @compress, @large_threshold, @shard)
|
||||
end
|
||||
|
||||
JSON.mapping({
|
||||
token: String,
|
||||
properties: IdentifyProperties,
|
||||
compress: Bool,
|
||||
large_threshold: Int32,
|
||||
shard: Tuple(Int32, Int32)?,
|
||||
})
|
||||
end
|
||||
|
||||
struct IdentifyProperties
|
||||
def initialize(@os, @browser, @device, @referrer, @referring_domain)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
os: {key: "$os", type: String},
|
||||
browser: {key: "$browser", type: String},
|
||||
device: {key: "$device", type: String},
|
||||
referrer: {key: "$referrer", type: String},
|
||||
referring_domain: {key: "$referring_domain", type: String}
|
||||
)
|
||||
end
|
||||
|
||||
struct ResumePacket
|
||||
def initialize(token, session_id, seq)
|
||||
@op = Discord::Client::OP_RESUME
|
||||
@d = ResumePayload.new(token, session_id, seq)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
op: Int32,
|
||||
d: ResumePayload
|
||||
)
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
struct ResumePayload
|
||||
def initialize(@token, @session_id, @seq)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
token: String,
|
||||
session_id: String,
|
||||
seq: Int64
|
||||
)
|
||||
end
|
||||
|
||||
struct StatusUpdatePacket
|
||||
def initialize(status, game, afk, since)
|
||||
@op = Discord::Client::OP_STATUS_UPDATE
|
||||
@d = StatusUpdatePayload.new(status, game, afk, since)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
op: Int32,
|
||||
d: StatusUpdatePayload
|
||||
)
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
struct StatusUpdatePayload
|
||||
def initialize(@status, @game, @afk, @since)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
status: {type: String?, emit_null: true},
|
||||
game: {type: GamePlaying?, emit_null: true},
|
||||
afk: Bool,
|
||||
since: {type: Int64, nilable: true, emit_null: true}
|
||||
)
|
||||
end
|
||||
|
||||
struct VoiceStateUpdatePacket
|
||||
def initialize(guild_id, channel_id, self_mute, self_deaf)
|
||||
@op = Discord::Client::OP_VOICE_STATE_UPDATE
|
||||
@d = VoiceStateUpdatePayload.new(guild_id, channel_id, self_mute, self_deaf)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
op: Int32,
|
||||
d: VoiceStateUpdatePayload
|
||||
)
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
struct VoiceStateUpdatePayload
|
||||
def initialize(@guild_id, @channel_id, @self_mute, @self_deaf)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
guild_id: UInt64,
|
||||
channel_id: {type: UInt64?, emit_null: true},
|
||||
self_mute: Bool,
|
||||
self_deaf: Bool
|
||||
)
|
||||
end
|
||||
|
||||
struct RequestGuildMembersPacket
|
||||
def initialize(guild_id, query, limit)
|
||||
@op = Discord::Client::OP_REQUEST_GUILD_MEMBERS
|
||||
@d = RequestGuildMembersPayload.new(guild_id, query, limit)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
op: Int32,
|
||||
d: RequestGuildMembersPayload
|
||||
)
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
struct RequestGuildMembersPayload
|
||||
def initialize(@guild_id, @query, @limit)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
guild_id: UInt64,
|
||||
query: String,
|
||||
limit: Int32
|
||||
)
|
||||
end
|
||||
|
||||
struct HelloPayload
|
||||
JSON.mapping(
|
||||
heartbeat_interval: UInt32,
|
||||
_trace: Array(String)
|
||||
)
|
||||
end
|
||||
|
||||
# This one is special from simply Guild since it also has fields for members
|
||||
# and presences.
|
||||
struct GuildCreatePayload
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
name: String,
|
||||
icon: String?,
|
||||
splash: String?,
|
||||
owner_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
region: String,
|
||||
afk_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
|
||||
afk_timeout: Int32?,
|
||||
verification_level: UInt8,
|
||||
roles: Array(Role),
|
||||
emoji: {type: Array(Emoji), key: "emojis"},
|
||||
features: Array(String),
|
||||
large: Bool,
|
||||
voice_states: Array(VoiceState),
|
||||
unavailable: Bool?,
|
||||
member_count: Int32,
|
||||
members: Array(GuildMember),
|
||||
channels: Array(Channel),
|
||||
presences: Array(Presence),
|
||||
widget_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
|
||||
default_message_notifications: UInt8,
|
||||
explicit_content_filter: UInt8,
|
||||
system_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter}
|
||||
)
|
||||
|
||||
{% unless flag?(:correct_english) %}
|
||||
def emojis
|
||||
emoji
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
struct GuildDeletePayload
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
unavailable: Bool?
|
||||
)
|
||||
end
|
||||
|
||||
struct GuildBanPayload
|
||||
JSON.mapping(
|
||||
user: User,
|
||||
guild_id: {type: UInt64, converter: SnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct GuildEmojiUpdatePayload
|
||||
JSON.mapping(
|
||||
guild_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
emoji: {type: Array(Emoji), key: "emojis"}
|
||||
)
|
||||
|
||||
{% unless flag?(:correct_english) %}
|
||||
def emojis
|
||||
emoji
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
struct GuildIntegrationsUpdatePayload
|
||||
JSON.mapping(
|
||||
guild_id: {type: UInt64, converter: SnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct GuildMemberAddPayload
|
||||
JSON.mapping(
|
||||
user: User,
|
||||
nick: String?,
|
||||
roles: {type: Array(UInt64), converter: SnowflakeArrayConverter},
|
||||
joined_at: {type: Time?, converter: TimestampConverter},
|
||||
deaf: Bool,
|
||||
mute: Bool,
|
||||
guild_id: {type: UInt64, converter: SnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct GuildMemberUpdatePayload
|
||||
JSON.mapping(
|
||||
user: User,
|
||||
roles: {type: Array(UInt64), converter: SnowflakeArrayConverter},
|
||||
nick: {type: String, nilable: true},
|
||||
guild_id: {type: UInt64, converter: SnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct GuildMemberRemovePayload
|
||||
JSON.mapping(
|
||||
user: User,
|
||||
guild_id: {type: UInt64, converter: SnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct GuildMembersChunkPayload
|
||||
JSON.mapping(
|
||||
guild_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
members: Array(GuildMember)
|
||||
)
|
||||
end
|
||||
|
||||
struct GuildRolePayload
|
||||
JSON.mapping(
|
||||
guild_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
role: Role
|
||||
)
|
||||
end
|
||||
|
||||
struct GuildRoleDeletePayload
|
||||
JSON.mapping(
|
||||
guild_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
role_id: {type: UInt64, converter: SnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct MessageReactionPayload
|
||||
JSON.mapping(
|
||||
user_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
channel_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
message_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
emoji: ReactionEmoji
|
||||
)
|
||||
end
|
||||
|
||||
struct MessageReactionRemoveAllPayload
|
||||
JSON.mapping(
|
||||
channel_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
message_id: {type: UInt64, converter: SnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct MessageUpdatePayload
|
||||
JSON.mapping(
|
||||
type: UInt8?,
|
||||
content: String?,
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
channel_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
author: User?,
|
||||
timestamp: {type: Time?, converter: TimestampConverter},
|
||||
tts: Bool?,
|
||||
mention_everyone: Bool?,
|
||||
mentions: Array(User)?,
|
||||
mention_roles: {type: Array(UInt64)?, converter: SnowflakeArrayConverter},
|
||||
attachments: Array(Attachment)?,
|
||||
embeds: Array(Embed)?,
|
||||
pinned: Bool?
|
||||
)
|
||||
end
|
||||
|
||||
struct MessageDeletePayload
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
channel_id: {type: UInt64, converter: SnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct MessageDeleteBulkPayload
|
||||
JSON.mapping(
|
||||
ids: {type: Array(UInt64), converter: SnowflakeArrayConverter},
|
||||
channel_id: {type: UInt64, converter: SnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct PresenceUpdatePayload
|
||||
JSON.mapping(
|
||||
user: PartialUser,
|
||||
roles: {type: Array(UInt64), converter: SnowflakeArrayConverter},
|
||||
game: GamePlaying?,
|
||||
nick: String?,
|
||||
guild_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
status: String
|
||||
)
|
||||
end
|
||||
|
||||
struct TypingStartPayload
|
||||
JSON.mapping(
|
||||
channel_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
user_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
timestamp: {type: Time, converter: Time::EpochConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct VoiceServerUpdatePayload
|
||||
JSON.mapping(
|
||||
token: String,
|
||||
guild_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
endpoint: String
|
||||
)
|
||||
end
|
||||
|
||||
struct WebhooksUpdatePayload
|
||||
JSON.mapping(
|
||||
guild_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
channel_id: {type: UInt64, converter: SnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct ChannelPinsUpdatePayload
|
||||
JSON.mapping(
|
||||
last_pin_timestamp: {type: Time, converter: TimestampConverter},
|
||||
channel_id: {type: UInt64, converter: SnowflakeConverter}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,188 @@
|
||||
require "./converters"
|
||||
require "./voice"
|
||||
|
||||
module Discord
|
||||
struct Guild
|
||||
# :nodoc:
|
||||
def initialize(payload : Gateway::GuildCreatePayload)
|
||||
@id = payload.id
|
||||
@name = payload.name
|
||||
@icon = payload.icon
|
||||
@splash = payload.splash
|
||||
@owner_id = payload.owner_id
|
||||
@region = payload.region
|
||||
@afk_channel_id = payload.afk_channel_id
|
||||
@afk_timeout = payload.afk_timeout
|
||||
@verification_level = payload.verification_level
|
||||
@roles = payload.roles
|
||||
@emoji = payload.emoji
|
||||
@features = payload.features
|
||||
@widget_channel_id = payload.widget_channel_id
|
||||
@default_message_notifications = payload.default_message_notifications
|
||||
@explicit_content_filter = payload.explicit_content_filter
|
||||
@system_channel_id = payload.system_channel_id
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
name: String,
|
||||
icon: String?,
|
||||
splash: String?,
|
||||
owner_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
region: String,
|
||||
afk_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
|
||||
afk_timeout: Int32?,
|
||||
embed_enabled: Bool?,
|
||||
embed_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
|
||||
verification_level: UInt8,
|
||||
roles: Array(Role),
|
||||
emoji: {type: Array(Emoji), key: "emojis"},
|
||||
features: Array(String),
|
||||
widget_enabled: {type: Bool, nilable: true},
|
||||
widget_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
|
||||
default_message_notifications: UInt8,
|
||||
explicit_content_filter: UInt8,
|
||||
system_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter}
|
||||
)
|
||||
|
||||
{% unless flag?(:correct_english) %}
|
||||
def emojis
|
||||
emoji
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
struct UnavailableGuild
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
unavailable: Bool
|
||||
)
|
||||
end
|
||||
|
||||
struct GuildEmbed
|
||||
JSON.mapping(
|
||||
enabled: Bool,
|
||||
channel_id: {type: UInt64, converter: SnowflakeConverter}
|
||||
)
|
||||
end
|
||||
|
||||
struct GuildMember
|
||||
# :nodoc:
|
||||
def initialize(payload : Gateway::GuildMemberAddPayload | GuildMember, roles : Array(UInt64), nick : String?)
|
||||
initialize(payload)
|
||||
@nick = nick
|
||||
@roles = roles
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
def initialize(payload : Gateway::GuildMemberAddPayload | GuildMember)
|
||||
@user = payload.user
|
||||
@nick = payload.nick
|
||||
@roles = payload.roles
|
||||
@joined_at = payload.joined_at
|
||||
@deaf = payload.deaf
|
||||
@mute = payload.mute
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
def initialize(payload : Gateway::PresenceUpdatePayload)
|
||||
@user = User.new(payload.user)
|
||||
@nick = payload.nick
|
||||
@roles = payload.roles
|
||||
# Presence updates have no joined_at or deaf/mute, thanks Discord
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
user: User,
|
||||
nick: String?,
|
||||
roles: {type: Array(UInt64), converter: SnowflakeArrayConverter},
|
||||
joined_at: {type: Time?, converter: TimestampConverter},
|
||||
deaf: Bool?,
|
||||
mute: Bool?
|
||||
)
|
||||
end
|
||||
|
||||
struct Integration
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
name: String,
|
||||
type: String,
|
||||
enabled: Bool,
|
||||
syncing: Bool,
|
||||
role_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
expire_behaviour: {type: UInt8, key: "expire_behavior"},
|
||||
expire_grace_period: Int32,
|
||||
user: User,
|
||||
account: IntegrationAccount,
|
||||
synced_at: {type: Time, converter: Time::EpochConverter}
|
||||
)
|
||||
|
||||
{% unless flag?(:correct_english) %}
|
||||
def expire_behavior
|
||||
expire_behaviour
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
struct IntegrationAccount
|
||||
JSON.mapping(
|
||||
id: String,
|
||||
name: String
|
||||
)
|
||||
end
|
||||
|
||||
struct Emoji
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
name: String,
|
||||
roles: {type: Array(UInt64), converter: SnowflakeArrayConverter},
|
||||
require_colons: Bool,
|
||||
managed: Bool
|
||||
)
|
||||
end
|
||||
|
||||
struct Role
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
name: String,
|
||||
permissions: Permissions,
|
||||
colour: {type: UInt32, key: "color"},
|
||||
hoist: Bool,
|
||||
position: Int32,
|
||||
managed: Bool,
|
||||
mentionable: Bool
|
||||
)
|
||||
|
||||
{% unless flag?(:correct_english) %}
|
||||
def color
|
||||
colour
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
struct GuildBan
|
||||
JSON.mapping(
|
||||
user: User,
|
||||
reason: String?
|
||||
)
|
||||
end
|
||||
|
||||
struct GamePlaying
|
||||
def initialize(@name = nil, @type = nil, @url = nil)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
name: String?,
|
||||
type: Int64? | String?,
|
||||
url: String?
|
||||
)
|
||||
end
|
||||
|
||||
struct Presence
|
||||
JSON.mapping(
|
||||
user: PartialUser,
|
||||
game: GamePlaying?,
|
||||
status: String
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
require "./converters"
|
||||
require "./user"
|
||||
|
||||
module Discord
|
||||
struct Invite
|
||||
JSON.mapping(
|
||||
code: String,
|
||||
guild: InviteGuild,
|
||||
channel: InviteChannel
|
||||
)
|
||||
end
|
||||
|
||||
struct InviteMetadata
|
||||
JSON.mapping(
|
||||
code: String,
|
||||
guild: InviteGuild,
|
||||
channel: InviteChannel,
|
||||
inviter: User,
|
||||
users: UInt32,
|
||||
max_uses: UInt32,
|
||||
max_age: UInt32,
|
||||
temporary: Bool,
|
||||
created_at: {type: Time, converter: TimestampConverter},
|
||||
revoked: Bool
|
||||
)
|
||||
end
|
||||
|
||||
struct InviteGuild
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
name: String,
|
||||
splash_hash: String?
|
||||
)
|
||||
end
|
||||
|
||||
struct InviteChannel
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
name: String,
|
||||
type: UInt8
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
require "./converters"
|
||||
require "./user"
|
||||
|
||||
module Discord
|
||||
# An OAuth2 application, as registered with Discord, that can hold
|
||||
# information about a `Client`'s associated bot user account and owner,
|
||||
# among other OAuth2 properties.
|
||||
struct OAuth2Application
|
||||
JSON.mapping({
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
name: String,
|
||||
icon: String?,
|
||||
description: String?,
|
||||
rpc_origins: Array(String)?,
|
||||
bot_public: Bool,
|
||||
bot_require_code_grant: Bool,
|
||||
owner: User,
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
module Discord
|
||||
@[Flags]
|
||||
enum Permissions : UInt64
|
||||
CreateInstantInvite = 1
|
||||
KickMembers = 1 << 1
|
||||
BanMembers = 1 << 2
|
||||
Administrator = 1 << 3
|
||||
ManageChannels = 1 << 4
|
||||
ManageGuild = 1 << 5
|
||||
AddReactions = 1 << 6
|
||||
ReadMessages = 1 << 10
|
||||
SendMessages = 1 << 11
|
||||
SendTTSMessages = 1 << 12
|
||||
ManageMessages = 1 << 13
|
||||
EmbedLinks = 1 << 14
|
||||
AttachFiles = 1 << 15
|
||||
ReadMessageHistory = 1 << 16
|
||||
MentionEveryone = 1 << 17
|
||||
UseExternalEmojis = 1 << 18
|
||||
Connect = 1 << 20
|
||||
Speak = 1 << 21
|
||||
MuteMembers = 1 << 22
|
||||
DeafenMembers = 1 << 23
|
||||
MoveMembers = 1 << 24
|
||||
UseVAD = 1 << 25
|
||||
ChangeNickname = 1 << 26
|
||||
ManageNicknames = 1 << 27
|
||||
ManageRoles = 1 << 28
|
||||
ManageWebhooks = 1 << 29
|
||||
ManageEmojis = 1 << 30
|
||||
|
||||
def self.new(pull : JSON::PullParser)
|
||||
# see https://github.com/crystal-lang/crystal/issues/3448
|
||||
# #from_value errors
|
||||
Permissions.new(pull.read_int.to_u64)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
require "./converters"
|
||||
|
||||
module Discord
|
||||
module REST
|
||||
# A response to the Get Gateway REST API call.
|
||||
struct GatewayResponse
|
||||
JSON.mapping(
|
||||
url: String
|
||||
)
|
||||
end
|
||||
|
||||
# A response to the Get Gateway Bot REST API call.
|
||||
struct GatewayBotResponse
|
||||
JSON.mapping(
|
||||
url: String,
|
||||
shards: Int32
|
||||
)
|
||||
end
|
||||
|
||||
# A response to the Get Guild Prune Count REST API call.
|
||||
struct PruneCountResponse
|
||||
JSON.mapping(
|
||||
pruned: UInt32
|
||||
)
|
||||
end
|
||||
|
||||
# A response to the Get Guild Vanity URL REST API call.
|
||||
struct GuildVanityURLResponse
|
||||
JSON.mapping(
|
||||
code: String
|
||||
)
|
||||
end
|
||||
|
||||
# A request payload to rearrange channels in a `Guild` by a REST API call.
|
||||
struct ModifyChannelPositionPayload
|
||||
def initialize(@id : UInt64, @position : Int32,
|
||||
@parent_id : UInt64 | ChannelParent = ChannelParent::Unchanged,
|
||||
@lock_permissions : Bool? = nil)
|
||||
end
|
||||
|
||||
def to_json(builder : JSON::Builder)
|
||||
builder.object do
|
||||
builder.field("id") do
|
||||
SnowflakeConverter.to_json(@id, builder)
|
||||
end
|
||||
|
||||
builder.field("position", @position)
|
||||
|
||||
case parent = @parent_id
|
||||
when UInt64
|
||||
SnowflakeConverter.to_json(parent, builder)
|
||||
when ChannelParent::None
|
||||
builder.field("parent_id", nil)
|
||||
when ChannelParent::Unchanged
|
||||
# no field
|
||||
end
|
||||
|
||||
builder.field("lock_permissions", @lock_permissions) unless @lock_permissions.nil?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,60 @@
|
||||
require "./converters"
|
||||
|
||||
module Discord
|
||||
struct User
|
||||
# :nodoc:
|
||||
def initialize(partial : PartialUser)
|
||||
@username = partial.username.not_nil!
|
||||
@id = partial.id
|
||||
@discriminator = partial.discriminator.not_nil!
|
||||
@avatar = partial.avatar
|
||||
@email = partial.email
|
||||
@bot = partial.bot
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
username: String,
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
discriminator: String,
|
||||
avatar: String?,
|
||||
email: String?,
|
||||
bot: Bool?,
|
||||
mfa_enabled: Bool?,
|
||||
verified: Bool?
|
||||
)
|
||||
end
|
||||
|
||||
struct PartialUser
|
||||
JSON.mapping(
|
||||
username: String?,
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
discriminator: String?,
|
||||
avatar: String?,
|
||||
email: String?,
|
||||
bot: Bool?
|
||||
)
|
||||
|
||||
def full? : Bool
|
||||
!@username.nil? && !@discriminator.nil? && !@avatar.nil?
|
||||
end
|
||||
end
|
||||
|
||||
struct UserGuild
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
name: String,
|
||||
icon: String?,
|
||||
owner: Bool,
|
||||
permissions: Permissions
|
||||
)
|
||||
end
|
||||
|
||||
struct Connection
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
name: String,
|
||||
type: String,
|
||||
revoked: Bool
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
require "./converters"
|
||||
|
||||
module Discord
|
||||
struct VoiceState
|
||||
JSON.mapping(
|
||||
guild_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
|
||||
channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
|
||||
user_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
session_id: String,
|
||||
deaf: Bool,
|
||||
mute: Bool,
|
||||
self_deaf: Bool,
|
||||
self_mute: Bool,
|
||||
suppress: Bool
|
||||
)
|
||||
end
|
||||
|
||||
struct VoiceRegion
|
||||
JSON.mapping(
|
||||
id: String,
|
||||
name: String,
|
||||
sample_hostname: String,
|
||||
sample_port: UInt16,
|
||||
custom: Bool?,
|
||||
vip: Bool,
|
||||
optimal: Bool
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
require "./converters"
|
||||
|
||||
module Discord
|
||||
# :nodoc:
|
||||
module VWS
|
||||
struct IdentifyPacket
|
||||
def initialize(server_id, user_id, session_id, token)
|
||||
@op = Discord::VoiceClient::OP_IDENTIFY
|
||||
@d = IdentifyPayload.new(server_id, user_id, session_id, token)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
op: Int32,
|
||||
d: IdentifyPayload
|
||||
)
|
||||
end
|
||||
|
||||
struct IdentifyPayload
|
||||
def initialize(@server_id, @user_id, @session_id, @token)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
server_id: UInt64,
|
||||
user_id: UInt64,
|
||||
session_id: String,
|
||||
token: String
|
||||
)
|
||||
end
|
||||
|
||||
struct SelectProtocolPacket
|
||||
def initialize(protocol, data)
|
||||
@op = Discord::VoiceClient::OP_SELECT_PROTOCOL
|
||||
@d = SelectProtocolPayload.new(protocol, data)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
op: Int32,
|
||||
d: SelectProtocolPayload
|
||||
)
|
||||
end
|
||||
|
||||
struct SelectProtocolPayload
|
||||
def initialize(@protocol, @data)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
protocol: String,
|
||||
data: ProtocolData
|
||||
)
|
||||
end
|
||||
|
||||
struct ProtocolData
|
||||
def initialize(@address, @port, @mode)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
address: String,
|
||||
port: UInt16,
|
||||
mode: String
|
||||
)
|
||||
end
|
||||
|
||||
struct ReadyPayload
|
||||
JSON.mapping(
|
||||
ssrc: Int32,
|
||||
port: Int32,
|
||||
modes: Array(String),
|
||||
heartbeat_interval: Int32
|
||||
)
|
||||
end
|
||||
|
||||
struct SessionDescriptionPayload
|
||||
JSON.mapping(
|
||||
secret_key: Array(UInt8)
|
||||
)
|
||||
end
|
||||
|
||||
struct SpeakingPacket
|
||||
def initialize(speaking, delay)
|
||||
@op = Discord::VoiceClient::OP_SPEAKING
|
||||
@d = SpeakingPayload.new(speaking, delay)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
op: Int32,
|
||||
d: SpeakingPayload
|
||||
)
|
||||
end
|
||||
|
||||
struct SpeakingPayload
|
||||
def initialize(@speaking, @delay)
|
||||
end
|
||||
|
||||
JSON.mapping(
|
||||
speaking: Bool,
|
||||
delay: Int32
|
||||
)
|
||||
end
|
||||
|
||||
struct HelloPayload
|
||||
JSON.mapping(
|
||||
heartbeat_interval: Int32
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
require "./converters"
|
||||
require "./user"
|
||||
|
||||
module Discord
|
||||
struct Webhook
|
||||
JSON.mapping(
|
||||
id: {type: UInt64, converter: SnowflakeConverter},
|
||||
guild_id: {type: UInt64?, converter: SnowflakeConverter},
|
||||
channel_id: {type: UInt64, converter: SnowflakeConverter},
|
||||
user: User?,
|
||||
name: String,
|
||||
avatar: String?,
|
||||
token: String
|
||||
)
|
||||
end
|
||||
end
|
||||
129
Crystal/First-Program/lib/discordcr/src/discordcr/mention.cr
Normal file
129
Crystal/First-Program/lib/discordcr/src/discordcr/mention.cr
Normal file
@@ -0,0 +1,129 @@
|
||||
module Discord::Mention
|
||||
record User, id : UInt64, start : Int32, size : Int32
|
||||
|
||||
record Role, id : UInt64, start : Int32, size : Int32
|
||||
|
||||
record Channel, id : UInt64, start : Int32, size : Int32
|
||||
|
||||
record Emoji, animated : Bool, name : String, id : UInt64, start : Int32, size : Int32
|
||||
|
||||
record Everyone, start : Int32 do
|
||||
def size
|
||||
9
|
||||
end
|
||||
end
|
||||
|
||||
record Here, start : Int32 do
|
||||
def size
|
||||
5
|
||||
end
|
||||
end
|
||||
|
||||
alias MentionType = User | Role | Channel | Emoji | Everyone | Here
|
||||
|
||||
# Returns an array of mentions found in a string
|
||||
def self.parse(string : String)
|
||||
Parser.new(string).parse
|
||||
end
|
||||
|
||||
# Parses a string for mentions, yielding for each mention found
|
||||
def self.parse(string : String, &block : MentionType ->)
|
||||
Parser.new(string).parse(&block)
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
class Parser
|
||||
def initialize(@string : String)
|
||||
@reader = Char::Reader.new string
|
||||
end
|
||||
|
||||
delegate has_next?, pos, current_char, next_char, peek_next_char, to: @reader
|
||||
|
||||
def parse(&block : MentionType ->)
|
||||
while has_next?
|
||||
start = pos
|
||||
animated = false
|
||||
|
||||
case current_char
|
||||
when '<'
|
||||
case next_char
|
||||
when '@'
|
||||
case peek_next_char
|
||||
when '&'
|
||||
next_char # Skip role mention indicator
|
||||
|
||||
if next_char.ascii_number?
|
||||
snowflake = scan_snowflake(pos)
|
||||
yield Role.new(snowflake, start, pos - start) if has_next? && current_char == '>'
|
||||
end
|
||||
when .ascii_number?, '!'
|
||||
next_char # Skip mention indicator
|
||||
next_char if current_char == '!' # Skip optional nickname indicator
|
||||
|
||||
if current_char.ascii_number?
|
||||
snowflake = scan_snowflake(pos)
|
||||
yield User.new(snowflake, start, pos - start + 1) if current_char == '>'
|
||||
end
|
||||
end
|
||||
when '#'
|
||||
next_char # Skip channel mention indicator
|
||||
|
||||
if peek_next_char.ascii_number?
|
||||
snowflake = scan_snowflake(pos)
|
||||
yield Channel.new(snowflake, start, pos - start + 1) if current_char == '>'
|
||||
end
|
||||
when ':', 'a'
|
||||
if current_char == 'a'
|
||||
next unless peek_next_char == ':'
|
||||
animated = true
|
||||
next_char
|
||||
end
|
||||
next_char
|
||||
|
||||
name = scan_word(pos)
|
||||
if current_char == ':' && peek_next_char.ascii_number?
|
||||
next_char
|
||||
snowflake = scan_snowflake(pos)
|
||||
yield Emoji.new(animated, name, snowflake, start, pos - start + 1) if current_char == '>'
|
||||
end
|
||||
end
|
||||
when '@'
|
||||
word = scan_word(pos)
|
||||
case word
|
||||
when "@everyone"
|
||||
yield Everyone.new(start)
|
||||
when "@here"
|
||||
yield Here.new(start)
|
||||
end
|
||||
else
|
||||
next_char
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def parse
|
||||
results = [] of MentionType
|
||||
parse { |mention| results << mention }
|
||||
results
|
||||
end
|
||||
|
||||
private def scan_snowflake(start)
|
||||
while next_char.ascii_number?
|
||||
# Nothing to do
|
||||
end
|
||||
@string[start..pos - 1].to_u64
|
||||
end
|
||||
|
||||
private def scan_word(start)
|
||||
while has_next?
|
||||
case next_char
|
||||
when .ascii_letter?, .ascii_number?
|
||||
# Nothing to do
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
@string[start..pos - 1]
|
||||
end
|
||||
end
|
||||
end
|
||||
1680
Crystal/First-Program/lib/discordcr/src/discordcr/rest.cr
Normal file
1680
Crystal/First-Program/lib/discordcr/src/discordcr/rest.cr
Normal file
File diff suppressed because it is too large
Load Diff
22
Crystal/First-Program/lib/discordcr/src/discordcr/sodium.cr
Normal file
22
Crystal/First-Program/lib/discordcr/src/discordcr/sodium.cr
Normal file
@@ -0,0 +1,22 @@
|
||||
module Discord
|
||||
# Bindings to libsodium. These aren't intended to be general bindings, just
|
||||
# for the specific xsalsa20poly1305 encryption Discord uses.
|
||||
@[Link("sodium")]
|
||||
lib Sodium
|
||||
# Encrypt something using xsalsa20poly1305
|
||||
fun crypto_secretbox_xsalsa20poly1305(c : UInt8*, message : UInt8*,
|
||||
mlen : UInt64, nonce : UInt8*,
|
||||
key : UInt8*) : LibC::Int
|
||||
|
||||
# Decrypt something using xsalsa20poly1305 ("open a secretbox")
|
||||
fun crypto_secretbox_xsalsa20poly1305_open(message : UInt8*, c : UInt8*,
|
||||
mlen : UInt64, nonce : UInt8*,
|
||||
key : UInt8*) : LibC::Int
|
||||
|
||||
# Constants
|
||||
fun crypto_secretbox_xsalsa20poly1305_keybytes : LibC::SizeT # Key size in bytes
|
||||
fun crypto_secretbox_xsalsa20poly1305_noncebytes : LibC::SizeT # Nonce size in bytes
|
||||
fun crypto_secretbox_xsalsa20poly1305_zerobytes : LibC::SizeT # Zero bytes before a plaintext
|
||||
fun crypto_secretbox_xsalsa20poly1305_boxzerobytes : LibC::SizeT # Zero bytes before a ciphertext
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
module Discord
|
||||
VERSION = "0.3.0"
|
||||
end
|
||||
298
Crystal/First-Program/lib/discordcr/src/discordcr/voice.cr
Normal file
298
Crystal/First-Program/lib/discordcr/src/discordcr/voice.cr
Normal file
@@ -0,0 +1,298 @@
|
||||
require "uri"
|
||||
|
||||
require "./mappings/gateway"
|
||||
require "./mappings/vws"
|
||||
require "./websocket"
|
||||
require "./sodium"
|
||||
|
||||
module Discord
|
||||
class VoiceClient
|
||||
UDP_PROTOCOL = "udp"
|
||||
|
||||
# The mode that tells Discord we want to send encrypted audio
|
||||
ENCRYPTED_MODE = "xsalsa20_poly1305"
|
||||
|
||||
OP_IDENTIFY = 0
|
||||
OP_SELECT_PROTOCOL = 1
|
||||
OP_READY = 2
|
||||
OP_HEARTBEAT = 3
|
||||
OP_SESSION_DESCRIPTION = 4
|
||||
OP_SPEAKING = 5
|
||||
OP_HELLO = 8
|
||||
|
||||
# The heartbeat is the same every time, so it can be a constant
|
||||
HEARTBEAT_JSON = {op: OP_HEARTBEAT, d: nil}.to_json
|
||||
|
||||
@udp : VoiceUDP
|
||||
|
||||
@sequence : UInt16 = 0_u16
|
||||
@time : UInt32 = 0_u32
|
||||
|
||||
@endpoint : String
|
||||
@server_id : UInt64
|
||||
@session_id : String
|
||||
@token : String
|
||||
|
||||
@heartbeat_interval : Int32?
|
||||
|
||||
# Creates a new voice client. The *payload* should be a payload received
|
||||
# from Discord as part of a VOICE_SERVER_UPDATE dispatch, received after
|
||||
# sending a voice state update (gateway op 4) packet. The *session* should
|
||||
# be the session currently in use by the gateway client on which the
|
||||
# aforementioned dispatch was received, and the *user_id* should be the
|
||||
# user ID of the account on which the voice client is created. (It is
|
||||
# received as part of the gateway READY dispatch, for example)
|
||||
def initialize(payload : Discord::Gateway::VoiceServerUpdatePayload,
|
||||
session : Discord::Gateway::Session, @user_id : UInt64)
|
||||
@endpoint = payload.endpoint.gsub(":80", "")
|
||||
|
||||
@server_id = payload.guild_id
|
||||
@session_id = session.session_id
|
||||
@token = payload.token
|
||||
|
||||
@websocket = Discord::WebSocket.new(
|
||||
host: @endpoint,
|
||||
path: "/",
|
||||
port: 443,
|
||||
tls: true
|
||||
)
|
||||
|
||||
@websocket.on_message(&->on_message(Discord::WebSocket::Packet))
|
||||
@websocket.on_close(&->on_close(String))
|
||||
|
||||
@udp = VoiceUDP.new
|
||||
end
|
||||
|
||||
# Initiates the connection process and blocks forever afterwards.
|
||||
def run
|
||||
spawn { heartbeat_loop }
|
||||
@websocket.run
|
||||
end
|
||||
|
||||
# Closes the VWS connection, in effect disconnecting from voice.
|
||||
delegate close, to: @websocket
|
||||
|
||||
# Sets the handler that should be run once the voice client has connected
|
||||
# successfully.
|
||||
def on_ready(&@ready_handler : ->)
|
||||
end
|
||||
|
||||
# Sends a packet to indicate to Discord whether or not we are speaking
|
||||
# right now
|
||||
def send_speaking(speaking : Bool, delay : Int32 = 0)
|
||||
packet = VWS::SpeakingPacket.new(speaking, delay)
|
||||
@websocket.send(packet.to_json)
|
||||
end
|
||||
|
||||
# Plays a single opus packet
|
||||
def play_opus(buf : Bytes)
|
||||
increment_packet_metadata
|
||||
@udp.send_audio(buf, @sequence, @time)
|
||||
end
|
||||
|
||||
# Increment sequence and time
|
||||
private def increment_packet_metadata
|
||||
@sequence += 1
|
||||
@time += 960
|
||||
end
|
||||
|
||||
private def heartbeat_loop
|
||||
loop do
|
||||
if @heartbeat_interval
|
||||
@websocket.send(HEARTBEAT_JSON)
|
||||
sleep @heartbeat_interval.not_nil!.milliseconds
|
||||
else
|
||||
sleep 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private def on_message(packet : Discord::WebSocket::Packet)
|
||||
LOGGER.debug("VWS packet received: #{packet} #{packet.data.to_s}")
|
||||
|
||||
case packet.opcode
|
||||
when OP_READY
|
||||
payload = VWS::ReadyPayload.from_json(packet.data)
|
||||
handle_ready(payload)
|
||||
when OP_SESSION_DESCRIPTION
|
||||
payload = VWS::SessionDescriptionPayload.from_json(packet.data)
|
||||
handle_session_description(payload)
|
||||
when OP_HELLO
|
||||
payload = VWS::HelloPayload.from_json(packet.data)
|
||||
handle_hello(payload)
|
||||
end
|
||||
end
|
||||
|
||||
private def on_close(message : String)
|
||||
if message.bytesize < 2
|
||||
LOGGER.warn("VWS closed with data: #{message.bytes}")
|
||||
return nil
|
||||
end
|
||||
|
||||
code = IO::Memory.new(message.byte_slice(0, 2)).read_bytes(UInt16, IO::ByteFormat::BigEndian)
|
||||
reason = message.byte_slice(2, message.bytesize - 2)
|
||||
LOGGER.warn("VWS closed with code #{code}, reason: #{reason}")
|
||||
nil
|
||||
end
|
||||
|
||||
private def handle_ready(payload : VWS::ReadyPayload)
|
||||
# We get a new heartbeat interval here that replaces the old one
|
||||
@heartbeat_interval = payload.heartbeat_interval
|
||||
udp_connect(payload.port.to_u32, payload.ssrc.to_u32)
|
||||
end
|
||||
|
||||
private def udp_connect(port, ssrc)
|
||||
@udp.connect(@endpoint, port, ssrc)
|
||||
@udp.send_discovery
|
||||
ip, port = @udp.receive_discovery_reply
|
||||
send_select_protocol(UDP_PROTOCOL, ip, port, ENCRYPTED_MODE)
|
||||
end
|
||||
|
||||
private def send_identify(server_id, user_id, session_id, token)
|
||||
packet = VWS::IdentifyPacket.new(server_id, user_id, session_id, token)
|
||||
@websocket.send(packet.to_json)
|
||||
end
|
||||
|
||||
private def send_select_protocol(protocol, address, port, mode)
|
||||
data = VWS::ProtocolData.new(address, port, mode)
|
||||
packet = VWS::SelectProtocolPacket.new(protocol, data)
|
||||
@websocket.send(packet.to_json)
|
||||
end
|
||||
|
||||
private def handle_session_description(payload : VWS::SessionDescriptionPayload)
|
||||
@udp.secret_key = Bytes.new(payload.secret_key.to_unsafe, payload.secret_key.size)
|
||||
|
||||
# Once the secret key has been received, we are ready to send audio data.
|
||||
# Notify the user of this
|
||||
spawn { @ready_handler.try(&.call) }
|
||||
end
|
||||
|
||||
private def handle_hello(payload : VWS::HelloPayload)
|
||||
@heartbeat_interval = payload.heartbeat_interval
|
||||
send_identify(@server_id, @user_id, @session_id, @token)
|
||||
end
|
||||
end
|
||||
|
||||
# Client for Discord's voice UDP protocol, on which the actual audio data is
|
||||
# sent. There should be no reason to manually use this class: use
|
||||
# `VoiceClient` instead which uses this class internally.
|
||||
class VoiceUDP
|
||||
@secret_key : Bytes?
|
||||
property secret_key
|
||||
|
||||
def initialize
|
||||
@socket = UDPSocket.new
|
||||
end
|
||||
|
||||
def connect(endpoint : String, port : UInt32, ssrc : UInt32)
|
||||
@ssrc = ssrc
|
||||
@socket.connect(endpoint, port)
|
||||
end
|
||||
|
||||
# Sends a discovery packet to Discord, telling them that we want to know our
|
||||
# IP so we can select the protocol on the VWS
|
||||
def send_discovery
|
||||
io = IO::Memory.new(70)
|
||||
|
||||
io.write_bytes(@ssrc.not_nil!, IO::ByteFormat::BigEndian)
|
||||
io.write(Bytes.new(70 - sizeof(UInt32), 0_u8))
|
||||
|
||||
@socket.write(io.to_slice)
|
||||
end
|
||||
|
||||
# Awaits a response to the discovery request and returns our local IP and
|
||||
# port once the response is received
|
||||
def receive_discovery_reply : {String, UInt16}
|
||||
buf = Bytes.new(70)
|
||||
@socket.receive(buf)
|
||||
io = IO::Memory.new(buf)
|
||||
|
||||
io.seek(4) # The first four bytes are just the SSRC again, we don't care about that
|
||||
ip = io.read_string(64).delete("\0")
|
||||
port = io.read_bytes(UInt16, IO::ByteFormat::BigEndian)
|
||||
|
||||
{ip, port}
|
||||
end
|
||||
|
||||
# Sends 20 ms of opus audio data to Discord, with the specified sequence and
|
||||
# time (used on the receiving client to synchronise packets)
|
||||
def send_audio(buf, sequence, time)
|
||||
header = create_header(sequence, time)
|
||||
|
||||
buf = encrypt_audio(header, buf)
|
||||
|
||||
new_buf = Bytes.new(header.size + buf.size)
|
||||
header.copy_to(new_buf)
|
||||
buf.copy_to(new_buf + header.size)
|
||||
|
||||
@socket.write(new_buf)
|
||||
end
|
||||
|
||||
private def create_header(sequence : UInt16, time : UInt32) : Bytes
|
||||
io = IO::Memory.new(12)
|
||||
|
||||
# Write the magic bytes required by Discord
|
||||
io.write_byte(0x80_u8)
|
||||
io.write_byte(0x78_u8)
|
||||
|
||||
# Write the actual information in the header
|
||||
io.write_bytes(sequence, IO::ByteFormat::BigEndian)
|
||||
io.write_bytes(time, IO::ByteFormat::BigEndian)
|
||||
io.write_bytes(@ssrc.not_nil!, IO::ByteFormat::BigEndian)
|
||||
|
||||
io.to_slice
|
||||
end
|
||||
|
||||
private def encrypt_audio(header : Bytes, buf : Bytes) : Bytes
|
||||
raise "No secret key was set!" unless @secret_key
|
||||
|
||||
nonce = Bytes.new(24, 0_u8) # 24 null bytes
|
||||
header.copy_to(nonce) # First 12 bytes of nonce is the header
|
||||
|
||||
# Sodium constants
|
||||
zero_bytes = Sodium.crypto_secretbox_xsalsa20poly1305_zerobytes
|
||||
box_zero_bytes = Sodium.crypto_secretbox_xsalsa20poly1305_boxzerobytes
|
||||
|
||||
# Prepend the buf with zero_bytes zero bytes
|
||||
message = Bytes.new(buf.size + zero_bytes, 0_u8)
|
||||
buf.copy_to(message + zero_bytes)
|
||||
|
||||
# Create a buffer for the ciphertext
|
||||
c = Bytes.new(message.size)
|
||||
|
||||
# Encrypt
|
||||
Sodium.crypto_secretbox_xsalsa20poly1305(c, message, message.bytesize, nonce, @secret_key.not_nil!)
|
||||
|
||||
# The resulting ciphertext buffer has box_zero_bytes zero bytes prepended;
|
||||
# we don't want them in the result, so move the slice forward by that many
|
||||
# bytes
|
||||
c + box_zero_bytes
|
||||
end
|
||||
end
|
||||
|
||||
# Utility function that runs the given block and measures the time it takes,
|
||||
# then sleeps the given time minus that time. This is useful for voice code
|
||||
# because (in most cases) voice data should be sent to Discord at a rate of
|
||||
# one frame every 20 ms, and if the processing and sending takes a certain
|
||||
# amount of time, then noticeable choppiness can be heard.
|
||||
def self.timed_run(total_time : Time::Span)
|
||||
t1 = Time.now
|
||||
yield
|
||||
delta = Time.now - t1
|
||||
|
||||
sleep_time = {total_time - delta, Time::Span.zero}.max
|
||||
sleep sleep_time
|
||||
end
|
||||
|
||||
# Runs the given block every *time_span*. This method takes into account the
|
||||
# execution time for the block to keep the intervals accurate.
|
||||
#
|
||||
# Note that if the block takes longer to execute than the given *time_span*,
|
||||
# there will be no delay: the next iteration follows immediately, with no
|
||||
# attempt to get in sync.
|
||||
def self.every(time_span : Time::Span)
|
||||
loop do
|
||||
timed_run(time_span) { yield }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
require "http"
|
||||
|
||||
module Discord
|
||||
# Internal wrapper around HTTP::WebSocket to decode the Discord-specific
|
||||
# payload format used in the gateway and VWS.
|
||||
class WebSocket
|
||||
# :nodoc:
|
||||
struct Packet
|
||||
getter opcode, sequence, data, event_type
|
||||
|
||||
def initialize(@opcode : Int64?, @sequence : Int64?, @data : IO::Memory, @event_type : String?)
|
||||
end
|
||||
|
||||
def inspect(io : IO)
|
||||
io << "Discord::WebSocket::Packet(@opcode="
|
||||
opcode.inspect(io)
|
||||
io << " @sequence="
|
||||
sequence.inspect(io)
|
||||
io << " @data="
|
||||
data.to_s.inspect(io)
|
||||
io << " @event_type="
|
||||
event_type.inspect(io)
|
||||
io << ')'
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(@host : String, @path : String, @port : Int32, @tls : Bool)
|
||||
@websocket = HTTP::WebSocket.new(
|
||||
host: @host,
|
||||
path: @path,
|
||||
port: @port,
|
||||
tls: @tls
|
||||
)
|
||||
end
|
||||
|
||||
def on_message(&handler : Packet ->)
|
||||
@websocket.on_message do |message|
|
||||
payload = parse_message(message)
|
||||
handler.call(payload)
|
||||
end
|
||||
end
|
||||
|
||||
def on_close(&handler : String ->)
|
||||
@websocket.on_close(&handler)
|
||||
end
|
||||
|
||||
delegate run, close, send, to: @websocket
|
||||
|
||||
private def parse_message(message : String)
|
||||
parser = JSON::PullParser.new(message)
|
||||
|
||||
opcode = nil
|
||||
sequence = nil
|
||||
event_type = nil
|
||||
data = IO::Memory.new
|
||||
|
||||
parser.read_object do |key|
|
||||
case key
|
||||
when "op"
|
||||
opcode = parser.read_int
|
||||
when "d"
|
||||
# Read the raw JSON into memory
|
||||
JSON.build(data) do |builder|
|
||||
parser.read_raw(builder)
|
||||
end
|
||||
when "s"
|
||||
sequence = parser.read_int_or_null
|
||||
when "t"
|
||||
event_type = parser.read_string_or_null
|
||||
else
|
||||
# Unknown field
|
||||
parser.skip
|
||||
end
|
||||
end
|
||||
|
||||
# Rewind to beginning of JSON
|
||||
data.rewind
|
||||
|
||||
Packet.new(opcode, sequence, data, event_type)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,16 @@
|
||||
require "discordcr"
|
||||
|
||||
token = "Bot NDIzNTkyMjczMTUxMDAwNTc2.DghCOQ.TITKUFX_WZDIh2j6kiprGTQ0Chw"
|
||||
id = 423592273151000576_u64
|
||||
prefix = '+'
|
||||
|
||||
|
||||
client = Discord::Client.new(token: token, client_id: id)
|
||||
|
||||
client.on_message_create do |payload|
|
||||
if payload.content.starts_with? prefix
|
||||
client.create_message(payload.channel_id, "Pong!")
|
||||
end
|
||||
end
|
||||
|
||||
client.run()
|
||||
6
Crystal/First-Program/shard.lock
Normal file
6
Crystal/First-Program/shard.lock
Normal file
@@ -0,0 +1,6 @@
|
||||
version: 1.0
|
||||
shards:
|
||||
discordcr:
|
||||
github: z64/discordcr
|
||||
commit: 5c722de5c25a6020466e80bc10d7904a0e30e7d5
|
||||
|
||||
19
Crystal/First-Program/shard.yml
Normal file
19
Crystal/First-Program/shard.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
name: First-Program
|
||||
version: 0.1.0
|
||||
|
||||
# authors:
|
||||
# - name <email@example.com>
|
||||
|
||||
# description: |
|
||||
# Short description of First-Program
|
||||
|
||||
dependencies:
|
||||
discordcr:
|
||||
github: z64/discordcr
|
||||
branch: crystal-0.25
|
||||
|
||||
# development_dependencies:
|
||||
# webmock:
|
||||
# github: manastech/webmock.cr
|
||||
|
||||
license: MIT
|
||||
Reference in New Issue
Block a user