chore: bot
This commit is contained in:
commit
60773bde4d
62 changed files with 8201 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
use flake
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.direnv
|
||||
.env
|
||||
|
||||
1
dictionary.json
Normal file
1
dictionary.json
Normal file
File diff suppressed because one or more lines are too long
25
flake.lock
generated
Normal file
25
flake.lock
generated
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772773019,
|
||||
"narHash": "sha256-E1bxHxNKfDoQUuvriG71+f+s/NT0qWkImXsYZNFFfCs=",
|
||||
"rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
|
||||
"revCount": 958961,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.958961%2Brev-aca4d95fce4914b3892661bcb80b8087293536c6/019cc7ad-65c5-7d4e-9860-842d09d8f4fa/source.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://flakehub.com/f/NixOS/nixpkgs/0.1"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
32
flake.nix
Normal file
32
flake.nix
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
description = "minimal flake for crystal dev";
|
||||
|
||||
inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1";
|
||||
|
||||
outputs = inputs: let
|
||||
supportedSystems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
forEachSupportedSystem = f:
|
||||
inputs.nixpkgs.lib.genAttrs supportedSystems (
|
||||
system:
|
||||
f {
|
||||
pkgs = import inputs.nixpkgs {inherit system;};
|
||||
}
|
||||
);
|
||||
in {
|
||||
devShells = forEachSupportedSystem (
|
||||
{pkgs}: {
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
crystal
|
||||
shards
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
6
lib/.shards.info
Normal file
6
lib/.shards.info
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
version: 1.0
|
||||
shards:
|
||||
discordcr:
|
||||
git: https://github.com/shardlab/discordcr.git
|
||||
version: 0.4.0+git.commit.0e03deb8ffa247814f2fec4e197cba7a62534f85
|
||||
32
lib/discordcr/.github/workflows/build_examples.yml
vendored
Normal file
32
lib/discordcr/.github/workflows/build_examples.yml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: MeilCli/setup-crystal-action@v4
|
||||
with:
|
||||
crystal_version: 1.2.1
|
||||
shards_version: 0.16.0
|
||||
- name: Install dependencies
|
||||
run: shards install
|
||||
- name: Run tests
|
||||
run: crystal spec
|
||||
- name: Run crystal tool format
|
||||
run: crystal tool format --check
|
||||
- name: Build examples
|
||||
run: find examples -name "*.cr" | xargs -L 1 crystal build --no-codegen
|
||||
34
lib/discordcr/.github/workflows/deploy_docs.yml
vendored
Normal file
34
lib/discordcr/.github/workflows/deploy_docs.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
paths-ignore:
|
||||
- "CHANGELOG.md"
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: MeilCli/setup-crystal-action@v4
|
||||
with:
|
||||
crystal_version: 1.2.1
|
||||
shards_version: 0.16.0
|
||||
- name: Install dependencies
|
||||
run: shards install
|
||||
- name: Run crystal doc
|
||||
run: crystal doc
|
||||
- name: Extract branch name
|
||||
shell: bash
|
||||
run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})"
|
||||
id: extract_branch
|
||||
- name: Deploy to gh-pages
|
||||
uses: peaceiris/actions-gh-pages@v3.7.0-8
|
||||
with:
|
||||
personal_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: docs
|
||||
destination_dir: ${{ steps.extract_branch.outputs.branch }}
|
||||
|
||||
14
lib/discordcr/.gitignore
vendored
Normal file
14
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
lib/discordcr/.travis.yml
Normal file
10
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
lib/discordcr/LICENSE
Normal file
21
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.
|
||||
101
lib/discordcr/README.md
Normal file
101
lib/discordcr/README.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
[](https://dcr.shardlab.dev/v0.4.0/) [](https://dcr.shardlab.dev/master/)
|
||||
|
||||
### Important Notice
|
||||
This is the **new official source of discordcr**!\
|
||||
The [old repo](https://github.com/discordcr/discordcr) has not been updated for countless months
|
||||
and new features, along with library breaking changes, are coming fast.\
|
||||
There is no guarantee any code/updates will be pushed to the old repo again, so as it stands,\
|
||||
this will be where all new code will be pushed and where all new PRs and Issues should be created.
|
||||
|
||||
Thanks!
|
||||
|
||||
# discordcr
|
||||
|
||||
(The "cr" stands for "creative name".)
|
||||
|
||||
discordcr is a minimalist [Discord](https://discord.com/) API library for
|
||||
[Crystal](https://crystal-lang.org/), designed to be a complement to
|
||||
[discordrb](https://github.com/shardlab/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/shardlab/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: shardlab/discordcr
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
An example bot can be found
|
||||
[here](https://github.com/shardlab/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://dcr.shardlab.dev/v0.4.0 for v0.4.0
|
||||
|
||||
https://dcr.shardlab.dev/master for latest
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork it (https://github.com/shardlab/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
|
||||
- [PixeL](https://github.com/PixelInc) - Maintainer
|
||||
78
lib/discordcr/deploy.sh
Normal file
78
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
lib/discordcr/deploy_key.enc
Normal file
BIN
lib/discordcr/deploy_key.enc
Normal file
Binary file not shown.
50
lib/discordcr/examples/mention_parser.cr
Normal file
50
lib/discordcr/examples/mention_parser.cr
Normal file
|
|
@ -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
|
||||
37
lib/discordcr/examples/multicommand.cr
Normal file
37
lib/discordcr/examples/multicommand.cr
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# 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.utc.to_s("%D"))
|
||||
else
|
||||
# Ignore.
|
||||
end
|
||||
end
|
||||
|
||||
client.run
|
||||
14
lib/discordcr/examples/ping.cr
Normal file
14
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
|
||||
18
lib/discordcr/examples/ping_with_response_time.cr
Normal file
18
lib/discordcr/examples/ping_with_response_time.cr
Normal file
|
|
@ -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 - payload.timestamp
|
||||
client.edit_message(m.channel_id, m.id, "Pong! Time taken: #{time.total_milliseconds} ms.")
|
||||
end
|
||||
end
|
||||
|
||||
client.run
|
||||
160
lib/discordcr/examples/voice_send.cr
Normal file
160
lib/discordcr/examples/voice_send.cr
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# 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)
|
||||
cache = Discord::Cache.new(client)
|
||||
client.cache = cache
|
||||
|
||||
# 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? "!vs"
|
||||
# Used as:
|
||||
# !vs
|
||||
|
||||
reply = begin
|
||||
vs = cache.resolve_voice_state(payload.guild_id.not_nil!, payload.author.id)
|
||||
vc = cache.resolve_channel(vs.channel_id.not_nil!)
|
||||
|
||||
# The voice region will be nil if the channel is set to automatically determine it.
|
||||
rtc_region = vc.rtc_region || "Automatic"
|
||||
|
||||
"You are connected to channel #{vs.channel_id} (region: #{rtc_region}) at guild #{vs.guild_id}"
|
||||
rescue
|
||||
"No voice state"
|
||||
end
|
||||
client.create_message(payload.channel_id, reply)
|
||||
elsif payload.content.starts_with? "!vr"
|
||||
# Used as:
|
||||
# !vr [guild ID]
|
||||
|
||||
regions : Array(Discord::VoiceRegion) = if payload.content.size > 4
|
||||
id = payload.content[4..-1]
|
||||
client.get_guild_voice_regions(id.to_u64)
|
||||
else
|
||||
client.list_voice_regions
|
||||
end
|
||||
|
||||
client.create_message(payload.channel_id, "Voice Regions: #{regions.map(&.name).join(", ")}")
|
||||
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
lib/discordcr/examples/welcome.cr
Normal file
17
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
|
||||
1
lib/discordcr/lib
Symbolic link
1
lib/discordcr/lib
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
..
|
||||
10
lib/discordcr/shard.yml
Normal file
10
lib/discordcr/shard.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
name: discordcr
|
||||
version: 0.4.0
|
||||
crystal: 1.0.0
|
||||
|
||||
authors:
|
||||
- meew0 <blactbt@live.de>
|
||||
- Chris Hobbs (RX14) <chris@rx14.co.uk>
|
||||
- z64 <zachnowicki@gmail.com>
|
||||
|
||||
license: MIT
|
||||
216
lib/discordcr/spec/cdn_spec.cr
Normal file
216
lib/discordcr/spec/cdn_spec.cr
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
require "./spec_helper"
|
||||
|
||||
describe Discord::CDN do
|
||||
it "builds a custom emoji URL" do
|
||||
url = Discord::CDN.custom_emoji(1, :png, 16)
|
||||
url.should eq "https://cdn.discordapp.com/emojis/1.png?size=16"
|
||||
end
|
||||
|
||||
it "builds a guild icon URL" do
|
||||
url = Discord::CDN.guild_icon(1, "hash", :png, 16)
|
||||
url.should eq "https://cdn.discordapp.com/icons/1/hash.png?size=16"
|
||||
end
|
||||
|
||||
it "builds a guild splash URL" do
|
||||
url = Discord::CDN.guild_splash(1, "hash", :png, 16)
|
||||
url.should eq "https://cdn.discordapp.com/splashes/1/hash.png?size=16"
|
||||
end
|
||||
|
||||
it "builds a default user avatar URL" do
|
||||
url = Discord::CDN.default_user_avatar("0001")
|
||||
url.should eq "https://cdn.discordapp.com/embed/avatars/1.png"
|
||||
|
||||
url = Discord::CDN.default_user_avatar("0007")
|
||||
url.should eq "https://cdn.discordapp.com/embed/avatars/2.png"
|
||||
end
|
||||
|
||||
describe "user_avatar" do
|
||||
it "builds a user avatar URL" do
|
||||
url = Discord::CDN.user_avatar(1, "hash", :png, 16)
|
||||
url.should eq "https://cdn.discordapp.com/avatars/1/hash.png?size=16"
|
||||
end
|
||||
|
||||
context "without format" do
|
||||
it "detects an animated avatar" do
|
||||
url = Discord::CDN.user_avatar(1_u64, "a_hash", 16)
|
||||
url.should eq "https://cdn.discordapp.com/avatars/1/a_hash.gif?size=16"
|
||||
end
|
||||
|
||||
it "defaults to webp" do
|
||||
url = Discord::CDN.user_avatar(1_u64, "hash", 16)
|
||||
url.should eq "https://cdn.discordapp.com/avatars/1/hash.webp?size=16"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "builds an application icon URL" do
|
||||
url = Discord::CDN.application_icon(1, "hash", :png, 16)
|
||||
url.should eq "https://cdn.discordapp.com/app-icons/1/hash.png?size=16"
|
||||
end
|
||||
|
||||
it "builds an application asset URL" do
|
||||
url = Discord::CDN.application_asset(1, 2, :png, 16)
|
||||
url.should eq "https://cdn.discordapp.com/app-assets/1/2.png?size=16"
|
||||
end
|
||||
|
||||
it "raises on an invalid size" do
|
||||
expect_raises(ArgumentError, "Size 17 is not between 16 and 2048 and a power of 2") do
|
||||
Discord::CDN.custom_emoji(1, :png, 17)
|
||||
end
|
||||
|
||||
expect_raises(ArgumentError, "Size 0 is not between 16 and 2048 and a power of 2") do
|
||||
Discord::CDN.custom_emoji(1, :png, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::User do
|
||||
user_with_default_avatar = Discord::User.from_json <<-JSON
|
||||
{
|
||||
"id": "1",
|
||||
"username": "foo",
|
||||
"avatar": null,
|
||||
"discriminator": "0007"
|
||||
}
|
||||
JSON
|
||||
|
||||
user_with_avatar = Discord::User.from_json <<-JSON
|
||||
{
|
||||
"id": "1",
|
||||
"username": "foo",
|
||||
"avatar": "hash",
|
||||
"discriminator": "0007"
|
||||
}
|
||||
JSON
|
||||
|
||||
user_with_animated_avatar = Discord::User.from_json <<-JSON
|
||||
{
|
||||
"id": "1",
|
||||
"username": "foo",
|
||||
"avatar": "a_hash",
|
||||
"discriminator": "0007"
|
||||
}
|
||||
JSON
|
||||
|
||||
describe "#avatar_url" do
|
||||
it "returns avatar URL with the given format and size" do
|
||||
user = user_with_avatar
|
||||
user.avatar_url(:png, 16).should eq Discord::CDN.user_avatar(user.id, user.avatar.not_nil!, :png, 16)
|
||||
end
|
||||
|
||||
it "returns default avatar URL with the given format and size" do
|
||||
user = user_with_default_avatar
|
||||
user.avatar_url(:png, 16).should eq Discord::CDN.default_user_avatar(user.discriminator)
|
||||
end
|
||||
|
||||
context "without format" do
|
||||
it "returns default avatar URL" do
|
||||
user = user_with_default_avatar
|
||||
user.avatar_url.should eq Discord::CDN.default_user_avatar(user.discriminator)
|
||||
end
|
||||
|
||||
it "returns avatar URL" do
|
||||
user = user_with_avatar
|
||||
user.avatar_url.should eq Discord::CDN.user_avatar(user.id, user.avatar.not_nil!)
|
||||
end
|
||||
|
||||
it "returns animated avatar URL" do
|
||||
user = user_with_animated_avatar
|
||||
user.avatar_url.should eq Discord::CDN.user_avatar(user.id, user.avatar.not_nil!)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::Guild do
|
||||
guild_with_icon_and_splash = Discord::Guild.from_json <<-JSON
|
||||
{
|
||||
"id": "1",
|
||||
"name": "name",
|
||||
"icon": "hash",
|
||||
"splash": "hash",
|
||||
"owner_id": "2",
|
||||
"region": "region",
|
||||
"verification_level": 1,
|
||||
"roles": [],
|
||||
"emojis": [],
|
||||
"features": [],
|
||||
"default_message_notifications": 1,
|
||||
"explicit_content_filter": 1,
|
||||
"premium_tier": 0
|
||||
}
|
||||
JSON
|
||||
|
||||
it "#icon_url" do
|
||||
guild = guild_with_icon_and_splash
|
||||
guild.icon_url(:png, 16).should eq Discord::CDN.guild_icon(guild.id, guild.icon.not_nil!, :png, 16)
|
||||
end
|
||||
|
||||
it "#splash_url" do
|
||||
guild = guild_with_icon_and_splash
|
||||
guild.splash_url(:png, 16).should eq Discord::CDN.guild_splash(guild.id, guild.splash.not_nil!, :png, 16)
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::Emoji do
|
||||
emoji = Discord::Emoji.from_json <<-JSON
|
||||
{
|
||||
"id": "1",
|
||||
"name": "name",
|
||||
"roles": [],
|
||||
"require_colons": true,
|
||||
"managed": false,
|
||||
"animated": false
|
||||
}
|
||||
JSON
|
||||
|
||||
animated_emoji = Discord::Emoji.from_json <<-JSON
|
||||
{
|
||||
"id": "1",
|
||||
"name": "name",
|
||||
"roles": [],
|
||||
"require_colons": true,
|
||||
"managed": false,
|
||||
"animated": true
|
||||
}
|
||||
JSON
|
||||
|
||||
describe "#image_url" do
|
||||
it "returns an image URL with given format and size" do
|
||||
emoji.image_url(:png, 16).should eq Discord::CDN.custom_emoji(emoji.id.not_nil!, :png, 16)
|
||||
end
|
||||
|
||||
context "without format" do
|
||||
it "returns a webp, or gif if animated" do
|
||||
emoji.image_url.should eq Discord::CDN.custom_emoji(emoji.id.not_nil!, :png, 128)
|
||||
animated_emoji.image_url.should eq Discord::CDN.custom_emoji(animated_emoji.id.not_nil!, :gif, 128)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::OAuth2Application do
|
||||
describe "#icon_url" do
|
||||
application_with_icon = Discord::OAuth2Application.from_json <<-JSON
|
||||
{
|
||||
"id": "1",
|
||||
"name": "name",
|
||||
"icon": "hash",
|
||||
"bot_public": true,
|
||||
"bot_require_code_grant": false,
|
||||
"owner": {
|
||||
"id": "1",
|
||||
"username": "username",
|
||||
"discriminator": "0001"
|
||||
},
|
||||
"summary": "some summary",
|
||||
"verify_key": "key"
|
||||
}
|
||||
JSON
|
||||
|
||||
it "returns a CDN URL with the given format and size" do
|
||||
application = application_with_icon
|
||||
application.icon_url(:png, 16).should eq Discord::CDN.application_icon(application.id, application.icon.not_nil!, :png, 16)
|
||||
end
|
||||
end
|
||||
end
|
||||
139
lib/discordcr/spec/discordcr_spec.cr
Normal file
139
lib/discordcr/spec/discordcr_spec.cr
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
require "yaml"
|
||||
require "./spec_helper"
|
||||
|
||||
struct StructWithTime
|
||||
include JSON::Serializable
|
||||
|
||||
@[JSON::Field(converter: Discord::TimestampConverter)]
|
||||
property data : Time
|
||||
end
|
||||
|
||||
struct StructWithMaybeTime
|
||||
include JSON::Serializable
|
||||
|
||||
@[JSON::Field(converter: Discord::MaybeTimestampConverter, emit_null: true)]
|
||||
property data : Time?
|
||||
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
|
||||
|
||||
it "raises on null" do
|
||||
json = %({"data":null})
|
||||
expect_raises(JSON::ParseException) do
|
||||
StructWithTime.from_json(json)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::MaybeTimestampConverter do
|
||||
it "parses a time" do
|
||||
json = %({"data":"2017-11-16T13:09:18.291000+00:00"})
|
||||
StructWithMaybeTime.from_json(json).data.should be_a Time
|
||||
end
|
||||
|
||||
it "parses null" do
|
||||
json = %({"data":null})
|
||||
StructWithMaybeTime.from_json(json).data.should be_nil
|
||||
end
|
||||
|
||||
it "serializes a time" do
|
||||
json = %({"data":"2017-11-16T13:09:18.291000+00:00"})
|
||||
obj = StructWithMaybeTime.from_json(json)
|
||||
obj.to_json.should eq json
|
||||
end
|
||||
|
||||
it "serializes null" do
|
||||
json = %({"data":null})
|
||||
obj = StructWithMaybeTime.from_json(json)
|
||||
obj.to_json.should eq json
|
||||
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::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 @sequence=1 @data="foo" @event_type="test"))
|
||||
end
|
||||
|
||||
it "serializes" do
|
||||
json = %({"op":0,"s":1,"d":"foo","t":"test"})
|
||||
packet = Discord::WebSocket::Packet.new(0_i64, 1_i64, IO::Memory.new(%("foo")), "test")
|
||||
packet.to_json.should eq json
|
||||
end
|
||||
|
||||
it "parses" do
|
||||
json = %({"op":0,"s":1,"d":"foo","t":"test"})
|
||||
packet = Discord::WebSocket::Packet.from_json(json)
|
||||
packet.opcode.should eq 0
|
||||
packet.sequence.should eq 1
|
||||
packet.data.to_s.should eq %("foo")
|
||||
packet.event_type.should eq "test"
|
||||
end
|
||||
end
|
||||
|
||||
describe Discord::TimeSpanMillisecondsConverter do
|
||||
it ".from_json" do
|
||||
parser = JSON::PullParser.new("300")
|
||||
span = Discord::TimeSpanMillisecondsConverter.from_json(parser)
|
||||
span.should eq 300.milliseconds
|
||||
end
|
||||
|
||||
it ".to_json" do
|
||||
json = JSON.build do |builder|
|
||||
Discord::TimeSpanMillisecondsConverter.to_json(300.milliseconds, builder)
|
||||
end
|
||||
json.should eq "300"
|
||||
end
|
||||
end
|
||||
|
||||
it ".shard_id" do
|
||||
part = 3_u64 << 22
|
||||
shard = Discord.shard_id(part, 2)
|
||||
shard.should eq 1
|
||||
end
|
||||
end
|
||||
50
lib/discordcr/spec/mention_spec.cr
Normal file
50
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, 7)])
|
||||
|
||||
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
|
||||
63
lib/discordcr/spec/paginator_spec.cr
Normal file
63
lib/discordcr/spec/paginator_spec.cr
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
require "./spec_helper"
|
||||
|
||||
describe Discord::Paginator do
|
||||
context "direction up" do
|
||||
it "requests all pages until empty" do
|
||||
data = {
|
||||
[1, 2, 3],
|
||||
[4, 5],
|
||||
[] of Int32,
|
||||
[6, 7],
|
||||
}
|
||||
|
||||
index = 0
|
||||
paginator = Discord::Paginator(Int32).new(nil, :down) do |last_page|
|
||||
if last_page
|
||||
last_page.should eq data[index - 1]
|
||||
end
|
||||
index += 1
|
||||
data[index - 1]
|
||||
end
|
||||
|
||||
paginator.to_a.should eq [1, 2, 3, 4, 5]
|
||||
end
|
||||
end
|
||||
|
||||
context "direction down" do
|
||||
it "requests all pages until empty" do
|
||||
data = {
|
||||
[6, 7],
|
||||
[4, 5],
|
||||
[] of Int32,
|
||||
[1, 2, 3],
|
||||
}
|
||||
|
||||
index = 0
|
||||
paginator = Discord::Paginator(Int32).new(nil, :up) do |last_page|
|
||||
if last_page
|
||||
last_page.should eq data[index - 1]
|
||||
end
|
||||
index += 1
|
||||
data[index - 1]
|
||||
end
|
||||
|
||||
paginator.to_a.should eq [7, 6, 5, 4]
|
||||
end
|
||||
end
|
||||
|
||||
it "only returns up to limit items" do
|
||||
data = {
|
||||
[1, 2, 3],
|
||||
[4, 5],
|
||||
[] of Int32,
|
||||
}
|
||||
|
||||
index = 0
|
||||
paginator = Discord::Paginator(Int32).new(2, :down) do |last_page|
|
||||
index += 1
|
||||
data[index - 1]
|
||||
end
|
||||
|
||||
paginator.to_a.should eq [1, 2]
|
||||
end
|
||||
end
|
||||
10
lib/discordcr/spec/rest_spec.cr
Normal file
10
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
|
||||
57
lib/discordcr/spec/snowflake_spec.cr
Normal file
57
lib/discordcr/spec/snowflake_spec.cr
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
require "./spec_helper"
|
||||
|
||||
describe Discord::Snowflake do
|
||||
describe Discord::DISCORD_EPOCH do
|
||||
it "is 2015-01-01" do
|
||||
expected = Time.utc(2015, 1, 1)
|
||||
Discord::DISCORD_EPOCH.should eq expected.to_unix_ms
|
||||
end
|
||||
end
|
||||
|
||||
it "#to_json" do
|
||||
snowflake = Discord::Snowflake.new(0_u64)
|
||||
json = JSON.build do |builder|
|
||||
snowflake.to_json(builder)
|
||||
end
|
||||
json.should eq %("0")
|
||||
end
|
||||
|
||||
it ".from_json" do
|
||||
parser = JSON::PullParser.new(%("0"))
|
||||
snowflake = Discord::Snowflake.new(parser)
|
||||
snowflake.value.should eq 0_u64
|
||||
end
|
||||
|
||||
describe Array(Discord::Snowflake) do
|
||||
it "can be sorted" do
|
||||
snowflake_a = Discord::Snowflake.new(2_u64)
|
||||
snowflake_b = Discord::Snowflake.new(1_u64)
|
||||
snowflake_c = Discord::Snowflake.new(0_u64)
|
||||
|
||||
array = [snowflake_a, snowflake_b, snowflake_c]
|
||||
array.sort.should eq [snowflake_c, snowflake_b, snowflake_a]
|
||||
end
|
||||
end
|
||||
|
||||
describe "#creation_time" do
|
||||
it "returns the time the snowflake was created" do
|
||||
time = Time.utc(2018, 4, 18)
|
||||
snowflake = Discord::Snowflake.new(time)
|
||||
snowflake.creation_time.should eq time
|
||||
end
|
||||
end
|
||||
|
||||
it "compares to uint64" do
|
||||
snowflake = Discord::Snowflake.new(1_u64)
|
||||
(snowflake == 1_u64).should be_true
|
||||
(snowflake == 0_u64).should be_false
|
||||
end
|
||||
end
|
||||
|
||||
describe UInt64 do
|
||||
it "compares to snowflake" do
|
||||
snowflake = Discord::Snowflake.new(1_u64)
|
||||
(1_u64 == snowflake).should be_true
|
||||
(0_u64 == snowflake).should be_false
|
||||
end
|
||||
end
|
||||
2
lib/discordcr/spec/spec_helper.cr
Normal file
2
lib/discordcr/spec/spec_helper.cr
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
require "spec"
|
||||
require "../src/discordcr"
|
||||
48
lib/discordcr/spec/voice_spec.cr
Normal file
48
lib/discordcr/spec/voice_spec.cr
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
require "./spec_helper"
|
||||
|
||||
private def with_voice_udp
|
||||
server = UDPSocket.new
|
||||
server.bind("localhost", 0)
|
||||
port = server.local_address.port
|
||||
client = Discord::VoiceUDP.new
|
||||
client.connect("localhost", port.to_u32, 1_u32)
|
||||
yield server, client
|
||||
server.close
|
||||
client.socket.close
|
||||
end
|
||||
|
||||
describe Discord::VoiceUDP do
|
||||
it "sends discovery" do
|
||||
with_voice_udp do |server, client|
|
||||
client.send_discovery
|
||||
data = Bytes.new(74)
|
||||
server.receive(data)
|
||||
data[4, 4].should eq Bytes[0, 0, 0, 1]
|
||||
end
|
||||
end
|
||||
|
||||
it "receives discovery reply" do
|
||||
with_voice_udp do |server, client|
|
||||
io = IO::Memory.new
|
||||
io.write Bytes.new(8)
|
||||
io.print("ip address".ljust(64, '\0'))
|
||||
io.write_bytes(2_u16, IO::ByteFormat::BigEndian)
|
||||
data = io.to_slice
|
||||
server.send(data, to: client.socket.local_address)
|
||||
|
||||
ip, port = client.receive_discovery_reply
|
||||
ip.should eq "ip address"
|
||||
port.should eq 2_u16
|
||||
end
|
||||
end
|
||||
|
||||
it "creates voice header" do
|
||||
with_voice_udp do |server, client|
|
||||
data = client.create_header(1_u16, 2_u32)
|
||||
data[0, 2].should eq Bytes[0x80, 0x78]
|
||||
data[2, 2].should eq Bytes[0, 1]
|
||||
data[4, 4].should eq Bytes[0, 0, 0, 2]
|
||||
data[8, 4].should eq Bytes[0, 0, 0, 1]
|
||||
end
|
||||
end
|
||||
end
|
||||
6
lib/discordcr/src/discordcr.cr
Normal file
6
lib/discordcr/src/discordcr.cr
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
require "log"
|
||||
require "./discordcr/*"
|
||||
|
||||
module Discord
|
||||
Log = ::Log.for("discord")
|
||||
end
|
||||
354
lib/discordcr/src/discordcr/cache.cr
Normal file
354
lib/discordcr/src/discordcr/cache.cr
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
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 map of cached stage instances, i. e. all stage instances on all servers
|
||||
# the bot is on, represented as {channel ID => Stage instance}.
|
||||
getter stage_instances
|
||||
|
||||
# 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
|
||||
|
||||
# Mapping of guilds to the channels with Stage instances on them, represented as {guild ID =>
|
||||
# [channel IDs]}.
|
||||
getter guild_stage_instances
|
||||
|
||||
# Mapping of users in guild to voice states, represented as {guild ID =>
|
||||
# {user ID => voice state}}
|
||||
getter voice_states
|
||||
|
||||
# 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
|
||||
@stage_instances = Hash(UInt64, StageInstance).new
|
||||
|
||||
@dm_channels = Hash(UInt64, UInt64).new
|
||||
|
||||
@guild_roles = Hash(UInt64, Array(UInt64)).new
|
||||
@guild_channels = Hash(UInt64, Array(UInt64)).new
|
||||
@guild_stage_instances = Hash(UInt64, Array(UInt64)).new
|
||||
|
||||
@voice_states = Hash(UInt64, Hash(UInt64, VoiceState)).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 | Snowflake) : User
|
||||
id = id.to_u64
|
||||
@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 | Snowflake) : Channel
|
||||
id = id.to_u64
|
||||
@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 | Snowflake) : Guild
|
||||
id = id.to_u64
|
||||
@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 | Snowflake, user_id : UInt64 | Snowflake) : GuildMember
|
||||
guild_id = guild_id.to_u64
|
||||
user_id = user_id.to_u64
|
||||
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 | Snowflake) : Role
|
||||
@roles[id.to_u64] # There is no endpoint for getting an individual role, so we will have to ignore that case for now.
|
||||
end
|
||||
|
||||
# Resolves a Stage instance by the *channel ID* it is on.
|
||||
# An API request will be performed if the object is not cached.
|
||||
def resolve_stage_instance(channel_id : UInt64 | Snowflake) : StageInstance
|
||||
channel_id = channel_id.to_u64
|
||||
@stage_instances.fetch(channel_id) do
|
||||
stage_instance = @client.get_stage_instance(channel_id)
|
||||
cache(stage_instance)
|
||||
add_guild_stage_instance(stage_instance.guild_id, stage_instance.channel_id)
|
||||
stage_instance
|
||||
end
|
||||
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 | Snowflake) : UInt64
|
||||
recipient_id = recipient_id.to_u64
|
||||
@dm_channels.fetch(recipient_id) do
|
||||
channel = @client.create_dm(recipient_id)
|
||||
cache(Channel.new(channel))
|
||||
@dm_channels[recipient_id] = channel.id.to_u64
|
||||
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
|
||||
|
||||
# Resolves a voice state by *guild ID* and *user ID*. No API request will be
|
||||
# performed if voice state is not cached, because there is no endpoint for
|
||||
# it. If there is a gateway connection this should always be cached.
|
||||
def resolve_voice_state(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake) : VoiceState
|
||||
@voice_states[guild_id.to_u64][user_id.to_u64]
|
||||
end
|
||||
|
||||
# Deletes a user from the cache given its *ID*.
|
||||
def delete_user(id : UInt64 | Snowflake)
|
||||
@users.delete(id.to_u64)
|
||||
end
|
||||
|
||||
# Deletes a channel from the cache given its *ID*.
|
||||
def delete_channel(id : UInt64 | Snowflake)
|
||||
@channels.delete(id.to_u64)
|
||||
end
|
||||
|
||||
# Deletes a guild from the cache given its *ID*.
|
||||
def delete_guild(id : UInt64 | Snowflake)
|
||||
@guilds.delete(id.to_u64)
|
||||
end
|
||||
|
||||
# Deletes a stage instance from the cache given the *channel_id* it belongs to.
|
||||
def delete_stage_instance(channel_id : UInt64 | Snowflake)
|
||||
@stage_instances.delete(channel_id.to_u64)
|
||||
end
|
||||
|
||||
# Deletes a member from the cache given its *user_id* and the *guild_id* it
|
||||
# is on.
|
||||
def delete_member(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake)
|
||||
guild_id = guild_id.to_u64
|
||||
user_id = user_id.to_u64
|
||||
@members[guild_id]?.try &.delete(user_id)
|
||||
end
|
||||
|
||||
# Deletes a role from the cache given its *ID*.
|
||||
def delete_role(id : UInt64 | Snowflake)
|
||||
@roles.delete(id.to_u64)
|
||||
end
|
||||
|
||||
# Deletes a DM channel with a particular user given the *recipient_id*.
|
||||
def delete_dm_channel(recipient_id : UInt64 | Snowflake)
|
||||
@dm_channels.delete(recipient_id.to_u64)
|
||||
end
|
||||
|
||||
# Deletes the current user from the cache, if that will ever be necessary.
|
||||
def delete_current_user
|
||||
@current_user = nil
|
||||
end
|
||||
|
||||
# Deletes voice state for user in guild from cache.
|
||||
def delete_voice_state(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake)
|
||||
guild_id = guild_id.to_u64
|
||||
user_id = user_id.to_u64
|
||||
@voice_states[guild_id]?.try &.delete(user_id)
|
||||
end
|
||||
|
||||
# Adds a specific *user* to the cache.
|
||||
def cache(user : User)
|
||||
@users[user.id.to_u64] = user
|
||||
end
|
||||
|
||||
# Adds a specific *channel* to the cache.
|
||||
def cache(channel : Channel)
|
||||
@channels[channel.id.to_u64] = channel
|
||||
end
|
||||
|
||||
# Adds a specific *guild* to the cache.
|
||||
def cache(guild : Guild)
|
||||
@guilds[guild.id.to_u64] = guild
|
||||
end
|
||||
|
||||
# Adds a specific *member* to the cache, given the *guild_id* it is on.
|
||||
def cache(member : GuildMember, guild_id : UInt64 | Snowflake)
|
||||
guild_id = guild_id.to_u64
|
||||
local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new
|
||||
local_members[member.user.id.to_u64] = member
|
||||
end
|
||||
|
||||
# Adds a specific *role* to the cache.
|
||||
def cache(role : Role)
|
||||
@roles[role.id.to_u64] = role
|
||||
end
|
||||
|
||||
# Adds a specific *Stage instance* to the cache.
|
||||
def cache(stage_instance : StageInstance)
|
||||
@stage_instances[stage_instance.channel_id.to_u64] = stage_instance
|
||||
end
|
||||
|
||||
# Adds a specific *voice state* to the cache.
|
||||
def cache(voice_state : VoiceState)
|
||||
user_id = voice_state.user_id.to_u64
|
||||
guild_id = voice_state.guild_id.not_nil!.to_u64
|
||||
user_voice_states = @voice_states[guild_id] ||= Hash(UInt64, VoiceState).new
|
||||
user_voice_states[user_id] = voice_state
|
||||
end
|
||||
|
||||
# Adds a particular DM channel to the cache, given the *channel_id* and the
|
||||
# *recipient_id*.
|
||||
def cache_dm_channel(channel_id : UInt64 | Snowflake, recipient_id : UInt64 | Snowflake)
|
||||
channel_id = channel_id.to_u64
|
||||
recipient_id = recipient_id.to_u64
|
||||
@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 | Snowflake)
|
||||
guild_id = guild_id.to_u64
|
||||
local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new
|
||||
members.each do |member|
|
||||
local_members[member.user.id.to_u64] = member
|
||||
end
|
||||
end
|
||||
|
||||
# Returns all roles of a guild, identified by its *guild_id*.
|
||||
def guild_roles(guild_id : UInt64 | Snowflake) : Array(UInt64)
|
||||
@guild_roles[guild_id.to_u64]
|
||||
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 | Snowflake, role_id : UInt64 | Snowflake)
|
||||
guild_id = guild_id.to_u64
|
||||
role_id = role_id.to_u64
|
||||
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 | Snowflake, role_id : UInt64 | Snowflake)
|
||||
guild_id = guild_id.to_u64
|
||||
role_id = role_id.to_u64
|
||||
@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 | Snowflake) : Array(UInt64)
|
||||
@guild_channels[guild_id.to_u64]
|
||||
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 | Snowflake, channel_id : UInt64 | Snowflake)
|
||||
guild_id = guild_id.to_u64
|
||||
channel_id = channel_id.to_u64
|
||||
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 | Snowflake, channel_id : UInt64 | Snowflake)
|
||||
guild_id = guild_id.to_u64
|
||||
channel_id = channel_id.to_u64
|
||||
@guild_channels[guild_id]?.try { |local_channels| local_channels.delete(channel_id) }
|
||||
end
|
||||
|
||||
# Returns all Stage instances of a guild, identified by its *guild_id*.
|
||||
def guild_stage_instances(guild_id : UInt64 | Snowflake) : Array(UInt64)
|
||||
@guild_stage_instances[guild_id.to_u64]
|
||||
end
|
||||
|
||||
# Marks a Stage instance, identified by the *channel_id* it is on, as belonging to a particular
|
||||
# guild, identified by the *guild_id*.
|
||||
def add_guild_stage_instance(guild_id : UInt64 | Snowflake, channel_id : UInt64 | Snowflake)
|
||||
guild_id = guild_id.to_u64
|
||||
channel_id = channel_id.to_u64
|
||||
local_instances = @guild_stage_instances[guild_id] ||= [] of UInt64
|
||||
local_instances << channel_id
|
||||
end
|
||||
|
||||
# Marks a Stage instance, identified by the *channel_id* it is on, as not belonging to a particular
|
||||
# guild, identified by the *guild_id*, anymore.
|
||||
def remove_guild_stage_instance(guild_id : UInt64 | Snowflake, channel_id : UInt64 | Snowflake)
|
||||
guild_id = guild_id.to_u64
|
||||
channel_id = channel_id.to_u64
|
||||
@guild_stage_instances[guild_id]?.try { |local_instances| local_instances.delete(channel_id) }
|
||||
end
|
||||
end
|
||||
end
|
||||
215
lib/discordcr/src/discordcr/cdn.cr
Normal file
215
lib/discordcr/src/discordcr/cdn.cr
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# This module contains methods for building URLs to resources on Discord's CDN
|
||||
# for things like guild icons and avatars.
|
||||
#
|
||||
# NOTE: All `size` arguments for CDN methods must be a power of 2 between 16
|
||||
# and 2048. If an invalid size is given, `ArgumentError` will be raised.
|
||||
#
|
||||
# [API Documentation for image formatting](https://discord.com/developers/docs/reference#image-formatting)
|
||||
module Discord::CDN
|
||||
extend self
|
||||
|
||||
# Base CDN URL
|
||||
BASE_URL = "https://cdn.discordapp.com"
|
||||
|
||||
# Available image formats for custom emoji
|
||||
enum CustomEmojiFormat
|
||||
PNG
|
||||
GIF
|
||||
|
||||
def to_s
|
||||
case self
|
||||
when PNG
|
||||
"png"
|
||||
when GIF
|
||||
"gif"
|
||||
end
|
||||
end
|
||||
|
||||
def to_s(io : IO)
|
||||
io << to_s
|
||||
end
|
||||
end
|
||||
|
||||
# Available image formats for guild icons
|
||||
enum GuildIconFormat
|
||||
PNG
|
||||
JPEG
|
||||
WebP
|
||||
|
||||
def to_s
|
||||
case self
|
||||
when PNG
|
||||
"png"
|
||||
when JPEG
|
||||
"jpeg"
|
||||
when WebP
|
||||
"webp"
|
||||
end
|
||||
end
|
||||
|
||||
def to_s(io : IO)
|
||||
io << to_s
|
||||
end
|
||||
end
|
||||
|
||||
# Available image formats for guild splashes
|
||||
enum GuildSplashFormat
|
||||
PNG
|
||||
JPEG
|
||||
WebP
|
||||
|
||||
def to_s
|
||||
case self
|
||||
when PNG
|
||||
"png"
|
||||
when JPEG
|
||||
"jpeg"
|
||||
when WebP
|
||||
"webp"
|
||||
end
|
||||
end
|
||||
|
||||
def to_s(io : IO)
|
||||
io << to_s
|
||||
end
|
||||
end
|
||||
|
||||
# Available image formats for user avatars
|
||||
enum UserAvatarFormat
|
||||
PNG
|
||||
JPEG
|
||||
WebP
|
||||
GIF
|
||||
|
||||
def to_s
|
||||
case self
|
||||
when PNG
|
||||
"png"
|
||||
when JPEG
|
||||
"jpeg"
|
||||
when WebP
|
||||
"webp"
|
||||
when GIF
|
||||
"gif"
|
||||
end
|
||||
end
|
||||
|
||||
def to_s(io : IO)
|
||||
io << to_s
|
||||
end
|
||||
end
|
||||
|
||||
# Available image formats for application icons
|
||||
enum ApplicationIconFormat
|
||||
PNG
|
||||
JPEG
|
||||
WebP
|
||||
GIF
|
||||
|
||||
def to_s
|
||||
case self
|
||||
when PNG
|
||||
"png"
|
||||
when JPEG
|
||||
"jpeg"
|
||||
when WebP
|
||||
"webp"
|
||||
when GIF
|
||||
"gif"
|
||||
end
|
||||
end
|
||||
|
||||
def to_s(io : IO)
|
||||
io << to_s
|
||||
end
|
||||
end
|
||||
|
||||
enum ApplicationAssetFormat
|
||||
PNG
|
||||
JPEG
|
||||
WebP
|
||||
|
||||
def to_s
|
||||
case self
|
||||
when PNG
|
||||
"png"
|
||||
when JPEG
|
||||
"jpeg"
|
||||
when WebP
|
||||
"webp"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private def check_size(value : Int32)
|
||||
in_range = (16..2048).includes?(value)
|
||||
power_of_two = (value > 0) && ((value & (value - 1)) == 0)
|
||||
unless in_range && power_of_two
|
||||
raise ArgumentError.new("Size #{value} is not between 16 and 2048 and a power of 2")
|
||||
end
|
||||
end
|
||||
|
||||
# Produces a CDN URL for a custom emoji in the given `format` and `size`
|
||||
def custom_emoji(id : UInt64 | Snowflake,
|
||||
format : CustomEmojiFormat = CustomEmojiFormat::PNG,
|
||||
size : Int32 = 128)
|
||||
check_size(size)
|
||||
"#{BASE_URL}/emojis/#{id}.#{format}?size=#{size}"
|
||||
end
|
||||
|
||||
# Produces a CDN URL for a guild icon in the given `format` and `size`
|
||||
def guild_icon(id : UInt64 | Snowflake, icon : String,
|
||||
format : GuildIconFormat = GuildIconFormat::WebP,
|
||||
size : Int32 = 128)
|
||||
check_size(size)
|
||||
"#{BASE_URL}/icons/#{id}/#{icon}.#{format}?size=#{size}"
|
||||
end
|
||||
|
||||
# Produces a CDN URL for a guild splash in the given `format` and `size`
|
||||
def guild_splash(id : UInt64 | Snowflake, splash : String,
|
||||
format : GuildSplashFormat = GuildSplashFormat::WebP,
|
||||
size : Int32 = 128)
|
||||
check_size(size)
|
||||
"#{BASE_URL}/splashes/#{id}/#{splash}.#{format}?size=#{size}"
|
||||
end
|
||||
|
||||
# Produces a CDN URL for a default user avatar, calculated from the given
|
||||
# discriminator value.
|
||||
def default_user_avatar(user_discriminator : String)
|
||||
index = user_discriminator.to_i % 5
|
||||
"#{BASE_URL}/embed/avatars/#{index}.png"
|
||||
end
|
||||
|
||||
# Produces a CDN URL for a user avatar in the given `size`. Given the `avatar`
|
||||
# string, this will return a WebP or GIF based on the animated avatar hint.
|
||||
def user_avatar(id : UInt64 | Snowflake, avatar : String, size : Int32 = 128)
|
||||
if avatar.starts_with?("a_")
|
||||
user_avatar(id, avatar, UserAvatarFormat::GIF, size)
|
||||
else
|
||||
user_avatar(id, avatar, UserAvatarFormat::WebP, size)
|
||||
end
|
||||
end
|
||||
|
||||
# Produces a CDN URL for a user avatar in the given `format` and `size`
|
||||
def user_avatar(id : UInt64 | Snowflake, avatar : String,
|
||||
format : UserAvatarFormat, size : Int32 = 128)
|
||||
check_size(size)
|
||||
"#{BASE_URL}/avatars/#{id}/#{avatar}.#{format}?size=#{size}"
|
||||
end
|
||||
|
||||
# Produces a CDN URL for an application icon in the given `format` and `size`
|
||||
def application_icon(id : UInt64 | Snowflake, icon : String,
|
||||
format : ApplicationIconFormat = ApplicationIconFormat::WebP,
|
||||
size : Int32 = 128)
|
||||
check_size(size)
|
||||
"#{BASE_URL}/app-icons/#{id}/#{icon}.#{format}?size=#{size}"
|
||||
end
|
||||
|
||||
# Produces a CDN URL for an application asset in the given `format` and `size`
|
||||
def application_asset(application_id : UInt64 | Snowflake, asset_id : UInt64 | Snowflake,
|
||||
format : ApplicationAssetFormat = ApplicationAssetFormat::PNG,
|
||||
size : Int32 = 128)
|
||||
check_size(size)
|
||||
"#{BASE_URL}/app-assets/#{application_id}/#{asset_id}.#{format}?size=#{size}"
|
||||
end
|
||||
end
|
||||
1069
lib/discordcr/src/discordcr/client.cr
Normal file
1069
lib/discordcr/src/discordcr/client.cr
Normal file
File diff suppressed because it is too large
Load diff
150
lib/discordcr/src/discordcr/dca.cr
Normal file
150
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
|
||||
include JSON::Serializable
|
||||
|
||||
property dca : DCA
|
||||
property opus : Opus
|
||||
property info : Info?
|
||||
property origin : Origin?
|
||||
property extra : JSON::Any
|
||||
end
|
||||
|
||||
struct DCA
|
||||
include JSON::Serializable
|
||||
|
||||
property version : Int32
|
||||
property tool : Tool
|
||||
end
|
||||
|
||||
struct Tool
|
||||
include JSON::Serializable
|
||||
|
||||
property name : String
|
||||
property version : String
|
||||
property url : String?
|
||||
property author : String?
|
||||
end
|
||||
|
||||
struct Opus
|
||||
include JSON::Serializable
|
||||
|
||||
property mode : String
|
||||
property sample_rate : Int32
|
||||
property frame_size : Int32
|
||||
property abr : Int32?
|
||||
property vbr : Bool
|
||||
property channels : Int32
|
||||
end
|
||||
|
||||
struct Info
|
||||
include JSON::Serializable
|
||||
|
||||
property title : String?
|
||||
property artist : String?
|
||||
property album : String?
|
||||
property genre : String?
|
||||
property comments : String?
|
||||
property cover : String?
|
||||
end
|
||||
|
||||
struct Origin
|
||||
include JSON::Serializable
|
||||
|
||||
property source : String?
|
||||
property abr : Int32?
|
||||
property channels : Int32?
|
||||
property encoding : String?
|
||||
property url : String?
|
||||
end
|
||||
end
|
||||
end
|
||||
71
lib/discordcr/src/discordcr/errors.cr
Normal file
71
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
|
||||
include JSON::Serializable
|
||||
|
||||
property code : Int32
|
||||
property 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
|
||||
429
lib/discordcr/src/discordcr/mappings/channel.cr
Normal file
429
lib/discordcr/src/discordcr/mappings/channel.cr
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
require "./converters"
|
||||
|
||||
module Discord
|
||||
enum MessageType : UInt8
|
||||
Default = 0
|
||||
RecipientAdd = 1
|
||||
RecipientRemove = 2
|
||||
Call = 3
|
||||
ChannelNameChange = 4
|
||||
ChannelIconChange = 5
|
||||
ChannelPinnedMessage = 6
|
||||
GuildMemberJoin = 7
|
||||
UserPremiumGuildSubscription = 8
|
||||
UserPremiumGuildSubscriptionTier1 = 9
|
||||
UserPremiumGuildSubscriptionTier2 = 10
|
||||
UserPremiumGuildSubscriptionTier3 = 11
|
||||
ChannelFollowAdd = 12
|
||||
GuildDiscoveryDisqualified = 14
|
||||
GuildDiscoveryRequalified = 15
|
||||
GuildDiscoveryGracePeriodInitialWarning = 16
|
||||
GuildDiscoveryGracePeriodFinalWarning = 17
|
||||
ThreadCreated = 18
|
||||
Reply = 19
|
||||
ApplicationCommand = 20
|
||||
ThreadStarterMessage = 21
|
||||
GuildInviteReminder = 22
|
||||
|
||||
def self.new(pull : JSON::PullParser)
|
||||
MessageType.new(pull.read_int.to_u8)
|
||||
end
|
||||
end
|
||||
|
||||
@[Flags]
|
||||
enum MessageFlags : UInt32
|
||||
Crossposted = 1 << 0
|
||||
IsCrosspost = 1 << 1
|
||||
SuppressEmbeds = 1 << 2
|
||||
SourceMessageDeleted = 1 << 3
|
||||
Urgent = 1 << 4
|
||||
HasThread = 1 << 5
|
||||
Ephemeral = 1 << 6
|
||||
Loading = 1 << 7
|
||||
FailedToMentionSomeRolesInThread = 1 << 8
|
||||
|
||||
def self.new(pull : JSON::PullParser)
|
||||
MessageFlags.new(pull.read_int.to_u32)
|
||||
end
|
||||
end
|
||||
|
||||
enum AutoArchiveDuration : UInt16
|
||||
Hour = 60
|
||||
Day = 1440
|
||||
ThreeDays = 4320
|
||||
Week = 10080
|
||||
|
||||
def self.new(pull : JSON::PullParser)
|
||||
AutoArchiveDuration.new(pull.read_int.to_u16)
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.number(value)
|
||||
end
|
||||
end
|
||||
|
||||
class Message
|
||||
include JSON::Serializable
|
||||
|
||||
property type : MessageType
|
||||
property content : String
|
||||
property id : Snowflake
|
||||
property channel_id : Snowflake
|
||||
property guild_id : Snowflake?
|
||||
property author : User
|
||||
property member : PartialGuildMember?
|
||||
@[JSON::Field(converter: Discord::TimestampConverter)]
|
||||
property timestamp : Time
|
||||
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
|
||||
property edited_timestamp : Time?
|
||||
property tts : Bool
|
||||
property mention_everyone : Bool
|
||||
property mentions : Array(User)
|
||||
property mention_roles : Array(Snowflake)
|
||||
property mention_channels : Array(Snowflake)?
|
||||
property attachments : Array(Attachment)
|
||||
property embeds : Array(Embed)
|
||||
property pinned : Bool?
|
||||
property reactions : Array(Reaction)?
|
||||
property nonce : String | Int64?
|
||||
property activity : Activity?
|
||||
property application : OAuth2Application?
|
||||
property webhook_id : Snowflake?
|
||||
property flags : MessageFlags?
|
||||
property thread : Channel?
|
||||
property referenced_message : Message?
|
||||
|
||||
def message_reference : MessageReference
|
||||
MessageReference.new(@id, @channel_id, @guild_id)
|
||||
end
|
||||
end
|
||||
|
||||
struct MessageReference
|
||||
include JSON::Serializable
|
||||
|
||||
property message_id : Snowflake?
|
||||
property channel_id : Snowflake?
|
||||
property guild_id : Snowflake?
|
||||
property fail_if_not_exists : Bool?
|
||||
|
||||
def initialize(@message_id = nil, @channel_id = nil,
|
||||
@guild_id = nil, @fail_if_not_exists = nil)
|
||||
end
|
||||
end
|
||||
|
||||
struct AllowedMentions
|
||||
include JSON::Serializable
|
||||
|
||||
property parse : Array(String)?
|
||||
property roles : Array(Snowflake)?
|
||||
property users : Array(Snowflake)?
|
||||
property replied_user : Bool
|
||||
|
||||
def initialize(@parse : Array(String)? = nil, @roles : Array(Snowflake)? = nil,
|
||||
@users : Array(Snowflake)? = nil, @replied_user : Bool = false)
|
||||
end
|
||||
end
|
||||
|
||||
enum ActivityType : UInt8
|
||||
Join = 1
|
||||
Spectate = 2
|
||||
Listen = 3
|
||||
JoinRequest = 5
|
||||
|
||||
def self.new(pull : JSON::PullParser)
|
||||
ActivityType.new(pull.read_int.to_u8)
|
||||
end
|
||||
end
|
||||
|
||||
struct Activity
|
||||
include JSON::Serializable
|
||||
|
||||
property type : ActivityType
|
||||
property party_id : String?
|
||||
end
|
||||
|
||||
enum ChannelType : UInt8
|
||||
GuildText = 0
|
||||
DM = 1
|
||||
GuildVoice = 2
|
||||
GroupDM = 3
|
||||
GuildCategory = 4
|
||||
GuildNews = 5
|
||||
GuildStore = 6
|
||||
GuildNewsThread = 10
|
||||
GuildPublicThread = 11
|
||||
GuildPrivateThread = 12
|
||||
GuildStageVoice = 13
|
||||
|
||||
def self.new(pull : JSON::PullParser)
|
||||
ChannelType.new(pull.read_int.to_u8)
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.number(value)
|
||||
end
|
||||
end
|
||||
|
||||
enum VideoQualityMode : UInt8
|
||||
Auto = 1
|
||||
Full = 2
|
||||
|
||||
def self.new(pull : JSON::PullParser)
|
||||
VideoQualityMode.new(pull.read_int.to_u8)
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.number(value)
|
||||
end
|
||||
end
|
||||
|
||||
struct Channel
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property type : ChannelType
|
||||
property guild_id : Snowflake?
|
||||
property name : String?
|
||||
property permission_overwrites : Array(Overwrite)?
|
||||
property topic : String?
|
||||
property last_message_id : Snowflake?
|
||||
property bitrate : UInt32?
|
||||
property user_limit : UInt32?
|
||||
property recipients : Array(User)?
|
||||
property nsfw : Bool?
|
||||
property icon : String?
|
||||
property owner_id : Snowflake?
|
||||
property application_id : Snowflake?
|
||||
property position : Int32?
|
||||
property parent_id : Snowflake?
|
||||
property rate_limit_per_user : Int32?
|
||||
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
|
||||
property last_pin_timestamp : Time?
|
||||
property rtc_region : String?
|
||||
property video_quality_mode : VideoQualityMode?
|
||||
property thread_metadata : ThreadMetaData?
|
||||
property message_count : UInt32?
|
||||
property member_count : UInt32?
|
||||
property member : ThreadMember?
|
||||
property default_auto_archive_duration : AutoArchiveDuration?
|
||||
property last_message_id : Snowflake?
|
||||
|
||||
# :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
|
||||
|
||||
# Produces a string to mention this channel in a message
|
||||
def mention
|
||||
"<##{id}>"
|
||||
end
|
||||
end
|
||||
|
||||
struct ThreadMetaData
|
||||
include JSON::Serializable
|
||||
property archived : Bool
|
||||
property auto_archive_duration : AutoArchiveDuration
|
||||
property archive_timestamp : Time
|
||||
property locked : Bool
|
||||
property invitable : Bool?
|
||||
end
|
||||
|
||||
enum StagePrivacyLevel : UInt8
|
||||
PUBLIC = 1
|
||||
GUILD_ONLY = 2
|
||||
|
||||
def self.new(pull : JSON::PullParser)
|
||||
StagePrivacyLevel.new(pull.read_int.to_u8)
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.number(value)
|
||||
end
|
||||
end
|
||||
|
||||
struct StageInstance
|
||||
include JSON::Serializable
|
||||
|
||||
getter id : Snowflake
|
||||
getter guild_id : Snowflake
|
||||
getter channel_id : Snowflake
|
||||
getter topic : String
|
||||
getter privacy_level : StagePrivacyLevel
|
||||
getter discoverable_disabled : Bool
|
||||
end
|
||||
|
||||
struct PrivateChannel
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property type : ChannelType
|
||||
property recipients : Array(User)
|
||||
property last_message_id : Snowflake?
|
||||
end
|
||||
|
||||
struct Overwrite
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property type : Int8
|
||||
property allow : Permissions
|
||||
property deny : Permissions
|
||||
end
|
||||
|
||||
struct Reaction
|
||||
include JSON::Serializable
|
||||
|
||||
property emoji : ReactionEmoji
|
||||
property count : UInt32
|
||||
property me : Bool
|
||||
end
|
||||
|
||||
struct ReactionEmoji
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake?
|
||||
property name : String
|
||||
end
|
||||
|
||||
struct Embed
|
||||
include JSON::Serializable
|
||||
|
||||
property title : String?
|
||||
property type : String
|
||||
property description : String?
|
||||
property url : String?
|
||||
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
|
||||
property timestamp : Time?
|
||||
@[JSON::Field(key: "color")]
|
||||
property colour : UInt32?
|
||||
property footer : EmbedFooter?
|
||||
property image : EmbedImage?
|
||||
property thumbnail : EmbedThumbnail?
|
||||
property video : EmbedVideo?
|
||||
property provider : EmbedProvider?
|
||||
property author : EmbedAuthor?
|
||||
property fields : Array(EmbedField)?
|
||||
|
||||
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
|
||||
|
||||
{% unless flag?(:correct_english) %}
|
||||
def color
|
||||
colour
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
struct EmbedThumbnail
|
||||
include JSON::Serializable
|
||||
|
||||
property url : String
|
||||
property proxy_url : String?
|
||||
property height : UInt32?
|
||||
property width : UInt32?
|
||||
|
||||
def initialize(@url : String)
|
||||
end
|
||||
end
|
||||
|
||||
struct EmbedVideo
|
||||
include JSON::Serializable
|
||||
|
||||
property url : String
|
||||
property height : UInt32
|
||||
property width : UInt32
|
||||
end
|
||||
|
||||
struct EmbedImage
|
||||
include JSON::Serializable
|
||||
|
||||
property url : String
|
||||
property proxy_url : String?
|
||||
property height : UInt32?
|
||||
property width : UInt32?
|
||||
|
||||
def initialize(@url : String)
|
||||
end
|
||||
end
|
||||
|
||||
struct EmbedProvider
|
||||
include JSON::Serializable
|
||||
|
||||
property name : String
|
||||
property url : String?
|
||||
end
|
||||
|
||||
struct EmbedAuthor
|
||||
include JSON::Serializable
|
||||
|
||||
property name : String?
|
||||
property url : String?
|
||||
property icon_url : String?
|
||||
property proxy_icon_url : String?
|
||||
|
||||
def initialize(
|
||||
@name : String? = nil,
|
||||
@url : String? = nil,
|
||||
@icon_url : String? = nil
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
struct EmbedFooter
|
||||
include JSON::Serializable
|
||||
|
||||
property text : String?
|
||||
property icon_url : String?
|
||||
property proxy_icon_url : String?
|
||||
|
||||
def initialize(
|
||||
@text : String? = nil,
|
||||
@icon_url : String? = nil
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
struct EmbedField
|
||||
include JSON::Serializable
|
||||
|
||||
property name : String
|
||||
property value : String
|
||||
property inline : Bool
|
||||
|
||||
def initialize(
|
||||
@name : String,
|
||||
@value : String,
|
||||
@inline : Bool = false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
struct Attachment
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property filename : String
|
||||
property size : UInt32
|
||||
property url : String
|
||||
property proxy_url : String
|
||||
property height : UInt32?
|
||||
property width : UInt32?
|
||||
end
|
||||
|
||||
struct ThreadMember
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake?
|
||||
property user_id : Snowflake?
|
||||
property join_timestamp : Time
|
||||
property flags : UInt32
|
||||
end
|
||||
end
|
||||
51
lib/discordcr/src/discordcr/mappings/converters.cr
Normal file
51
lib/discordcr/src/discordcr/mappings/converters.cr
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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 MaybeTimestampConverter
|
||||
def self.from_json(parser : JSON::PullParser)
|
||||
if parser.kind.null?
|
||||
parser.read_null
|
||||
return nil
|
||||
end
|
||||
TimestampConverter.from_json(parser)
|
||||
end
|
||||
|
||||
def self.to_json(value : Time?, builder : JSON::Builder)
|
||||
if value
|
||||
TimestampConverter.to_json(value, builder)
|
||||
else
|
||||
builder.null
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
module TimeSpanMillisecondsConverter
|
||||
def self.from_json(parser : JSON::PullParser)
|
||||
parser.read_int.milliseconds
|
||||
end
|
||||
|
||||
def self.to_json(value : Time::Span, builder : JSON::Builder)
|
||||
builder.scalar(value.milliseconds)
|
||||
end
|
||||
end
|
||||
end
|
||||
8
lib/discordcr/src/discordcr/mappings/enums.cr
Normal file
8
lib/discordcr/src/discordcr/mappings/enums.cr
Normal file
|
|
@ -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
|
||||
480
lib/discordcr/src/discordcr/mappings/gateway.cr
Normal file
480
lib/discordcr/src/discordcr/mappings/gateway.cr
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
require "./converters"
|
||||
require "./user"
|
||||
require "./channel"
|
||||
require "./guild"
|
||||
|
||||
module Discord
|
||||
module Gateway
|
||||
struct ReadyPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property v : UInt8
|
||||
property user : User
|
||||
property private_channels : Array(PrivateChannel)
|
||||
property guilds : Array(UnavailableGuild)
|
||||
property session_id : String
|
||||
property resume_gateway_url : String
|
||||
end
|
||||
|
||||
struct ResumedPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property _trace : Array(String)
|
||||
end
|
||||
|
||||
struct IdentifyPacket
|
||||
include JSON::Serializable
|
||||
|
||||
property op : Int32
|
||||
property d : IdentifyPayload
|
||||
|
||||
def initialize(token, properties, large_threshold, compress, shard, intents)
|
||||
@op = Discord::Client::OP_IDENTIFY
|
||||
@d = IdentifyPayload.new(token, properties, large_threshold, compress, shard, intents)
|
||||
end
|
||||
end
|
||||
|
||||
struct IdentifyPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property token : String
|
||||
property properties : IdentifyProperties
|
||||
property compress : Bool
|
||||
property large_threshold : Int32
|
||||
property shard : Tuple(Int32, Int32)?
|
||||
|
||||
@[JSON::Field(converter: Enum::ValueConverter)]
|
||||
property intents : Intents?
|
||||
|
||||
def initialize(@token, @properties, @compress, @large_threshold, @shard, @intents)
|
||||
end
|
||||
end
|
||||
|
||||
struct IdentifyProperties
|
||||
include JSON::Serializable
|
||||
|
||||
@[JSON::Field(key: "$os")]
|
||||
property os : String
|
||||
@[JSON::Field(key: "$browser")]
|
||||
property browser : String
|
||||
@[JSON::Field(key: "$device")]
|
||||
property device : String
|
||||
@[JSON::Field(key: "$referrer")]
|
||||
property referrer : String
|
||||
@[JSON::Field(key: "$referring_domain")]
|
||||
property referring_domain : String
|
||||
|
||||
def initialize(@os, @browser, @device, @referrer, @referring_domain)
|
||||
end
|
||||
end
|
||||
|
||||
@[Flags]
|
||||
enum Intents
|
||||
Guilds = 1 << 0
|
||||
GuildMembers = 1 << 1
|
||||
GuildBans = 1 << 2
|
||||
GuildEmojis = 1 << 3
|
||||
GuildIntegrations = 1 << 4
|
||||
GuildWebhooks = 1 << 5
|
||||
GuildInvites = 1 << 6
|
||||
GuildVoiceStates = 1 << 7
|
||||
GuildPresences = 1 << 8
|
||||
GuildMessages = 1 << 9
|
||||
GuildMessageReactions = 1 << 10
|
||||
GuildMessageTyping = 1 << 11
|
||||
DirectMessages = 1 << 12
|
||||
DirectMessageReactions = 1 << 13
|
||||
DirectMessageTyping = 1 << 14
|
||||
|
||||
# Generates an Unprivileged intents constant, removing GuildMembers and GuildPresences.
|
||||
{% begin %}
|
||||
Unprivileged = {{ @type.constants.reject { |e| ["All", "None", "GuildMembers", "GuildPresences"].includes?(e.stringify) }.join("|").id }}
|
||||
{% end %}
|
||||
end
|
||||
|
||||
struct ResumePacket
|
||||
include JSON::Serializable
|
||||
|
||||
property op : Int32
|
||||
property d : ResumePayload
|
||||
|
||||
def initialize(token, session_id, seq)
|
||||
@op = Discord::Client::OP_RESUME
|
||||
@d = ResumePayload.new(token, session_id, seq)
|
||||
end
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
struct ResumePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property token : String
|
||||
property session_id : String
|
||||
property seq : Int64
|
||||
|
||||
def initialize(@token, @session_id, @seq)
|
||||
end
|
||||
end
|
||||
|
||||
struct StatusUpdatePacket
|
||||
include JSON::Serializable
|
||||
|
||||
property op : Int32
|
||||
property d : StatusUpdatePayload
|
||||
|
||||
def initialize(status, game, afk, since)
|
||||
@op = Discord::Client::OP_STATUS_UPDATE
|
||||
@d = StatusUpdatePayload.new(status, game, afk, since)
|
||||
end
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
struct StatusUpdatePayload
|
||||
include JSON::Serializable
|
||||
|
||||
@[JSON::Field(emit_null: true)]
|
||||
property status : String?
|
||||
@[JSON::Field(emit_null: true)]
|
||||
property game : GamePlaying?
|
||||
property afk : Bool
|
||||
@[JSON::Field(emit_null: true)]
|
||||
property since : Int64?
|
||||
|
||||
def initialize(@status, @game, @afk, @since)
|
||||
end
|
||||
end
|
||||
|
||||
struct VoiceStateUpdatePacket
|
||||
include JSON::Serializable
|
||||
|
||||
property op : Int32
|
||||
property d : VoiceStateUpdatePayload
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
struct VoiceStateUpdatePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property guild_id : UInt64
|
||||
@[JSON::Field(emit_null: true)]
|
||||
property channel_id : UInt64?
|
||||
property self_mute : Bool
|
||||
property self_deaf : Bool
|
||||
|
||||
def initialize(@guild_id, @channel_id, @self_mute, @self_deaf)
|
||||
end
|
||||
end
|
||||
|
||||
struct RequestGuildMembersPacket
|
||||
include JSON::Serializable
|
||||
|
||||
property op : Int32
|
||||
property d : RequestGuildMembersPayload
|
||||
|
||||
def initialize(guild_id, query, limit)
|
||||
@op = Discord::Client::OP_REQUEST_GUILD_MEMBERS
|
||||
@d = RequestGuildMembersPayload.new(guild_id, query, limit)
|
||||
end
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
struct RequestGuildMembersPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property guild_id : UInt64
|
||||
property query : String
|
||||
property limit : Int32
|
||||
|
||||
def initialize(@guild_id, @query, @limit)
|
||||
end
|
||||
end
|
||||
|
||||
struct HelloPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property heartbeat_interval : UInt32
|
||||
property _trace : Array(String)
|
||||
end
|
||||
|
||||
# This one is special from simply Guild since it also has fields for members
|
||||
# and presences.
|
||||
struct GuildCreatePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property name : String
|
||||
property icon : String?
|
||||
property splash : String?
|
||||
property owner_id : Snowflake
|
||||
property region : String
|
||||
property afk_channel_id : Snowflake?
|
||||
property afk_timeout : Int32?
|
||||
property verification_level : UInt8
|
||||
property premium_tier : UInt8
|
||||
property premium_subscription_count : UInt8?
|
||||
property roles : Array(Role)
|
||||
@[JSON::Field(key: "emojis")]
|
||||
property emoji : Array(Emoji)
|
||||
property features : Array(String)
|
||||
property large : Bool
|
||||
property voice_states : Array(VoiceState)
|
||||
property unavailable : Bool?
|
||||
property member_count : Int32
|
||||
property members : Array(GuildMember)
|
||||
property channels : Array(Channel)
|
||||
property presences : Array(Presence)
|
||||
property widget_channel_id : Snowflake?
|
||||
property default_message_notifications : UInt8
|
||||
property explicit_content_filter : UInt8
|
||||
property system_channel_id : Snowflake?
|
||||
property stage_instances : Array(StageInstance)
|
||||
property threads : Array(Channel)
|
||||
|
||||
{% unless flag?(:correct_english) %}
|
||||
def emojis
|
||||
emoji
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
struct GuildDeletePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property unavailable : Bool?
|
||||
end
|
||||
|
||||
struct GuildBanPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property user : User
|
||||
property guild_id : Snowflake
|
||||
end
|
||||
|
||||
struct GuildEmojiUpdatePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property guild_id : Snowflake
|
||||
@[JSON::Field(key: "emojis")]
|
||||
property emoji : Array(Emoji)
|
||||
|
||||
{% unless flag?(:correct_english) %}
|
||||
def emojis
|
||||
emoji
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
struct GuildIntegrationsUpdatePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property guild_id : Snowflake
|
||||
end
|
||||
|
||||
struct GuildMemberAddPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property user : User
|
||||
property nick : String?
|
||||
property roles : Array(Snowflake)
|
||||
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
|
||||
property joined_at : Time?
|
||||
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
|
||||
property premium_since : Time?
|
||||
property deaf : Bool
|
||||
property mute : Bool
|
||||
property guild_id : Snowflake
|
||||
end
|
||||
|
||||
struct GuildMemberUpdatePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property user : User
|
||||
property roles : Array(Snowflake)
|
||||
property nick : String?
|
||||
property guild_id : Snowflake
|
||||
end
|
||||
|
||||
struct GuildMemberRemovePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property user : User
|
||||
property guild_id : Snowflake
|
||||
end
|
||||
|
||||
struct GuildMembersChunkPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property guild_id : Snowflake
|
||||
property members : Array(GuildMember)
|
||||
end
|
||||
|
||||
struct GuildRolePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property guild_id : Snowflake
|
||||
property role : Role
|
||||
end
|
||||
|
||||
struct GuildRoleDeletePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property guild_id : Snowflake
|
||||
property role_id : Snowflake
|
||||
end
|
||||
|
||||
struct InviteCreatePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property channel_id : Snowflake
|
||||
property code : String
|
||||
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
|
||||
property created_at : Time?
|
||||
property guild_id : Snowflake?
|
||||
property inviter : User?
|
||||
property max_age : Int32
|
||||
property max_uses : Int32
|
||||
property temporary : Bool
|
||||
property uses : Int32
|
||||
end
|
||||
|
||||
struct InviteDeletePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property channel_id : Snowflake
|
||||
property guild_id : Snowflake?
|
||||
property code : String
|
||||
end
|
||||
|
||||
struct MessageReactionPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property user_id : Snowflake
|
||||
property channel_id : Snowflake
|
||||
property message_id : Snowflake
|
||||
property guild_id : Snowflake?
|
||||
property emoji : ReactionEmoji
|
||||
end
|
||||
|
||||
struct MessageReactionRemoveAllPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property channel_id : Snowflake
|
||||
property message_id : Snowflake
|
||||
property guild_id : Snowflake?
|
||||
end
|
||||
|
||||
struct MessageReactionRemoveEmojiPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property channel_id : Snowflake
|
||||
property guild_id : Snowflake
|
||||
property message_id : Snowflake
|
||||
property emoji : ReactionEmoji
|
||||
end
|
||||
|
||||
struct MessageUpdatePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property type : UInt8?
|
||||
property content : String?
|
||||
property id : Snowflake
|
||||
property channel_id : Snowflake
|
||||
property guild_id : Snowflake?
|
||||
property author : User?
|
||||
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
|
||||
property timestamp : Time?
|
||||
property tts : Bool?
|
||||
property mention_everyone : Bool?
|
||||
property mentions : Array(User)?
|
||||
property mention_roles : Array(Snowflake)?
|
||||
property attachments : Array(Attachment)?
|
||||
property embeds : Array(Embed)?
|
||||
property pinned : Bool?
|
||||
end
|
||||
|
||||
struct MessageDeletePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property channel_id : Snowflake
|
||||
property guild_id : Snowflake?
|
||||
end
|
||||
|
||||
struct MessageDeleteBulkPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property ids : Array(Snowflake)
|
||||
property channel_id : Snowflake
|
||||
property guild_id : Snowflake?
|
||||
end
|
||||
|
||||
struct PresenceUpdatePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property user : PartialUser
|
||||
property game : GamePlaying?
|
||||
property guild_id : Snowflake
|
||||
property status : String
|
||||
property activities : Array(GamePlaying)
|
||||
end
|
||||
|
||||
struct TypingStartPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property channel_id : Snowflake
|
||||
property user_id : Snowflake
|
||||
property guild_id : Snowflake?
|
||||
property member : GuildMember?
|
||||
@[JSON::Field(converter: Time::EpochConverter)]
|
||||
property timestamp : Time
|
||||
end
|
||||
|
||||
struct VoiceServerUpdatePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property token : String
|
||||
property guild_id : Snowflake
|
||||
property endpoint : String
|
||||
end
|
||||
|
||||
struct WebhooksUpdatePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property guild_id : Snowflake
|
||||
property channel_id : Snowflake
|
||||
end
|
||||
|
||||
struct ChannelPinsUpdatePayload
|
||||
include JSON::Serializable
|
||||
|
||||
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
|
||||
property last_pin_timestamp : Time?
|
||||
property channel_id : Snowflake
|
||||
end
|
||||
|
||||
struct ThreadMembersUpdatePayload
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property guild_id : Snowflake
|
||||
property member_count : UInt32
|
||||
property added_members : Array(ThreadMember)?
|
||||
property removed_member_ids : Array(Snowflake)?
|
||||
end
|
||||
|
||||
struct ThreadListSyncPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property guild_id : Snowflake
|
||||
property channel_ids : Array(Snowflake)?
|
||||
property threads : Array(Channel)
|
||||
property members : Array(ThreadMember)
|
||||
end
|
||||
end
|
||||
end
|
||||
342
lib/discordcr/src/discordcr/mappings/guild.cr
Normal file
342
lib/discordcr/src/discordcr/mappings/guild.cr
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
require "./converters"
|
||||
require "./voice"
|
||||
|
||||
module Discord
|
||||
struct Guild
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property name : String
|
||||
property icon : String?
|
||||
property splash : String?
|
||||
property owner_id : Snowflake
|
||||
property region : String
|
||||
property afk_channel_id : Snowflake?
|
||||
property afk_timeout : Int32?
|
||||
# Removed in v8
|
||||
# property embed_enabled : Bool?
|
||||
# property embed_channel_id : Snowflake?
|
||||
property verification_level : UInt8
|
||||
property premium_tier : UInt8
|
||||
property premium_subscription_count : UInt8?
|
||||
property roles : Array(Role)
|
||||
@[JSON::Field(key: "emojis")]
|
||||
property emoji : Array(Emoji)
|
||||
property features : Array(String)
|
||||
property widget_enabled : Bool?
|
||||
property widget_channel_id : Snowflake?
|
||||
property default_message_notifications : UInt8
|
||||
property explicit_content_filter : UInt8
|
||||
property system_channel_id : Snowflake?
|
||||
|
||||
# :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
|
||||
@premium_tier = payload.premium_tier
|
||||
@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
|
||||
|
||||
{% unless flag?(:correct_english) %}
|
||||
def emojis
|
||||
emoji
|
||||
end
|
||||
{% end %}
|
||||
|
||||
# Produces a CDN URL to this guild's icon in the given `format` and `size`,
|
||||
# or `nil` if no icon is set.
|
||||
def icon_url(format : CDN::GuildIconFormat = CDN::GuildIconFormat::WebP,
|
||||
size : Int32 = 128)
|
||||
if icon = @icon
|
||||
CDN.guild_icon(id, icon, format, size)
|
||||
end
|
||||
end
|
||||
|
||||
# Produces a CDN URL to this guild's splash in the given `format` and `size`,
|
||||
# or `nil` if no splash is set.
|
||||
def splash_url(format : CDN::GuildSplashFormat = CDN::GuildSplashFormat::WebP,
|
||||
size : Int32 = 128)
|
||||
if splash = @splash
|
||||
CDN.guild_splash(id, splash, format, size)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
struct UnavailableGuild
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property unavailable : Bool
|
||||
end
|
||||
|
||||
struct GuildEmbed
|
||||
include JSON::Serializable
|
||||
|
||||
property enabled : Bool
|
||||
property channel_id : Snowflake?
|
||||
end
|
||||
|
||||
struct GuildMember
|
||||
include JSON::Serializable
|
||||
|
||||
property user : User
|
||||
property nick : String?
|
||||
property roles : Array(Snowflake)?
|
||||
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
|
||||
property joined_at : Time?
|
||||
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
|
||||
property premium_since : Time?
|
||||
property deaf : Bool?
|
||||
property mute : Bool?
|
||||
property communication_disabled_until : Time?
|
||||
|
||||
# :nodoc:
|
||||
def initialize(user : User, partial_member : PartialGuildMember)
|
||||
@user = user
|
||||
@roles = partial_member.roles
|
||||
@nick = partial_member.nick
|
||||
@joined_at = partial_member.joined_at
|
||||
@premium_since = partial_member.premium_since
|
||||
@mute = partial_member.mute
|
||||
@deaf = partial_member.deaf
|
||||
@communication_disabled_until = partial_member.communication_disabled_until
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
def initialize(payload : Gateway::GuildMemberAddPayload | GuildMember, roles : Array(Snowflake), 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
|
||||
@premium_since = payload.premium_since
|
||||
@deaf = payload.deaf
|
||||
@mute = payload.mute
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
def initialize(payload : Gateway::PresenceUpdatePayload)
|
||||
@user = User.new(payload.user)
|
||||
# Presence updates have no joined_at or deaf/mute, thanks Discord
|
||||
end
|
||||
|
||||
# Produces a string to mention this member in a message
|
||||
def mention
|
||||
if nick
|
||||
"<@!#{user.id}>"
|
||||
else
|
||||
"<@#{user.id}>"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
struct PartialGuildMember
|
||||
include JSON::Serializable
|
||||
|
||||
property nick : String?
|
||||
property roles : Array(Snowflake)
|
||||
@[JSON::Field(converter: Discord::TimestampConverter)]
|
||||
property joined_at : Time
|
||||
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
|
||||
property premium_since : Time?
|
||||
property deaf : Bool
|
||||
property mute : Bool
|
||||
property communication_disabled_until : Time?
|
||||
end
|
||||
|
||||
struct Integration
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property name : String
|
||||
property type : String
|
||||
property enabled : Bool
|
||||
property syncing : Bool
|
||||
property role_id : Snowflake
|
||||
@[JSON::Field(key: "expire_behavior")]
|
||||
property expire_behaviour : UInt8
|
||||
property expire_grace_period : Int32
|
||||
property user : User
|
||||
property account : IntegrationAccount
|
||||
@[JSON::Field(converter: Time::EpochConverter)]
|
||||
property synced_at : Time
|
||||
|
||||
{% unless flag?(:correct_english) %}
|
||||
def expire_behavior
|
||||
expire_behaviour
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
struct IntegrationAccount
|
||||
include JSON::Serializable
|
||||
|
||||
property id : String
|
||||
property name : String
|
||||
end
|
||||
|
||||
struct Emoji
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake?
|
||||
property name : String
|
||||
property roles : Array(Snowflake)?
|
||||
property require_colons : Bool?
|
||||
property managed : Bool?
|
||||
property animated : Bool?
|
||||
|
||||
# Produces a CDN URL to this emoji's image in the given `size`. Will return
|
||||
# a PNG, or GIF if the emoji is animated.
|
||||
def image_url(size : Int32 = 128)
|
||||
if animated
|
||||
image_url(:gif, size)
|
||||
else
|
||||
image_url(:png, size)
|
||||
end
|
||||
end
|
||||
|
||||
# Produces a CDN URL to this emoji's image in the given `format` and `size`
|
||||
# or `nil` if the emoji has no id.
|
||||
def image_url(format : CDN::CustomEmojiFormat, size : Int32 = 128)
|
||||
if emoji_id = id
|
||||
CDN.custom_emoji(emoji_id, format, size)
|
||||
end
|
||||
end
|
||||
|
||||
# Produces a string to mention this emoji in a message
|
||||
def mention
|
||||
if animated
|
||||
"<a:#{name}:#{id}>"
|
||||
else
|
||||
"<:#{name}:#{id}>"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
struct Role
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property name : String
|
||||
property permissions : Permissions
|
||||
@[JSON::Field(key: "color")]
|
||||
property colour : UInt32
|
||||
property hoist : Bool
|
||||
property position : Int32
|
||||
property managed : Bool
|
||||
property mentionable : Bool
|
||||
|
||||
@[JSON::Field(converter: Discord::RoleTags)]
|
||||
property tags : RoleTags?
|
||||
|
||||
{% unless flag?(:correct_english) %}
|
||||
def color
|
||||
colour
|
||||
end
|
||||
{% end %}
|
||||
|
||||
# Produces a string to mention this role in a message
|
||||
def mention
|
||||
"<@&#{id}>"
|
||||
end
|
||||
end
|
||||
|
||||
struct RoleTags
|
||||
include JSON::Serializable
|
||||
|
||||
property bot_id : Snowflake?
|
||||
property integration_id : Snowflake?
|
||||
property premium_subscriber : Bool = false
|
||||
|
||||
def initialize(@bot_id : Snowflake?, @integration_id : Snowflake?,
|
||||
@premium_subscriber : Bool?)
|
||||
end
|
||||
|
||||
# This struct requires a special parsing routine because Discord
|
||||
# decided to send dumb values for it.
|
||||
# This can be removed whenever premium_subscriber doesnt return only null.
|
||||
def self.from_json(pull : JSON::PullParser)
|
||||
bot_id = nil
|
||||
integration_id = nil
|
||||
premium_subscriber = false
|
||||
|
||||
pull.read_object do |key|
|
||||
case key
|
||||
when "bot_id" then bot_id = Snowflake.new(pull)
|
||||
when "integration_id" then integration_id = Snowflake.new(pull)
|
||||
when "premium_subscriber"
|
||||
premium_subscriber = true
|
||||
pull.skip
|
||||
else
|
||||
pull.skip
|
||||
end
|
||||
end
|
||||
|
||||
RoleTags.new(bot_id, integration_id, premium_subscriber)
|
||||
end
|
||||
end
|
||||
|
||||
struct GuildBan
|
||||
include JSON::Serializable
|
||||
|
||||
property user : User
|
||||
property reason : String?
|
||||
end
|
||||
|
||||
struct GamePlaying
|
||||
include JSON::Serializable
|
||||
|
||||
enum Type : UInt8
|
||||
Playing = 0
|
||||
Streaming = 1
|
||||
Listening = 2
|
||||
Watching = 3
|
||||
Custom = 4
|
||||
Competing = 5
|
||||
end
|
||||
|
||||
property name : String?
|
||||
@[JSON::Field(converter: Enum::ValueConverter(Discord::GamePlaying::Type))]
|
||||
property type : Type?
|
||||
property url : String?
|
||||
property state : String?
|
||||
property emoji : Emoji?
|
||||
|
||||
def initialize(
|
||||
@name = nil,
|
||||
@type : Type? = nil,
|
||||
@url = nil,
|
||||
@state = nil,
|
||||
@emoji = nil
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
struct Presence
|
||||
include JSON::Serializable
|
||||
|
||||
property user : PartialUser
|
||||
property game : GamePlaying?
|
||||
property status : String
|
||||
property activities : Array(GamePlaying)
|
||||
end
|
||||
end
|
||||
44
lib/discordcr/src/discordcr/mappings/invite.cr
Normal file
44
lib/discordcr/src/discordcr/mappings/invite.cr
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
require "./converters"
|
||||
require "./user"
|
||||
|
||||
module Discord
|
||||
struct Invite
|
||||
include JSON::Serializable
|
||||
|
||||
property code : String
|
||||
property guild : InviteGuild
|
||||
property channel : InviteChannel
|
||||
end
|
||||
|
||||
struct InviteMetadata
|
||||
include JSON::Serializable
|
||||
|
||||
property code : String
|
||||
property guild : InviteGuild
|
||||
property channel : InviteChannel
|
||||
property inviter : User
|
||||
property users : UInt32
|
||||
property max_uses : UInt32
|
||||
property max_age : UInt32
|
||||
property temporary : Bool
|
||||
@[JSON::Field(converter: Discord::TimestampConverter)]
|
||||
property created_at : Time
|
||||
property revoked : Bool
|
||||
end
|
||||
|
||||
struct InviteGuild
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property name : String
|
||||
property splash_hash : String?
|
||||
end
|
||||
|
||||
struct InviteChannel
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property name : String
|
||||
property type : UInt8
|
||||
end
|
||||
end
|
||||
62
lib/discordcr/src/discordcr/mappings/oauth2.cr
Normal file
62
lib/discordcr/src/discordcr/mappings/oauth2.cr
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
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
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property name : String
|
||||
property icon : String?
|
||||
property description : String?
|
||||
property rpc_origins : Array(String)?
|
||||
property bot_public : Bool
|
||||
property bot_require_code_grant : Bool
|
||||
property owner : User
|
||||
property summary : String
|
||||
property verify_key : String
|
||||
property team : Team?
|
||||
property guild_id : Snowflake?
|
||||
property primary_sku_id : String?
|
||||
property slug : String?
|
||||
property cover_image : String?
|
||||
|
||||
# Produces a CDN URL for this application's icon in the given `format` and `size`
|
||||
def icon_url(format : CDN::ApplicationIconFormat = CDN::ApplicationIconFormat::WebP,
|
||||
size : Int32 = 128)
|
||||
if icon = @icon
|
||||
CDN.application_icon(id, icon, format, size)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
struct Team
|
||||
include JSON::Serializable
|
||||
|
||||
property icon : String?
|
||||
property id : Snowflake
|
||||
property members : Array(TeamMember)
|
||||
property owner_user_id : Snowflake
|
||||
end
|
||||
|
||||
struct TeamMember
|
||||
include JSON::Serializable
|
||||
|
||||
property membership_state : TeamMembershipState
|
||||
property permissions : Array(String)
|
||||
property team_id : Snowflake
|
||||
property user : User
|
||||
end
|
||||
|
||||
enum TeamMembershipState : UInt8
|
||||
Invited = 1
|
||||
Accepted = 2
|
||||
|
||||
def self.new(pull : JSON::PullParser)
|
||||
TeamMembershipState.new(pull.read_int.to_u8)
|
||||
end
|
||||
end
|
||||
end
|
||||
51
lib/discordcr/src/discordcr/mappings/permissions.cr
Normal file
51
lib/discordcr/src/discordcr/mappings/permissions.cr
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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
|
||||
ViewAuditLog = 1 << 7
|
||||
PrioritySpeaker = 1 << 8
|
||||
Stream = 1 << 9
|
||||
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
|
||||
UseApplicationCommands = 1 << 31
|
||||
RequestToSpeak = 1 << 32
|
||||
ManageThreads = 1 << 34
|
||||
UsePrivateThreads = 1 << 36
|
||||
UseExternalStickers = 1 << 37
|
||||
SendMessagesInThreads = 1 << 38
|
||||
UseEmbeddedActivities = 1 << 39
|
||||
ModerateMembers = 1 << 40
|
||||
|
||||
def self.new(pull : JSON::PullParser)
|
||||
Permissions.new(pull.read_string.to_u64)
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.string(value)
|
||||
end
|
||||
end
|
||||
end
|
||||
98
lib/discordcr/src/discordcr/mappings/rest.cr
Normal file
98
lib/discordcr/src/discordcr/mappings/rest.cr
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
require "./converters"
|
||||
|
||||
module Discord
|
||||
module REST
|
||||
# A response to the Get Gateway REST API call.
|
||||
struct GatewayResponse
|
||||
include JSON::Serializable
|
||||
|
||||
property url : String
|
||||
end
|
||||
|
||||
# A response to the Get Gateway Bot REST API call.
|
||||
struct GatewayBotResponse
|
||||
include JSON::Serializable
|
||||
|
||||
property url : String
|
||||
property shards : Int32
|
||||
property session_start_limit : SessionStartLimit
|
||||
end
|
||||
|
||||
# Session start limit details included in the Get Gateway Bot REST API call.
|
||||
struct SessionStartLimit
|
||||
include JSON::Serializable
|
||||
|
||||
property total : Int32
|
||||
property remaining : Int32
|
||||
@[JSON::Field(converter: Discord::TimeSpanMillisecondsConverter)]
|
||||
property reset_after : Time::Span
|
||||
end
|
||||
|
||||
# A response to the Get Guild Prune Count REST API call.
|
||||
struct PruneCountResponse
|
||||
include JSON::Serializable
|
||||
|
||||
property pruned : UInt32
|
||||
end
|
||||
|
||||
# A response to the Get Guild Vanity URL REST API call.
|
||||
struct GuildVanityURLResponse
|
||||
include JSON::Serializable
|
||||
|
||||
property code : String
|
||||
end
|
||||
|
||||
# A request payload to rearrange channels in a `Guild` by a REST API call.
|
||||
struct ModifyChannelPositionPayload
|
||||
@id : Snowflake
|
||||
|
||||
def initialize(id : UInt64 | Snowflake, @position : Int32,
|
||||
@parent_id : UInt64 | Snowflake | ChannelParent = ChannelParent::Unchanged,
|
||||
@lock_permissions : Bool? = nil)
|
||||
id = Snowflake.new(id) unless id.is_a?(Snowflake)
|
||||
@id = id
|
||||
end
|
||||
|
||||
def to_json(builder : JSON::Builder)
|
||||
builder.object do
|
||||
builder.field("id") { @id.to_json(builder) }
|
||||
|
||||
builder.field("position", @position)
|
||||
|
||||
case parent = @parent_id
|
||||
when UInt64, Snowflake
|
||||
parent.to_json(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
|
||||
|
||||
# A request payload to rearrange roles in a `Guild` by a REST API call.
|
||||
struct ModifyRolePositionPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property position : Int32
|
||||
|
||||
def initialize(id : UInt64 | Snowflake, @position : Int32)
|
||||
id = Snowflake.new(id) unless id.is_a?(Snowflake)
|
||||
@id = id
|
||||
end
|
||||
end
|
||||
|
||||
# Response payload to a thread list request
|
||||
struct ThreadsPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property threads : Array(Channel)
|
||||
property members : Array(ThreadMember)
|
||||
property has_more : Bool?
|
||||
end
|
||||
end
|
||||
end
|
||||
113
lib/discordcr/src/discordcr/mappings/user.cr
Normal file
113
lib/discordcr/src/discordcr/mappings/user.cr
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
require "./converters"
|
||||
|
||||
module Discord
|
||||
struct User
|
||||
include JSON::Serializable
|
||||
|
||||
property username : String
|
||||
property id : Snowflake
|
||||
property discriminator : String
|
||||
property avatar : String?
|
||||
property email : String?
|
||||
property bot : Bool?
|
||||
property system : Bool?
|
||||
property mfa_enabled : Bool?
|
||||
property verified : Bool?
|
||||
property member : PartialGuildMember?
|
||||
property flags : UserFlags?
|
||||
|
||||
# :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
|
||||
|
||||
# Produces a CDN URL to this user's avatar in the given `size`.
|
||||
# If the user has an avatar a WebP will be returned, or a GIF
|
||||
# if the avatar is animated. If the user has no avatar, a default
|
||||
# avatar URL is returned.
|
||||
def avatar_url(size : Int32 = 128)
|
||||
if avatar = @avatar
|
||||
CDN.user_avatar(id, avatar, size)
|
||||
else
|
||||
CDN.default_user_avatar(discriminator)
|
||||
end
|
||||
end
|
||||
|
||||
# Produces a CDN URL to this user's avatar, in the given `format` and
|
||||
# `size`. If the user has no avatar, a default avatar URL is returned.
|
||||
def avatar_url(format : CDN::UserAvatarFormat, size : Int32 = 128)
|
||||
if avatar = @avatar
|
||||
CDN.user_avatar(id, avatar, format, size)
|
||||
else
|
||||
CDN.default_user_avatar(discriminator)
|
||||
end
|
||||
end
|
||||
|
||||
# Produces a string to mention this user in a message
|
||||
def mention
|
||||
"<@#{id}>"
|
||||
end
|
||||
end
|
||||
|
||||
@[Flags]
|
||||
enum UserFlags : UInt32
|
||||
DiscordEmployee = 1 << 0
|
||||
PartneredServerOwner = 1 << 1
|
||||
HypeSquadEvents = 1 << 2
|
||||
BugHunterLevel1 = 1 << 3
|
||||
HouseBravery = 1 << 6
|
||||
HouseBrilliance = 1 << 7
|
||||
HouseBalance = 1 << 8
|
||||
EarlySupporter = 1 << 9
|
||||
TeamUser = 1 << 10
|
||||
System = 1 << 12
|
||||
BugHunterLevel2 = 1 << 14
|
||||
VerifiedBot = 1 << 16
|
||||
EarlyVerifiedBotDeveloper = 1 << 17
|
||||
CertifiedModerator = 1 << 18
|
||||
BotHttpInteractions = 1 << 19
|
||||
|
||||
def self.new(pull : JSON::PullParser)
|
||||
UserFlags.new(pull.read_int.to_u32)
|
||||
end
|
||||
end
|
||||
|
||||
struct PartialUser
|
||||
include JSON::Serializable
|
||||
|
||||
property username : String?
|
||||
property id : Snowflake
|
||||
property discriminator : String?
|
||||
property avatar : String?
|
||||
property email : String?
|
||||
property bot : Bool?
|
||||
|
||||
def full? : Bool
|
||||
!@username.nil? && !@discriminator.nil? && !@avatar.nil?
|
||||
end
|
||||
end
|
||||
|
||||
struct UserGuild
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property name : String
|
||||
property icon : String?
|
||||
property owner : Bool
|
||||
property permissions : Permissions
|
||||
end
|
||||
|
||||
struct Connection
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property name : String
|
||||
property type : String
|
||||
property revoked : Bool
|
||||
end
|
||||
end
|
||||
31
lib/discordcr/src/discordcr/mappings/voice.cr
Normal file
31
lib/discordcr/src/discordcr/mappings/voice.cr
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
require "./converters"
|
||||
|
||||
module Discord
|
||||
struct VoiceState
|
||||
include JSON::Serializable
|
||||
|
||||
property guild_id : Snowflake?
|
||||
property channel_id : Snowflake?
|
||||
property user_id : Snowflake
|
||||
property member : GuildMember?
|
||||
property session_id : String
|
||||
property deaf : Bool
|
||||
property mute : Bool
|
||||
property self_deaf : Bool
|
||||
property self_mute : Bool
|
||||
property suppress : Bool
|
||||
|
||||
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
|
||||
property request_to_speak_timestamp : Time?
|
||||
end
|
||||
|
||||
struct VoiceRegion
|
||||
include JSON::Serializable
|
||||
|
||||
property id : String
|
||||
property name : String
|
||||
property custom : Bool
|
||||
property deprecated : Bool
|
||||
property optimal : Bool
|
||||
end
|
||||
end
|
||||
107
lib/discordcr/src/discordcr/mappings/vws.cr
Normal file
107
lib/discordcr/src/discordcr/mappings/vws.cr
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
require "./converters"
|
||||
|
||||
module Discord
|
||||
# :nodoc:
|
||||
module VWS
|
||||
struct IdentifyPacket
|
||||
include JSON::Serializable
|
||||
|
||||
property op : Int32
|
||||
property d : IdentifyPayload
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
struct IdentifyPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property server_id : UInt64
|
||||
property user_id : UInt64
|
||||
property session_id : String
|
||||
property token : String
|
||||
|
||||
def initialize(@server_id, @user_id, @session_id, @token)
|
||||
end
|
||||
end
|
||||
|
||||
struct SelectProtocolPacket
|
||||
include JSON::Serializable
|
||||
|
||||
property op : Int32
|
||||
property d : SelectProtocolPayload
|
||||
|
||||
def initialize(protocol, data)
|
||||
@op = Discord::VoiceClient::OP_SELECT_PROTOCOL
|
||||
@d = SelectProtocolPayload.new(protocol, data)
|
||||
end
|
||||
end
|
||||
|
||||
struct SelectProtocolPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property protocol : String
|
||||
property data : ProtocolData
|
||||
|
||||
def initialize(@protocol, @data)
|
||||
end
|
||||
end
|
||||
|
||||
struct ProtocolData
|
||||
include JSON::Serializable
|
||||
|
||||
property address : String
|
||||
property port : UInt16
|
||||
property mode : String
|
||||
|
||||
def initialize(@address, @port, @mode)
|
||||
end
|
||||
end
|
||||
|
||||
struct ReadyPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property ssrc : Int32
|
||||
property port : Int32
|
||||
property modes : Array(String)
|
||||
property ip : String
|
||||
end
|
||||
|
||||
struct SessionDescriptionPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property secret_key : Array(UInt8)
|
||||
property mode : String
|
||||
end
|
||||
|
||||
struct SpeakingPacket
|
||||
include JSON::Serializable
|
||||
|
||||
property op : Int32
|
||||
property d : SpeakingPayload
|
||||
|
||||
def initialize(speaking, delay)
|
||||
@op = Discord::VoiceClient::OP_SPEAKING
|
||||
@d = SpeakingPayload.new(speaking, delay)
|
||||
end
|
||||
end
|
||||
|
||||
struct SpeakingPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property speaking : Bool
|
||||
property delay : Int32
|
||||
|
||||
def initialize(@speaking, @delay)
|
||||
end
|
||||
end
|
||||
|
||||
struct HelloPayload
|
||||
include JSON::Serializable
|
||||
|
||||
property heartbeat_interval : Float32
|
||||
end
|
||||
end
|
||||
end
|
||||
16
lib/discordcr/src/discordcr/mappings/webhook.cr
Normal file
16
lib/discordcr/src/discordcr/mappings/webhook.cr
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
require "./converters"
|
||||
require "./user"
|
||||
|
||||
module Discord
|
||||
struct Webhook
|
||||
include JSON::Serializable
|
||||
|
||||
property id : Snowflake
|
||||
property guild_id : Snowflake?
|
||||
property channel_id : Snowflake
|
||||
property user : User?
|
||||
property name : String
|
||||
property avatar : String?
|
||||
property token : String
|
||||
end
|
||||
end
|
||||
135
lib/discordcr/src/discordcr/mention.cr
Normal file
135
lib/discordcr/src/discordcr/mention.cr
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
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 + 1) 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
|
||||
else
|
||||
# Continue parsing.
|
||||
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
|
||||
else
|
||||
# Continue parsing.
|
||||
end
|
||||
when '@'
|
||||
word = scan_word(pos)
|
||||
case word
|
||||
when "@everyone"
|
||||
yield Everyone.new(start)
|
||||
when "@here"
|
||||
yield Here.new(start)
|
||||
else
|
||||
# Continue parsing.
|
||||
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
|
||||
39
lib/discordcr/src/discordcr/paginator.cr
Normal file
39
lib/discordcr/src/discordcr/paginator.cr
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
module Discord
|
||||
class Paginator(T)
|
||||
include ::Enumerable(T)
|
||||
|
||||
enum Direction
|
||||
Up
|
||||
Down
|
||||
end
|
||||
|
||||
def initialize(@limit : Int32?, @direction : Direction,
|
||||
&@block : Array(T)? -> Array(T))
|
||||
@count = 0
|
||||
end
|
||||
|
||||
def each
|
||||
last_page = nil
|
||||
loop do
|
||||
page = @block.call(last_page)
|
||||
return if page.empty?
|
||||
|
||||
if @direction.up?
|
||||
page.reverse_each do |item|
|
||||
yield(item)
|
||||
@count += 1
|
||||
@limit.try { |l| return if @count >= l }
|
||||
end
|
||||
else
|
||||
page.each do |item|
|
||||
yield(item)
|
||||
@count += 1
|
||||
@limit.try { |l| return if @count >= l }
|
||||
end
|
||||
end
|
||||
|
||||
last_page = page
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
2259
lib/discordcr/src/discordcr/rest.cr
Normal file
2259
lib/discordcr/src/discordcr/rest.cr
Normal file
File diff suppressed because it is too large
Load diff
65
lib/discordcr/src/discordcr/snowflake.cr
Normal file
65
lib/discordcr/src/discordcr/snowflake.cr
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
module Discord
|
||||
DISCORD_EPOCH = 1420070400000_u64
|
||||
|
||||
# Struct representing a Discord ID
|
||||
struct Snowflake
|
||||
include Comparable(Snowflake)
|
||||
include Comparable(UInt64)
|
||||
|
||||
getter value : UInt64
|
||||
|
||||
def self.new(string : String)
|
||||
new(string.to_u64)
|
||||
end
|
||||
|
||||
def self.new(parser : JSON::PullParser)
|
||||
string = parser.read_string
|
||||
new(string.to_u64)
|
||||
end
|
||||
|
||||
# Creates a `Snowflake` embedded with the given timestamp
|
||||
def self.new(time : Time)
|
||||
ms = time.to_unix_ms.to_u64
|
||||
value = (ms - DISCORD_EPOCH) << 22
|
||||
new(value)
|
||||
end
|
||||
|
||||
def initialize(@value : UInt64)
|
||||
end
|
||||
|
||||
# Compatibility with UInt64 API
|
||||
def to_u64
|
||||
@value
|
||||
end
|
||||
|
||||
def to_s(io : IO)
|
||||
io << @value
|
||||
end
|
||||
|
||||
# The time at which this snowflake was created
|
||||
def creation_time
|
||||
ms = (value >> 22) + DISCORD_EPOCH
|
||||
Time.unix_ms(ms)
|
||||
end
|
||||
|
||||
def to_json(builder : JSON::Builder)
|
||||
builder.scalar value.to_s
|
||||
end
|
||||
|
||||
def <=>(other : Snowflake)
|
||||
value <=> other.value
|
||||
end
|
||||
|
||||
def <=>(int : UInt64)
|
||||
value <=> int
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
struct UInt64
|
||||
include Comparable(Discord::Snowflake)
|
||||
|
||||
def <=>(snowflake : Discord::Snowflake)
|
||||
self <=> snowflake.value
|
||||
end
|
||||
end
|
||||
22
lib/discordcr/src/discordcr/sodium.cr
Normal file
22
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
|
||||
3
lib/discordcr/src/discordcr/version.cr
Normal file
3
lib/discordcr/src/discordcr/version.cr
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module Discord
|
||||
VERSION = "0.4.0"
|
||||
end
|
||||
334
lib/discordcr/src/discordcr/voice.cr
Normal file
334
lib/discordcr/src/discordcr/voice.cr
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
require "uri"
|
||||
|
||||
require "./mappings/gateway"
|
||||
require "./mappings/vws"
|
||||
require "./websocket"
|
||||
require "./sodium"
|
||||
|
||||
module Discord
|
||||
class VoiceClient
|
||||
UDP_PROTOCOL = "udp"
|
||||
|
||||
Log = Discord::Log.for("voice")
|
||||
|
||||
# Supported encryption modes. Sorted by preference
|
||||
ENCRYPTION_MODES = {"xsalsa20_poly1305_lite", "xsalsa20_poly1305_suffix", "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
|
||||
|
||||
@udp : VoiceUDP
|
||||
|
||||
@sequence : UInt16 = 0_u16
|
||||
@time : UInt32 = 0_u32
|
||||
|
||||
@endpoint : String
|
||||
@server_id : UInt64
|
||||
@user_id : UInt64
|
||||
@session_id : String
|
||||
@token : String
|
||||
|
||||
@heartbeat_interval : Float32?
|
||||
@send_heartbeats = false
|
||||
|
||||
# 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 | Snowflake)
|
||||
initialize(payload.endpoint, payload.token, session.session_id, payload.guild_id, user_id)
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
def initialize(@endpoint, @token, @session_id, guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake)
|
||||
@user_id = user_id.to_u64
|
||||
host, port = @endpoint.split(':')
|
||||
|
||||
@server_id = guild_id.to_u64
|
||||
|
||||
@websocket = Discord::WebSocket.new(
|
||||
host: host,
|
||||
path: "/?v=4",
|
||||
port: port.to_i,
|
||||
tls: true
|
||||
)
|
||||
|
||||
@websocket.on_message(&->on_message(Discord::WebSocket::Packet))
|
||||
@websocket.on_close(&->on_close(HTTP::WebSocket::CloseCode, String))
|
||||
|
||||
@udp = VoiceUDP.new
|
||||
end
|
||||
|
||||
# Initiates the connection process and blocks forever afterwards.
|
||||
def run
|
||||
@send_heartbeats = true
|
||||
spawn { heartbeat_loop }
|
||||
@websocket.run
|
||||
end
|
||||
|
||||
# Closes the VWS connection, in effect disconnecting from voice.
|
||||
def close
|
||||
@send_heartbeats = false
|
||||
@websocket.close
|
||||
end
|
||||
|
||||
# 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
|
||||
while @send_heartbeats
|
||||
if @heartbeat_interval
|
||||
@websocket.send({op: 3, d: Time.utc.to_unix_ms}.to_json)
|
||||
sleep @heartbeat_interval.not_nil!.milliseconds
|
||||
else
|
||||
sleep 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private def on_message(packet : Discord::WebSocket::Packet)
|
||||
Log.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)
|
||||
else
|
||||
# TODO: Debug log unknown opcodes?
|
||||
end
|
||||
end
|
||||
|
||||
private def on_close(code : HTTP::WebSocket::CloseCode, message : String)
|
||||
@send_heartbeats = false
|
||||
reason = message.empty? ? "(none)" : message
|
||||
Log.warn { "VWS closed with code: #{code}, reason: #{reason}" }
|
||||
end
|
||||
|
||||
private def handle_ready(payload : VWS::ReadyPayload)
|
||||
if selected_crypto = ENCRYPTION_MODES.find { |preferred| payload.modes.includes?(preferred) }
|
||||
udp_connect(payload.ip, payload.port.to_u32, payload.ssrc.to_u32, selected_crypto)
|
||||
else
|
||||
raise "No supported crypto modes found in #{payload.modes}"
|
||||
end
|
||||
end
|
||||
|
||||
private def udp_connect(ip, port, ssrc, encryption_mode)
|
||||
@udp.connect(ip, port, ssrc)
|
||||
@udp.send_discovery
|
||||
ip, port = @udp.receive_discovery_reply
|
||||
send_select_protocol(UDP_PROTOCOL, ip, port, encryption_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)
|
||||
@udp.mode = payload.mode
|
||||
|
||||
# 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?
|
||||
@mode : String?
|
||||
@lite_nonce : UInt32 = 0
|
||||
|
||||
property secret_key
|
||||
property mode
|
||||
getter socket
|
||||
|
||||
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
|
||||
data = Bytes.new(74)
|
||||
IO::ByteFormat::BigEndian.encode(1_u16, data[0, 2]) # Mark as request
|
||||
IO::ByteFormat::BigEndian.encode(70_u16, data[2, 2]) # Message size
|
||||
IO::ByteFormat::BigEndian.encode(@ssrc.not_nil!, data[4, 4])
|
||||
@socket.write(data)
|
||||
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(74)
|
||||
@socket.receive(buf)
|
||||
|
||||
# The first 8 bytes are utility and the SSRC again, we don't care about that
|
||||
data = buf[8, buf.size - 8]
|
||||
ip = String.new(data[0, 64]).delete("\0")
|
||||
port = IO::ByteFormat::BigEndian.decode(UInt16, data[64, 2])
|
||||
|
||||
{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)
|
||||
nonce = create_nonce(header)
|
||||
buf = encrypt_audio(nonce, buf)
|
||||
|
||||
new_buf = if @mode == "xsalsa20_poly1305"
|
||||
Bytes.new(header.size + buf.size)
|
||||
else
|
||||
Bytes.new(header.size + buf.size + nonce.size)
|
||||
end
|
||||
|
||||
header.copy_to(new_buf)
|
||||
buf.copy_to(new_buf + header.size)
|
||||
|
||||
nonce.copy_to(new_buf + header.size + buf.size) unless @mode == "xsalsa20_poly1305"
|
||||
|
||||
@socket.write(new_buf)
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
def create_header(sequence : UInt16, time : UInt32) : Bytes
|
||||
bytes = Bytes.new(12)
|
||||
|
||||
# Write the magic bytes required by Discord
|
||||
bytes[0] = 0x80_u8
|
||||
bytes[1] = 0x78_u8
|
||||
|
||||
IO::ByteFormat::BigEndian.encode(sequence, bytes[2, 2])
|
||||
IO::ByteFormat::BigEndian.encode(time, bytes[4, 4])
|
||||
IO::ByteFormat::BigEndian.encode(@ssrc.not_nil!, bytes[8, 4])
|
||||
|
||||
bytes
|
||||
end
|
||||
|
||||
private def create_nonce(header : Bytes)
|
||||
nonce = nil
|
||||
case @mode
|
||||
when "xsalsa20_poly1305"
|
||||
nonce = Bytes.new(header.size)
|
||||
header.copy_to(nonce)
|
||||
when "xsalsa20_poly1305_suffix"
|
||||
nonce = Random::Secure.random_bytes(24)
|
||||
when "xsalsa20_poly1305_lite"
|
||||
nonce = Bytes.new(4)
|
||||
IO::ByteFormat::BigEndian.encode(@lite_nonce, nonce)
|
||||
|
||||
@lite_nonce &+= 1
|
||||
else
|
||||
raise "Cannot create a nonce for unsupported audio mode #{@mode.inspect}"
|
||||
end
|
||||
nonce
|
||||
end
|
||||
|
||||
private def encrypt_audio(nonce : Bytes, buf : Bytes) : Bytes
|
||||
raise "No secret key was set!" unless @secret_key
|
||||
|
||||
sodium_nonce = Bytes.new(24, 0_u8)
|
||||
nonce.copy_to(sodium_nonce)
|
||||
|
||||
# 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, sodium_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)
|
||||
delta = Time.measure { yield }
|
||||
|
||||
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
|
||||
135
lib/discordcr/src/discordcr/websocket.cr
Normal file
135
lib/discordcr/src/discordcr/websocket.cr
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
require "http"
|
||||
require "compress/zlib"
|
||||
|
||||
module Discord
|
||||
# Internal wrapper around HTTP::WebSocket to decode the Discord-specific
|
||||
# payload format used in the gateway and VWS.
|
||||
class WebSocket
|
||||
Log = Discord::Log.for("ws")
|
||||
|
||||
# :nodoc:
|
||||
struct Packet
|
||||
include JSON::Serializable
|
||||
|
||||
module DataConverter
|
||||
def self.from_json(parser)
|
||||
data = IO::Memory.new
|
||||
JSON.build(data) do |builder|
|
||||
parser.read_raw(builder)
|
||||
end
|
||||
data.rewind
|
||||
end
|
||||
|
||||
def self.to_json(value, builder)
|
||||
builder.raw(value.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
@[JSON::Field(key: "op")]
|
||||
getter opcode : Int64
|
||||
|
||||
@[JSON::Field(key: "s")]
|
||||
getter sequence : Int64?
|
||||
|
||||
@[JSON::Field(key: "d", converter: Discord::WebSocket::Packet::DataConverter)]
|
||||
getter data : IO::Memory
|
||||
|
||||
@[JSON::Field(key: "t")]
|
||||
getter event_type : String?
|
||||
|
||||
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
|
||||
|
||||
ZLIB_SUFFIX = Bytes[0x0, 0x0, 0xFF, 0xFF]
|
||||
|
||||
@zlib_reader : Compress::Zlib::Reader?
|
||||
@buffer : Bytes
|
||||
|
||||
def initialize(@host : String, @path : String, @port : Int32, @tls : Bool,
|
||||
@zlib_buffer_size : Int32 = 10 * 1024 * 1024)
|
||||
Log.info { "Connecting to #{@host}:#{@port}#{@path}" }
|
||||
@websocket = HTTP::WebSocket.new(
|
||||
host: @host,
|
||||
path: @path,
|
||||
port: @port,
|
||||
tls: @tls
|
||||
)
|
||||
|
||||
# Buffer for zlib-stream
|
||||
@buffer_memory = Bytes.empty
|
||||
@buffer = @buffer_memory[0, 0]
|
||||
@zlib_io = IO::Memory.new
|
||||
@zlib_reader = nil
|
||||
end
|
||||
|
||||
def on_compressed(&handler : Packet ->)
|
||||
@websocket.on_binary do |binary|
|
||||
io = IO::Memory.new(binary)
|
||||
Compress::Zlib::Reader.open(io) do |reader|
|
||||
payload = Packet.from_json(reader)
|
||||
Log.debug { "[WS IN] (compressed, #{binary.size} bytes) #{payload.to_json}" }
|
||||
handler.call(payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def on_compressed_stream(&handler : Packet ->)
|
||||
@buffer_memory = Bytes.new(@zlib_buffer_size)
|
||||
@websocket.on_binary do |binary|
|
||||
@zlib_io.write binary
|
||||
next if binary.size < 4 || binary[binary.size - 4, 4] != ZLIB_SUFFIX
|
||||
@zlib_io.rewind
|
||||
|
||||
zlib_reader = (@zlib_reader ||= Compress::Zlib::Reader.new(@zlib_io))
|
||||
|
||||
read_size = zlib_reader.read(@buffer_memory)
|
||||
@buffer = @buffer_memory[0, read_size]
|
||||
|
||||
payload = Packet.from_json(IO::Memory.new(@buffer))
|
||||
Log.debug { "[WS IN] (compressed, #{binary.size} bytes) #{payload.to_json}" }
|
||||
handler.call(payload)
|
||||
|
||||
@zlib_io.clear
|
||||
end
|
||||
end
|
||||
|
||||
def on_message(&handler : Packet ->)
|
||||
@websocket.on_message do |message|
|
||||
Log.debug { "[WS IN] #{message}" }
|
||||
payload = Packet.from_json(message)
|
||||
handler.call(payload)
|
||||
end
|
||||
end
|
||||
|
||||
def on_close(&handler : HTTP::WebSocket::CloseCode, String ->)
|
||||
@websocket.on_close(&handler)
|
||||
end
|
||||
|
||||
def run
|
||||
@websocket.run
|
||||
end
|
||||
|
||||
def close(code : HTTP::WebSocket::CloseCode | Int? = nil, message = nil)
|
||||
Log.info { "Closing with code: #{code} #{message || "(no message)"}" }
|
||||
@websocket.close(code, message)
|
||||
end
|
||||
|
||||
def send(message)
|
||||
Log.debug { "[WS OUT] #{message}" }
|
||||
@websocket.send(message)
|
||||
end
|
||||
end
|
||||
end
|
||||
184
main.cr
Normal file
184
main.cr
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
require "discordcr"
|
||||
require "json"
|
||||
require "uuid"
|
||||
require "./responses"
|
||||
require "./reactions"
|
||||
|
||||
token = ENV["DISCORD_BOT_TOKEN"]? || raise "Missing DISCORD_BOT_TOKEN env variable"
|
||||
|
||||
puts("Bot #{token}")
|
||||
|
||||
client = Discord::Client.new(
|
||||
token: "Bot #{token}",
|
||||
client_id: 1475668463363293398_u64
|
||||
)
|
||||
|
||||
DICT_FILE = "./dictionary.json"
|
||||
USER_LIMIT = 100
|
||||
|
||||
def load_dict
|
||||
if File.exists?(DICT_FILE)
|
||||
JSON.parse(File.read(DICT_FILE)).as_h.transform_values do |defs|
|
||||
defs.as_a.map do |d|
|
||||
{
|
||||
"id" => d["id"].as_s,
|
||||
"definition" => d["definition"].as_s,
|
||||
"author" => d["author"].as_s,
|
||||
"upvotes" => d["upvotes"].as_i,
|
||||
"downvotes" => d["downvotes"].as_i
|
||||
}
|
||||
end
|
||||
end
|
||||
else
|
||||
{} of String => Array(Hash(String, String|Int32))
|
||||
end
|
||||
end
|
||||
|
||||
def save_dict(dict)
|
||||
File.write(DICT_FILE, dict.to_json)
|
||||
end
|
||||
|
||||
dictionary = load_dict
|
||||
|
||||
def user_def_count(dict, username)
|
||||
dict.values.flatten.count { |d| d["author"] == username }
|
||||
end
|
||||
|
||||
client.on_message_create do |payload|
|
||||
content = payload.content
|
||||
args = content.split(" ", 3)
|
||||
command = args[0]?
|
||||
user = payload.author.username
|
||||
|
||||
if payload.author.bot
|
||||
next
|
||||
end
|
||||
|
||||
case command
|
||||
when "!add"
|
||||
word = args[1]?
|
||||
definition = args[2]?
|
||||
|
||||
if word.nil? || definition.nil?
|
||||
client.create_message(payload.channel_id, "usage: !add <word> <definition>")
|
||||
next
|
||||
end
|
||||
|
||||
if user_def_count(dictionary, user) >= USER_LIMIT
|
||||
client.create_message(payload.channel_id, "#{user}, you reached the limit of #{USER_LIMIT} definitions.")
|
||||
next
|
||||
end
|
||||
|
||||
dictionary[word] ||= [] of Hash(String, String | Int32)
|
||||
dictionary[word] << {
|
||||
"id" => UUID.random.to_s,
|
||||
"definition" => definition,
|
||||
"author" => user,
|
||||
"upvotes" => 0,
|
||||
"downvotes" => 0
|
||||
}
|
||||
|
||||
save_dict(dictionary)
|
||||
client.create_message(payload.channel_id, "added definition for **#{word}** by #{user}.")
|
||||
|
||||
when "!define"
|
||||
word = args[1]?
|
||||
if word.nil?
|
||||
client.create_message(payload.channel_id, "usage: !define <word>")
|
||||
next
|
||||
end
|
||||
|
||||
defs = dictionary[word]?
|
||||
if defs
|
||||
text = defs.map_with_index do |d, i|
|
||||
"#{i+1}. #{d["definition"]} (by #{d["author"]}) 👍#{d["upvotes"]} 👎#{d["downvotes"]}"
|
||||
end.join("\n")
|
||||
client.create_message(payload.channel_id, "**#{word}**:\n#{text}")
|
||||
else
|
||||
client.create_message(payload.channel_id, "no definition for **#{word}**.")
|
||||
end
|
||||
|
||||
when "!list"
|
||||
word = args[1]?
|
||||
if word.nil?
|
||||
client.create_message(payload.channel_id, "usage: !list <word>")
|
||||
next
|
||||
end
|
||||
|
||||
defs = dictionary[word]?
|
||||
if defs
|
||||
text = defs.map_with_index do |d, i|
|
||||
"#{i+1}. #{d["definition"]} (by #{d["author"]}) 👍#{d["upvotes"]} 👎#{d["downvotes"]}"
|
||||
end.join("\n")
|
||||
client.create_message(payload.channel_id, "**#{word}** definitions:\n#{text}")
|
||||
else
|
||||
client.create_message(payload.channel_id, "no definitions found.")
|
||||
end
|
||||
|
||||
when "!upvote", "!downvote"
|
||||
word = args[1]?
|
||||
index = args[2]? ? args[2].to_i : nil
|
||||
|
||||
if word.nil? || index.nil?
|
||||
client.create_message(payload.channel_id, "usage: #{command} <word> <definition_number>")
|
||||
next
|
||||
end
|
||||
|
||||
defs = dictionary[word]?
|
||||
if defs && index > 0 && index <= defs.size
|
||||
target = defs[index - 1]
|
||||
if command == "!upvote"
|
||||
target["upvotes"] = target["upvotes"].to_i + 1
|
||||
else
|
||||
target["downvotes"] = target["downvotes"].to_i + 1
|
||||
end
|
||||
save_dict(dictionary)
|
||||
client.create_message(payload.channel_id, "updated votes for definition #{index} of **#{word}**.")
|
||||
else
|
||||
client.create_message(payload.channel_id, "invalid word or definition number.")
|
||||
end
|
||||
|
||||
when "!random"
|
||||
if dictionary.empty?
|
||||
client.create_message(payload.channel_id, "nichectionary is empty.")
|
||||
next
|
||||
end
|
||||
|
||||
word = dictionary.keys.sample
|
||||
defs = dictionary[word]
|
||||
def_choice = defs.sample
|
||||
client.create_message(payload.channel_id,
|
||||
"**#{word}**:\n#{def_choice["definition"]} (by #{def_choice["author"]}) 👍#{def_choice["upvotes"]} 👎#{def_choice["downvotes"]}"
|
||||
)
|
||||
|
||||
when "!search"
|
||||
term = args[1]?
|
||||
if term.nil?
|
||||
client.create_message(payload.channel_id, "usage: !search <term>")
|
||||
next
|
||||
end
|
||||
|
||||
matches = dictionary.select { |w, _| w.includes?(term) }
|
||||
if matches.empty?
|
||||
client.create_message(payload.channel_id, "no words matched `#{term}`.")
|
||||
next
|
||||
end
|
||||
|
||||
text = matches.keys.join(", ")
|
||||
client.create_message(payload.channel_id, "words matching `#{term}`: #{text}")
|
||||
end
|
||||
|
||||
Reactions.check_message(content).each do |emoji|
|
||||
client.create_reaction(payload.channel_id, payload.id, emoji)
|
||||
end
|
||||
|
||||
preset_reply = Responses.check_message(content)
|
||||
if preset_reply
|
||||
client.trigger_typing_indicator(payload.channel_id)
|
||||
sleep 500.milliseconds
|
||||
client.create_message(payload.channel_id, preset_reply, message_reference: payload.message_reference())
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
client.run
|
||||
20
reactions.cr
Normal file
20
reactions.cr
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
module Reactions
|
||||
TRIGGERS = {
|
||||
"void" => ["👀"],
|
||||
"cursed" => ["😳"],
|
||||
"based" => ["🗿"],
|
||||
}
|
||||
|
||||
def self.check_message(content : String) : Array(String)
|
||||
lower = content.downcase
|
||||
emojis = [] of String
|
||||
|
||||
TRIGGERS.each do |keyword, reactions|
|
||||
if lower.matches?(/\b#{Regex.escape(keyword)}\b/)
|
||||
emojis.concat(reactions)
|
||||
end
|
||||
end
|
||||
|
||||
emojis
|
||||
end
|
||||
end
|
||||
28
responses.cr
Normal file
28
responses.cr
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
module Responses
|
||||
PRESETS = {
|
||||
"wachin" => ["wachin vos", "k onda wachinovich", "qn te juna a vos wachin"],
|
||||
"mamita" => ["\"mamita\" Villacoño :round_pushpin:"],
|
||||
"niche" => ["very niche indeed :eyes:"],
|
||||
"parezco musulmana" => ["TIA K DICES :woman_facepalming:"],
|
||||
"eh?" => ["jajaja"],
|
||||
"jaja" => ["se jijea el jijonoide", "se jijeaba", "el jijonazo"],
|
||||
"rust" => ["ruzzzzzzzt"],
|
||||
"we" => ["we? eres mexa? :face_vomiting:", "we dice el pelotudo", "#freemencho"],
|
||||
"tranki" => ["piola sin berretin"],
|
||||
"67" => ["<:67:1478470308678865066>"],
|
||||
"smokedope" => ["WE LOVE SMOKEDOPE"],
|
||||
"pinto" => ["y si, pintó"]
|
||||
}
|
||||
|
||||
PROBABILITY = 0.55
|
||||
|
||||
def self.check_message(message : String)
|
||||
PRESETS.each do |keyword, responses|
|
||||
if message.downcase.includes?(keyword)
|
||||
prob = rand
|
||||
return responses.sample if prob < PROBABILITY
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
end
|
||||
6
shard.lock
Normal file
6
shard.lock
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
version: 2.0
|
||||
shards:
|
||||
discordcr:
|
||||
git: https://github.com/shardlab/discordcr.git
|
||||
version: 0.4.0+git.commit.0e03deb8ffa247814f2fec4e197cba7a62534f85
|
||||
|
||||
24
shard.yml
Normal file
24
shard.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
name: cr
|
||||
version: 0.1.0
|
||||
|
||||
dependencies:
|
||||
discordcr:
|
||||
github: shardlab/discordcr
|
||||
branch: master
|
||||
|
||||
# authors:
|
||||
# - name <email@example.com>
|
||||
|
||||
# description: |
|
||||
# Short description of cr
|
||||
|
||||
# dependencies:
|
||||
# pg:
|
||||
# github: will/crystal-pg
|
||||
# version: "~> 0.5"
|
||||
|
||||
# development_dependencies:
|
||||
# webmock:
|
||||
# github: manastech/webmock.cr
|
||||
|
||||
# license: MIT
|
||||
Loading…
Add table
Reference in a new issue