Started learning crystal and made an OOP example for a freind

This commit is contained in:
plane000
2018-06-19 21:02:20 +01:00
parent 70aff3619e
commit 80df37405a
52 changed files with 5731 additions and 0 deletions

25
C#/this dot/this dot.sln Normal file
View File

@@ -0,0 +1,25 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.27428.2043
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "this dot", "this dot\this dot.csproj", "{2119289F-E327-4138-82E7-414D9FB8DD9E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2119289F-E327-4138-82E7-414D9FB8DD9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2119289F-E327-4138-82E7-414D9FB8DD9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2119289F-E327-4138-82E7-414D9FB8DD9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2119289F-E327-4138-82E7-414D9FB8DD9E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {70988D6A-4E61-4366-8D8E-7440E079C2AB}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.1" />
</startup>
</configuration>

View File

@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace this_dot {
class Program {
static void Main() {
Person one = new Person("Dave", 16);
Person two = new Person("John", 21, one);
Person three = new Person("Chris", 12, two);
Person four = new Person("Vero", 23, three);
Person[] ppl = { one, two, three, four };
foreach (Person person in ppl) {
this.getOlder();
}
}
}
class Person {
public string Name { get; set; }
public int Age { get; set; }
public Person Parent { get; set; }
public Person(string _name, int _age, Person _parent) {
Name = _name;
Age = _age;
Parent = _parent;
}
public Person(string _name, int _age) {
Name = _name;
Age = _age;
}
public void getOlder() {
Age++;
}
}
}

View File

@@ -0,0 +1,36 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("this dot")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("this dot")]
[assembly: AssemblyCopyright("Copyright © 2018")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("2119289f-e327-4138-82e7-414d9fb8dd9e")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{2119289F-E327-4138-82E7-414D9FB8DD9E}</ProjectGuid>
<OutputType>Exe</OutputType>
<RootNamespace>this_dot</RootNamespace>
<AssemblyName>this dot</AssemblyName>
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

View File

@@ -0,0 +1 @@
5c722de5c25a6020466e80bc10d7904a0e30e7d5

View 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

View 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"

View 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.

View File

@@ -0,0 +1,88 @@
[![docs](https://img.shields.io/badge/docs-latest-green.svg?style=flat-square)](https://meew0.github.io/discordcr/doc/v0.1.0/)
# discordcr
(The "cr" stands for "creative name".)
discordcr is a minimalist [Discord](https://discordapp.com/) API library for
[Crystal](https://crystal-lang.org/), designed to be a complement to
[discordrb](https://github.com/meew0/discordrb) for users who want more control
and performance and who care less about ease-of-use.
discordcr isn't designed for beginners to the Discord API - while experience
with making bots isn't *required*, it's certainly recommended. If you feel
overwhelmed by the complex documentation, try
[discordrb](https://github.com/meew0/discordrb) first and then check back.
Unlike many other libs which handle a lot of stuff, like caching or resolving,
themselves automatically, discordcr requires the user to do such things
manually. It also doesn't provide any advanced abstractions for REST calls;
the methods perform the HTTP request with the given data but nothing else.
This means that the user has full control over them, but also full
responsibility. discordcr does not support user accounts; it may work but
likely doesn't.
## Installation
Add this to your application's `shard.yml`:
```yaml
dependencies:
discordcr:
github: meew0/discordcr
```
## Usage
An example bot can be found
[here](https://github.com/meew0/discordcr/blob/master/examples/ping.cr). More
examples will come in the future.
A short overview of library structure: the `Client` class includes the `REST`
module, which handles the REST parts of Discord's API; the `Client` itself
handles the gateway, i. e. the interactive parts such as receiving messages. It
is possible to use only the REST parts by never calling the `#run` method on a
`Client`, which is what does the actual gateway connection.
The example linked above has an example of an event (`on_message_create`) that
is called through the gateway, and of a REST call (`client.create_message`).
Other gateway events and REST calls work much in the same way - see the
documentation for what specific events and REST calls do.
Caching is done using a separate `Cache` class that needs to be added into
clients manually:
```cr
client = Discord::Client.new # ...
cache = Discord::Cache.new(client)
client.cache = cache
```
Resolution requests for objects can now be done on the `cache` object instead of
directly over REST, this ensures that if an object is needed more than once
there will still only be one request to Discord. (There may even be no request
at all, if the requested data has already been obtained over the gateway.)
An example of how to use the cache once it has been instantiated:
```cr
# Get the username of the user with ID 66237334693085184
user = cache.resolve_user(66237334693085184_u64)
user = cache.resolve_user(66237334693085184_u64) # won't do a request to Discord
puts user.username
```
Apart from this, API documentation is also available, at
https://meew0.github.io/discordcr/doc/v0.1.0/.
## Contributing
1. Fork it (https://github.com/meew0/discordcr/fork)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create a new Pull Request
## Contributors
- [meew0](https://github.com/meew0) - creator, maintainer
- [RX14](https://github.com/RX14) - Crystal expert, maintainer

View 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

Binary file not shown.

View 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

View File

@@ -0,0 +1,35 @@
# multicommand.cr is an example that uses a simple command "dispatcher"
# via a case statement.
# This example features a few commands:
# » !help ==> sends a dm (direct message) to the user
# with information
# » !about ==> prints about information in a code block
# » !echo <args> ==> echos args
# » !date ==> prints the current date
require "../src/discordcr"
# Make sure to replace this fake data with actual data when running.
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64)
# Command Prefix
PREFIX = "!"
client.on_message_create do |payload|
command = payload.content
case command
when PREFIX + "help"
client.create_message(client.create_dm(payload.author.id).id, "Help is on the way!")
when PREFIX + "about"
block = "```\nBot developed by discordcr\n```"
client.create_message(payload.channel_id, block)
when .starts_with? PREFIX + "echo"
# !echo is a good example of a command with arguments (suffix)
suffix = command.split(' ')[1..-1].join(" ")
client.create_message(payload.channel_id, suffix)
when PREFIX + "date"
client.create_message(payload.channel_id, Time.now.to_s("%D"))
end
end
client.run

View 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

View 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_now - payload.timestamp
client.edit_message(m.channel_id, m.id, "Pong! Time taken: #{time.total_milliseconds} ms.")
end
end
client.run

View File

@@ -0,0 +1,130 @@
# This is a simple music bot that can connect to a voice channel and play back
# some music in DCA format. It demonstrates how to use VoiceClient and
# DCAParser.
#
# For more information on the DCA file format, see
# https://github.com/bwmarrin/dca.
require "../src/discordcr"
# Make sure to replace this fake data with actual data when running.
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64)
# ID of the current user, required to create a voice client
current_user_id = nil
# The ID of the (text) channel in which the connect command was run, so the
# "Voice connected." message is sent to the correct channel
connect_channel_id = nil
# Where the created voice client will eventually be stored
voice_client = nil
client.on_ready do |payload|
current_user_id = payload.user.id
end
client.on_message_create do |payload|
if payload.content.starts_with? "!connect "
# Used as:
# !connect <guild ID> <channel ID>
# Parse the command arguments
ids = payload.content[9..-1].split(' ').map(&.to_u64)
client.create_message(payload.channel_id, "Connecting...")
connect_channel_id = payload.channel_id
client.voice_state_update(ids[0].to_u64, ids[1].to_u64, false, false)
elsif payload.content.starts_with? "!play_dca "
# Used as:
# !play_dca <filename>
#
# Make sure the DCA file you play back is valid according to the spec
# (including metadata), otherwise playback will fail.
unless voice_client
client.create_message(payload.channel_id, "Voice client is nil!")
next
end
filename = payload.content[10..-1]
file = File.open(filename)
# The DCAParser class handles parsing of the DCA file. It doesn't do any
# sending of audio data to Discord itself that has to be done by
# VoiceClient.
parser = Discord::DCAParser.new(file)
# A proper DCA(1) file contains metadata, which is exposed by DCAParser.
# This metadata may be of interest, so here is some example code that uses
# it.
if metadata = parser.metadata
tool = metadata.dca.tool
client.create_message(payload.channel_id, "DCA file was created by #{tool.name}, version #{tool.version}.")
if info = metadata.info
client.create_message(payload.channel_id, "Song info: #{info.title} by #{info.artist}.") if info.title && info.artist
end
else
client.create_message(payload.channel_id, "DCA file metadata is invalid!")
end
# Set the bot as speaking (green circle). This is important and has to be
# done at least once in every voice connection, otherwise the Discord client
# will not know who the packets we're sending belongs to.
voice_client.not_nil!.send_speaking(true)
client.create_message(payload.channel_id, "Playing DCA file `#{filename}`.")
# For smooth audio streams Discord requires one packet every
# 20 milliseconds. The `every` method measures the time it takes to run the
# block and then sleeps 20 milliseconds minus that time before moving on to
# the next iteration, ensuring accurate timing.
#
# When simply reading from DCA, the time it takes to read, process and
# send the frame is small enough that `every` doesn't make much of a
# difference (in fact, some users report that it actually makes things
# worse). If the processing time is not negligibly slow because you're
# doing something else than DCA parsing, or because you're reading from a
# slow source, or for any other reason, then it is recommended to use
# `every`. Otherwise, simply using a loop and `sleep`ing `20.milliseconds`
# each time may suffice.
Discord.every(20.milliseconds) do
frame = parser.next_frame(reuse_buffer: true)
break unless frame
# Perform the actual sending of the frame to Discord.
voice_client.not_nil!.play_opus(frame)
end
# Alternatively, the above code can be realised as the following:
#
# parser.parse do |frame|
# Discord.timed_run(20.milliseconds) do
# voice_client.not_nil!.play_opus(frame)
# end
# end
#
# (The `parse` method reads the frames consecutively and passes them to the
# block.)
file.close
end
end
# The VOICE_SERVER_UPDATE dispatch is sent by Discord once the op4 packet sent
# by voice_state_update has been processed. It tells the client the endpoint
# to connect to.
client.on_voice_server_update do |payload|
begin
vc = voice_client = Discord::VoiceClient.new(payload, client.session.not_nil!, current_user_id.not_nil!)
vc.on_ready do
client.create_message(connect_channel_id.not_nil!, "Voice connected.")
end
vc.run
rescue e
e.inspect_with_backtrace(STDOUT)
end
end
client.run

View 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

View File

@@ -0,0 +1,7 @@
name: discordcr
version: 0.3.0
authors:
- meew0 <blactbt@live.de>
license: MIT

View File

@@ -0,0 +1,171 @@
require "yaml"
require "./spec_helper"
struct StructWithSnowflake
JSON.mapping(
data: {type: UInt64, converter: Discord::SnowflakeConverter}
)
end
struct StructWithMaybeSnowflake
JSON.mapping(
data: {type: UInt64?, converter: Discord::MaybeSnowflakeConverter}
)
end
struct StructWithSnowflakeArray
JSON.mapping(
data: {type: Array(UInt64), converter: Discord::SnowflakeArrayConverter}
)
end
struct StructWithTime
JSON.mapping(
data: {type: Time, converter: Discord::TimestampConverter}
)
end
struct StructWithMessageType
JSON.mapping(
data: {type: Discord::MessageType, converter: Discord::MessageTypeConverter}
)
end
struct StructWithChannelType
JSON.mapping(
data: {type: Discord::ChannelType, converter: Discord::ChannelTypeConverter}
)
end
describe Discord do
describe "VERSION" do
it "matches shards.yml" do
version = YAML.parse(File.read(File.join(__DIR__, "..", "shard.yml")))["version"].as_s
version.should eq(Discord::VERSION)
end
end
describe Discord::TimestampConverter do
it "parses a time with floating point accuracy" do
json = %({"data":"2017-11-16T13:09:18.291000+00:00"})
obj = StructWithTime.from_json(json)
obj.data.should be_a Time
end
it "parses a time without floating point accuracy" do
json = %({"data":"2017-11-15T02:23:35+00:00"})
obj = StructWithTime.from_json(json)
obj.data.should be_a Time
end
it "serializes" do
json = %({"data":"2017-11-16T13:09:18.291000+00:00"})
obj = StructWithTime.from_json(json)
obj.to_json.should eq json
end
end
describe Discord::SnowflakeConverter do
it "converts a string to u64" do
json = %({"data":"10000000000"})
obj = StructWithSnowflake.from_json(json)
obj.data.should eq 10000000000
obj.data.should be_a UInt64
end
end
describe Discord::MaybeSnowflakeConverter do
it "converts a string to u64" do
json = %({"data":"10000000000"})
obj = StructWithMaybeSnowflake.from_json(json)
obj.data.should eq 10000000000
obj.data.should be_a UInt64
end
it "converts null to nil" do
json = %({"data":null})
obj = StructWithMaybeSnowflake.from_json(json)
obj.data.should eq nil
end
end
describe Discord::SnowflakeArrayConverter do
it "converts an array of strings to u64s" do
json = %({"data":["1", "2", "10000000000"]})
obj = StructWithSnowflakeArray.from_json(json)
obj.data.should be_a Array(UInt64)
obj.data[0].should eq 1
obj.data[1].should eq 2
obj.data[2].should eq 10000000000
end
end
describe Discord::REST::ModifyChannelPositionPayload do
describe "#to_json" do
context "parent_id is ChannelParent::Unchanged" do
it "doesn't emit parent_id" do
payload = {Discord::REST::ModifyChannelPositionPayload.new(0_u64, 0, Discord::REST::ChannelParent::Unchanged, true)}
payload.to_json.should eq %([{"id":"0","position":0,"lock_permissions":true}])
end
end
context "parent_id is ChannelParent::None" do
it "emits null for parent_id" do
payload = {Discord::REST::ModifyChannelPositionPayload.new(0_u64, 0, Discord::REST::ChannelParent::None, true)}
payload.to_json.should eq %([{"id":"0","position":0,"parent_id":null,"lock_permissions":true}])
end
end
end
end
describe Discord::MessageTypeConverter do
it "converts an integer into a MessageType" do
json = %({"data": 0})
obj = StructWithMessageType.from_json(json)
obj.data.should eq Discord::MessageType::Default
end
context "with an invalid json value" do
it "raises" do
json = %({"data":"foo"})
expect_raises(Exception, %(Unexpected message type value: "foo")) do
StructWithMessageType.from_json(json)
end
end
end
end
describe Discord::WebSocket::Packet do
it "inspects" do
packet = Discord::WebSocket::Packet.new(0_i64, 1_i64, IO::Memory.new("foo"), "test")
packet.inspect.should eq %(Discord::WebSocket::Packet(@opcode=0_i64 @sequence=1_i64 @data="foo" @event_type="test"))
end
end
describe Discord::ChannelTypeConverter do
it "converts an integer into a ChannelType" do
json = %({"data": 0})
obj = StructWithChannelType.from_json(json)
obj.data.should eq Discord::ChannelType::GuildText
end
context "with an invalid json value" do
it "raises" do
json = %({"data":"foo"})
expect_raises(Exception, %(Unexpected channel type value: "foo")) do
StructWithChannelType.from_json(json)
end
end
end
end
end

View File

@@ -0,0 +1,50 @@
require "./spec_helper"
def it_parses_message(string, into expected)
it "parses #{string.inspect} into #{expected}" do
parsed = Discord::Mention.parse(string)
parsed.should eq expected
end
end
describe Discord::Mention do
describe ".parse" do
it_parses_message(
"<@123><@!456>",
into: [
Discord::Mention::User.new(123_u64, 0, 6),
Discord::Mention::User.new(456_u64, 6, 7),
]
)
it_parses_message(
"<@&123>",
into: [Discord::Mention::Role.new(123_u64, 0, 6)])
it_parses_message(
"<#123>",
into: [Discord::Mention::Channel.new(123_u64, 0, 6)])
it_parses_message(
"<:foo:123><a:bar:456>",
into: [
Discord::Mention::Emoji.new(false, "foo", 123_u64, 0, 10),
Discord::Mention::Emoji.new(true, "bar", 456_u64, 10, 11),
]
)
it_parses_message(
"@everyone@here",
into: [
Discord::Mention::Everyone.new(0),
Discord::Mention::Here.new(9),
]
)
context "with invalid mentions" do
it_parses_message(
"<<@123<@?123><#123<:foo:123<b:foo:123><@abc><@!abc>",
into: [] of Discord::Mention)
end
end
end

View 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

View File

@@ -0,0 +1,2 @@
require "spec"
require "../src/discordcr"

View File

@@ -0,0 +1,5 @@
require "./discordcr/*"
module Discord
# TODO Put your code here
end

View File

@@ -0,0 +1,252 @@
require "./mappings/*"
module Discord
# A cache is a utility class that stores various kinds of Discord objects,
# like `User`s, `Role`s etc. Its purpose is to reduce both the load on
# Discord's servers and reduce the latency caused by having to do an API call.
# It is recommended to use caching for bots that interact heavily with
# Discord-provided data, like for example administration bots, as opposed to
# bots that only interact by sending and receiving messages. For that latter
# kind, caching is usually even counter-productive as it only unnecessarily
# increases memory usage.
#
# Caching can either be used standalone, in a purely REST-based way:
# ```
# client = Discord::Client.new(token: "Bot token", client_id: 123_u64)
# cache = Discord::Cache.new(client)
#
# puts cache.resolve_user(66237334693085184) # will perform API call
# puts cache.resolve_user(66237334693085184) # will not perform an API call, as the data is now cached
# ```
#
# It can also be integrated more deeply into a `Client` (specifically one that
# uses a gateway connection) to reduce cache misses even more by automatically
# caching data received over the gateway:
# ```
# client = Discord::Client.new(token: "Bot token", client_id: 123_u64)
# cache = Discord::Cache.new(client)
# client.cache = cache # Integrate the cache into the client
# ```
#
# Note that if a cache is *not* used this way, its data will slowly go out of
# sync with Discord, and unless it is used in an environment with few changes
# likely to occur, a client without a gateway connection should probably
# refrain from caching at all.
class Cache
# A map of cached users. These aren't necessarily all the users in servers
# the bot has access to, but rather all the users that have been seen by
# the bot in the past (and haven't been deleted by means of `delete_user`).
getter users
# A map of cached channels, i. e. all channels on all servers the bot is on,
# as well as all DM channels.
getter channels
# A map of guilds (servers) the bot is on. Doesn't ignore guilds temporarily
# deleted due to an outage; so if an outage is going on right now the
# affected guilds would be missing here too.
getter guilds
# A double map of members on servers, represented as {guild ID => {user ID
# => member}}. Will only contain previously and currently online members as
# well as all members that have been chunked (see
# `Client#request_guild_members`).
getter members
# A map of all roles on servers the bot is on. Does not discriminate by
# guild, as role IDs are unique even across guilds.
getter roles
# Mapping of users to the respective DM channels the bot has open with them,
# represented as {user ID => channel ID}.
getter dm_channels
# Mapping of guilds to the roles on them, represented as {guild ID =>
# [role IDs]}.
getter guild_roles
# Mapping of guilds to the channels on them, represented as {guild ID =>
# [channel IDs]}.
getter guild_channels
# Creates a new cache with a *client* that requests (in case of cache
# misses) should be done on.
def initialize(@client : Client)
@users = Hash(UInt64, User).new
@channels = Hash(UInt64, Channel).new
@guilds = Hash(UInt64, Guild).new
@members = Hash(UInt64, Hash(UInt64, GuildMember)).new
@roles = Hash(UInt64, Role).new
@dm_channels = Hash(UInt64, UInt64).new
@guild_roles = Hash(UInt64, Array(UInt64)).new
@guild_channels = Hash(UInt64, Array(UInt64)).new
end
# Resolves a user by its *ID*. If the requested object is not cached, it
# will do an API call.
def resolve_user(id : UInt64) : User
@users.fetch(id) { @users[id] = @client.get_user(id) }
end
# Resolves a channel by its *ID*. If the requested object is not cached, it
# will do an API call.
def resolve_channel(id : UInt64) : Channel
@channels.fetch(id) { @channels[id] = @client.get_channel(id) }
end
# Resolves a guild by its *ID*. If the requested object is not cached, it
# will do an API call.
def resolve_guild(id : UInt64) : Guild
@guilds.fetch(id) { @guilds[id] = @client.get_guild(id) }
end
# Resolves a member by the *guild_id* of the guild the member is on, and the
# *user_id* of the member itself. An API request will be performed if the
# object is not cached.
def resolve_member(guild_id : UInt64, user_id : UInt64) : GuildMember
local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new
local_members.fetch(user_id) { local_members[user_id] = @client.get_guild_member(guild_id, user_id) }
end
# Resolves a role by its *ID*. No API request will be performed if the role
# is not cached, because there is no endpoint for individual roles; however
# all roles should be cached at all times so it won't be a problem.
def resolve_role(id : UInt64) : Role
@roles[id] # There is no endpoint for getting an individual role, so we will have to ignore that case for now.
end
# Resolves the ID of a DM channel with a particular user by the recipient's
# *recipient_id*. If there is no such channel cached, one will be created.
def resolve_dm_channel(recipient_id : UInt64) : UInt64
@dm_channels.fetch(recipient_id) do
channel = @client.create_dm(recipient_id)
cache(Channel.new(channel))
channel.id
end
end
# Resolves the current user's profile. Requires no parameters since the
# endpoint has none either. If there is a gateway connection this should
# always be cached.
def resolve_current_user : User
@current_user ||= @client.get_current_user
end
# Deletes a user from the cache given its *ID*.
def delete_user(id : UInt64)
@users.delete(id)
end
# Deletes a channel from the cache given its *ID*.
def delete_channel(id : UInt64)
@channels.delete(id)
end
# Deletes a guild from the cache given its *ID*.
def delete_guild(id : UInt64)
@guilds.delete(id)
end
# Deletes a member from the cache given its *user_id* and the *guild_id* it
# is on.
def delete_member(guild_id : UInt64, user_id : UInt64)
@members[guild_id]?.try &.delete(user_id)
end
# Deletes a role from the cache given its *ID*.
def delete_role(id : UInt64)
@roles.delete(id)
end
# Deletes a DM channel with a particular user given the *recipient_id*.
def delete_dm_channel(recipient_id : UInt64)
@dm_channels.delete(recipient_id)
end
# Deletes the current user from the cache, if that will ever be necessary.
def delete_current_user
@current_user = nil
end
# Adds a specific *user* to the cache.
def cache(user : User)
@users[user.id] = user
end
# Adds a specific *channel* to the cache.
def cache(channel : Channel)
@channels[channel.id] = channel
end
# Adds a specific *guild* to the cache.
def cache(guild : Guild)
@guilds[guild.id] = guild
end
# Adds a specific *member* to the cache, given the *guild_id* it is on.
def cache(member : GuildMember, guild_id : UInt64)
local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new
local_members[member.user.id] = member
end
# Adds a specific *role* to the cache.
def cache(role : Role)
@roles[role.id] = role
end
# Adds a particular DM channel to the cache, given the *channel_id* and the
# *recipient_id*.
def cache_dm_channel(channel_id : UInt64, recipient_id : UInt64)
@dm_channels[recipient_id] = channel_id
end
# Caches the current user.
def cache_current_user(@current_user : User); end
# Adds multiple *members* at once to the cache, given the *guild_id* they
# all share. This method exists to slightly reduce the overhead of
# processing chunks; outside of that it is likely not of much use.
def cache_multiple_members(members : Array(GuildMember), guild_id : UInt64)
local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new
members.each do |member|
local_members[member.user.id] = member
end
end
# Returns all roles of a guild, identified by its *guild_id*.
def guild_roles(guild_id : UInt64) : Array(UInt64)
@guild_roles[guild_id]
end
# Marks a role, identified by the *role_id*, as belonging to a particular
# guild, identified by the *guild_id*.
def add_guild_role(guild_id : UInt64, role_id : UInt64)
local_roles = @guild_roles[guild_id] ||= [] of UInt64
local_roles << role_id
end
# Marks a role as not belonging to a particular guild anymore.
def remove_guild_role(guild_id : UInt64, role_id : UInt64)
@guild_roles[guild_id]?.try { |local_roles| local_roles.delete(role_id) }
end
# Returns all channels of a guild, identified by its *guild_id*.
def guild_channels(guild_id : UInt64) : Array(UInt64)
@guild_channels[guild_id]
end
# Marks a channel, identified by the *channel_id*, as belonging to a particular
# guild, identified by the *guild_id*.
def add_guild_channel(guild_id : UInt64, channel_id : UInt64)
local_channels = @guild_channels[guild_id] ||= [] of UInt64
local_channels << channel_id
end
# Marks a channel as not belonging to a particular guild anymore.
def remove_guild_channel(guild_id : UInt64, channel_id : UInt64)
@guild_channels[guild_id]?.try { |local_channels| local_channels.delete(channel_id) }
end
end
end

View File

@@ -0,0 +1,843 @@
require "json"
require "./logger"
require "./rest"
require "./cache"
module Discord
# The basic client class that is used to connect to Discord, send REST
# requests, or send or receive gateway messages. It is required for doing any
# sort of interaction with Discord.
#
# A new simple client that does nothing yet can be created like this:
# ```
# client = Discord::Client.new(token: "Bot token", client_id: 123_u64)
# ```
#
# With this client, REST requests can now be sent. (See the `Discord::REST`
# module.) A gateway connection can also be started using the `#run` method.
class Client
include REST
# If this is set to any `Cache`, the data in the cache will be updated as
# the client receives the corresponding gateway dispatches.
property cache : Cache?
# The internal *session* the client is currently using, necessary to create
# a voice client, for example
getter session : Gateway::Session?
# The internal websocket the client is currently using
getter websocket : Discord::WebSocket do
initialize_websocket
end
@backoff : Float64
# Default analytics properties sent in IDENTIFY
DEFAULT_PROPERTIES = Gateway::IdentifyProperties.new(
os: "Crystal",
browser: "discordcr",
device: "discordcr",
referrer: "",
referring_domain: ""
)
# Creates a new bot with the given *token* and optionally the *client_id*.
# Both of these things can be found on a bot's application page; the token
# will need to be revealed using the "click to reveal" thing on the token
# (**not** the OAuth2 secret!)
#
# If the *shard* key is set, the gateway will operate in sharded mode. This
# means that this client's gateway connection will only receive packets from
# a part of the guilds the bot is connected to. See
# [here](https://discordapp.com/developers/docs/topics/gateway#sharding)
# for more information.
#
# The *large_threshold* defines the minimum member count that, if a guild
# has at least that many members, the client will only receive online
# members in GUILD_CREATE. The default value 100 is what the Discord client
# uses; the maximum value is 250. To get a list of offline members as well,
# the `#request_guild_members` method can be used.
#
# If *compress* is true, packets will be sent in a compressed manner.
# discordcr doesn't currently handle packet decompression, so until that is
# implemented, setting this to true will cause the client to fail to parse
# anything.
#
# The *properties* define what values are sent to Discord as analytics
# properties. It's not recommended to change these from the default values,
# but if you desire to do so, you can.
def initialize(@token : String, @client_id : UInt64? = nil,
@shard : Gateway::ShardKey? = nil,
@large_threshold : Int32 = 100,
@compress : Bool = false,
@properties : Gateway::IdentifyProperties = DEFAULT_PROPERTIES)
@backoff = 1.0
# Set some default value for the heartbeat interval. This should never
# actually be used as a delay between heartbeats because it will have an
# actual value before heartbeating starts.
@heartbeat_interval = 1000_u32
@send_heartbeats = false
# Initially, this flag is set to true so the client doesn't immediately
# try to reconnect at the next heartbeat.
@last_heartbeat_acked = true
# If the websocket is closed, whether we should immediately try and reconnect
@should_reconnect = true
setup_heartbeats
end
# Returns this client's ID as provided in its associated Oauth2 application.
# A getter for @client_id, this will make a REST call to obtain it
# if it was not provided in the initializer.
def client_id
@client_id ||= get_oauth2_application.id
end
# Connects this client to the gateway. This is required if the bot needs to
# do anything beyond making REST API calls. Calling this method will block
# execution until the bot is forcibly stopped.
def run
loop do
begin
websocket.run
rescue ex
LOGGER.error <<-LOG
Received exception from WebSocket#run:
#{ex.inspect_with_backtrace}
LOG
end
@send_heartbeats = false
@session.try &.suspend
break unless @should_reconnect
wait_for_reconnect
LOGGER.info "Reconnecting"
@websocket = initialize_websocket
end
end
# Closes the gateway connection permanently
def stop(message = nil)
@should_reconnect = false
websocket.close(message)
end
# Separate method to wait an ever-increasing amount of time before reconnecting after being disconnected in an
# unexpected way
def wait_for_reconnect
# Wait before reconnecting so we don't spam Discord's servers.
LOGGER.debug "Attempting to reconnect in #{@backoff} seconds."
sleep @backoff.seconds
# Calculate new backoff
@backoff = 1.0 if @backoff < 1.0
@backoff *= 1.5
@backoff = 115 + (rand * 10) if @backoff > 120 # Cap the backoff at 120 seconds and then add some random jitter
end
private def initialize_websocket : Discord::WebSocket
url = URI.parse(get_gateway.url)
websocket = Discord::WebSocket.new(
host: url.host.not_nil!,
path: "#{url.path}/?encoding=json&v=6",
port: 443,
tls: true
)
websocket.on_message(&->on_message(Discord::WebSocket::Packet))
websocket.on_close(&->on_close(String))
websocket
end
private def on_close(message : String)
# TODO: make more sophisticated
LOGGER.warn "Closed with: " + message
@send_heartbeats = false
@session.try &.suspend
nil
end
OP_DISPATCH = 0
OP_HEARTBEAT = 1
OP_IDENTIFY = 2
OP_STATUS_UPDATE = 3
OP_VOICE_STATE_UPDATE = 4
OP_VOICE_SERVER_PING = 5
OP_RESUME = 6
OP_RECONNECT = 7
OP_REQUEST_GUILD_MEMBERS = 8
OP_INVALID_SESSION = 9
OP_HELLO = 10
OP_HEARTBEAT_ACK = 11
private def on_message(packet : Discord::WebSocket::Packet)
spawn do
begin
case packet.opcode
when OP_HELLO
payload = Gateway::HelloPayload.from_json(packet.data)
handle_hello(payload.heartbeat_interval)
when OP_DISPATCH
handle_dispatch(packet.event_type.not_nil!, packet.data)
when OP_RECONNECT
handle_reconnect
when OP_INVALID_SESSION
handle_invalid_session
when OP_HEARTBEAT
# We got a received heartbeat, reply with the same sequence
LOGGER.debug "Heartbeat received"
websocket.send({op: 1, d: packet.sequence}.to_json)
when OP_HEARTBEAT_ACK
handle_heartbeat_ack
else
LOGGER.warn "Unsupported payload: #{packet}"
end
rescue ex : JSON::ParseException
LOGGER.error <<-LOG
An exception occurred during message parsing! Please report this.
#{ex.inspect_with_backtrace}
(pertaining to previous exception) Raised with packet:
#{packet}
LOG
rescue ex
LOGGER.error <<-LOG
A miscellaneous exception occurred during message handling.
#{ex.inspect_with_backtrace}
LOG
end
# Set the sequence to confirm that we have handled this packet, in case
# we need to resume
seq = packet.sequence
@session.try &.sequence = seq if seq
end
nil
end
# Injects a *packet* into the packet handler.
def inject(packet : Discord::WebSocket::Packet)
on_message(packet)
end
private def handle_hello(heartbeat_interval)
@heartbeat_interval = heartbeat_interval
@send_heartbeats = true
@last_heartbeat_acked = true
# If it seems like we can resume, we will - worst case we get an op9
if @session.try &.should_resume?
resume
else
identify
end
end
private def setup_heartbeats
spawn do
loop do
if @send_heartbeats
unless @last_heartbeat_acked
LOGGER.warn "Last heartbeat not acked, reconnecting"
# Give the new connection another chance by resetting the last
# acked flag; otherwise it would try to reconnect again at the
# first heartbeat
@last_heartbeat_acked = true
reconnect(should_suspend: true)
next
end
LOGGER.debug "Sending heartbeat"
begin
seq = @session.try &.sequence || 0
websocket.send({op: 1, d: seq}.to_json)
@last_heartbeat_acked = false
rescue ex
LOGGER.error <<-LOG
Heartbeat failed!
#{ex.inspect_with_backtrace}
LOG
end
end
sleep @heartbeat_interval.milliseconds
end
end
end
private def identify
if shard = @shard
shard_tuple = shard.values
end
packet = Gateway::IdentifyPacket.new(@token, @properties, @compress, @large_threshold, shard_tuple)
websocket.send(packet.to_json)
end
# Sends a resume packet from the given *sequence* number, or alternatively
# the current session's last received sequence if none is given. This will
# make Discord replay all events since that sequence.
def resume(sequence : Int64? = nil)
session = @session.not_nil!
sequence ||= session.sequence
packet = Gateway::ResumePacket.new(@token, session.session_id, sequence)
websocket.send(packet.to_json)
end
# Reconnects the websocket connection entirely. If *should_suspend* is set,
# the session will be suspended, which means (unless other factors prevent
# this) that the session will be resumed after reconnection. If
# *backoff_override* is set to anything other than `nil`, the reconnection
# backoff will not use the standard formula and instead wait the value
# provided; use `0.0` to skip waiting entirely.
def reconnect(should_suspend = false, backoff_override = nil)
@backoff = backoff_override if backoff_override
@send_heartbeats = false
websocket.close
# Suspend the session so we resume, if desired
@session.try &.suspend if should_suspend
end
# Sends a status update to Discord. The *status* can be `"online"`,
# `"idle"`, `"dnd"`, or `"invisible"`. Setting the *game* to a `GamePlaying`
# object makes the bot appear as playing some game on Discord. *since* and
# *afk* can be used in conjunction to signify to Discord that the status
# change is due to inactivity on the bot's part this fulfills no cosmetic
# purpose.
def status_update(status : String? = nil, game : GamePlaying? = nil, afk : Bool = false, since : Int64? = nil)
packet = Gateway::StatusUpdatePacket.new(status, game, afk, since)
websocket.send(packet.to_json)
end
# Sends a voice state update to Discord. This will create a new voice
# connection on the given *guild_id* and *channel_id*, update an existing
# one with new *self_mute* and *self_deaf* status, or disconnect from voice
# if the *channel_id* is `nil`.
#
# discordcr doesn't support sending or receiving any data from voice
# connections yet - this will have to be done externally until that happens.
def voice_state_update(guild_id : UInt64, channel_id : UInt64?, self_mute : Bool, self_deaf : Bool)
packet = Gateway::VoiceStateUpdatePacket.new(guild_id, channel_id, self_mute, self_deaf)
websocket.send(packet.to_json)
end
# Requests a full list of members to be sent for a specific guild. This is
# necessary to get the entire members list for guilds considered large (what
# is considered large can be changed using the large_threshold parameter
# in `#initialize`).
#
# The list will arrive in the form of GUILD_MEMBERS_CHUNK dispatch events,
# which can be listened to using `#on_guild_members_chunk`. If a cache
# is set up, arriving members will be cached automatically.
def request_guild_members(guild_id : UInt64, query : String = "", limit : Int32 = 0)
packet = Gateway::RequestGuildMembersPacket.new(guild_id, query, limit)
websocket.send(packet.to_json)
end
# :nodoc:
macro call_event(name, payload)
@on_{{name}}_handlers.try &.each do |handler|
begin
handler.call({{payload}})
rescue ex
LOGGER.error <<-LOG
An exception occurred in a user-defined event handler!
#{ex.inspect_with_backtrace}
LOG
end
end
end
# :nodoc:
macro cache(object)
@cache.try &.cache {{object}}
end
private def handle_dispatch(type, data)
call_event dispatch, {type, data}
case type
when "READY"
payload = Gateway::ReadyPayload.from_json(data)
@session = Gateway::Session.new(payload.session_id)
# Reset the backoff, because READY means we successfully achieved a
# connection and don't have to wait next time
@backoff = 1.0
@cache.try &.cache_current_user(payload.user)
payload.private_channels.each do |channel|
cache Channel.new(channel)
if channel.type == 1 # DM channel, not group
recipient_id = channel.recipients[0].id
@cache.try &.cache_dm_channel(channel.id, recipient_id)
end
end
LOGGER.info "Received READY, v: #{payload.v}"
call_event ready, payload
when "RESUMED"
# RESUMED also means a connection was achieved, so reset the
# reconnection backoff here too
@backoff = 1.0
payload = Gateway::ResumedPayload.from_json(data)
call_event resumed, payload
when "CHANNEL_CREATE"
payload = Channel.from_json(data)
cache payload
guild_id = payload.guild_id
recipients = payload.recipients
if guild_id
@cache.try &.add_guild_channel(guild_id, payload.id)
elsif payload.type == 1 && recipients
@cache.try &.cache_dm_channel(payload.id, recipients[0].id)
end
call_event channel_create, payload
when "CHANNEL_UPDATE"
payload = Channel.from_json(data)
cache payload
call_event channel_update, payload
when "CHANNEL_DELETE"
payload = Channel.from_json(data)
@cache.try &.delete_channel(payload.id)
guild_id = payload.guild_id
@cache.try &.remove_guild_channel(guild_id, payload.id) if guild_id
call_event channel_delete, payload
when "CHANNEL_PINS_UPDATE"
payload = Gateway::ChannelPinsUpdatePayload.from_json(data)
call_event channel_pins_update, payload
when "GUILD_CREATE"
payload = Gateway::GuildCreatePayload.from_json(data)
guild = Guild.new(payload)
cache guild
payload.channels.each do |channel|
channel.guild_id = guild.id
cache channel
@cache.try &.add_guild_channel(guild.id, channel.id)
end
payload.roles.each do |role|
cache role
@cache.try &.add_guild_role(guild.id, role.id)
end
payload.members.each do |member|
cache member.user
@cache.try &.cache(member, guild.id)
end
call_event guild_create, payload
when "GUILD_UPDATE"
payload = Guild.from_json(data)
cache payload
call_event guild_update, payload
when "GUILD_DELETE"
payload = Gateway::GuildDeletePayload.from_json(data)
@cache.try &.delete_guild(payload.id)
call_event guild_delete, payload
when "GUILD_BAN_ADD"
payload = Gateway::GuildBanPayload.from_json(data)
call_event guild_ban_add, payload
when "GUILD_BAN_REMOVE"
payload = Gateway::GuildBanPayload.from_json(data)
call_event guild_ban_remove, payload
when "GUILD_EMOJIS_UPDATE"
payload = Gateway::GuildEmojiUpdatePayload.from_json(data)
call_event guild_emoji_update, payload
when "GUILD_INTEGRATIONS_UPDATE"
payload = Gateway::GuildIntegrationsUpdatePayload.from_json(data)
call_event guild_integrations_update, payload
when "GUILD_MEMBER_ADD"
payload = Gateway::GuildMemberAddPayload.from_json(data)
cache payload.user
member = GuildMember.new(payload)
@cache.try &.cache(member, payload.guild_id)
call_event guild_member_add, payload
when "GUILD_MEMBER_UPDATE"
payload = Gateway::GuildMemberUpdatePayload.from_json(data)
cache payload.user
@cache.try do |c|
member = c.resolve_member(payload.guild_id, payload.user.id)
new_member = GuildMember.new(member, payload.roles, payload.nick)
c.cache(new_member, payload.guild_id)
end
call_event guild_member_update, payload
when "GUILD_MEMBER_REMOVE"
payload = Gateway::GuildMemberRemovePayload.from_json(data)
cache payload.user
@cache.try &.delete_member(payload.guild_id, payload.user.id)
call_event guild_member_remove, payload
when "GUILD_MEMBERS_CHUNK"
payload = Gateway::GuildMembersChunkPayload.from_json(data)
@cache.try &.cache_multiple_members(payload.members, payload.guild_id)
call_event guild_members_chunk, payload
when "GUILD_ROLE_CREATE"
payload = Gateway::GuildRolePayload.from_json(data)
cache payload.role
@cache.try &.add_guild_role(payload.guild_id, payload.role.id)
call_event guild_role_create, payload
when "GUILD_ROLE_UPDATE"
payload = Gateway::GuildRolePayload.from_json(data)
cache payload.role
call_event guild_role_update, payload
when "GUILD_ROLE_DELETE"
payload = Gateway::GuildRoleDeletePayload.from_json(data)
@cache.try &.delete_role(payload.role_id)
@cache.try &.remove_guild_role(payload.guild_id, payload.role_id)
call_event guild_role_delete, payload
when "MESSAGE_CREATE"
payload = Message.from_json(data)
LOGGER.debug "Received message with content #{payload.content}"
call_event message_create, payload
when "MESSAGE_REACTION_ADD"
payload = Gateway::MessageReactionPayload.from_json(data)
call_event message_reaction_add, payload
when "MESSAGE_REACTION_REMOVE"
payload = Gateway::MessageReactionPayload.from_json(data)
call_event message_reaction_remove, payload
when "MESSAGE_REACTION_REMOVE_ALL"
payload = Gateway::MessageReactionRemoveAllPayload.from_json(data)
call_event message_reaction_remove_all, payload
when "MESSAGE_UPDATE"
payload = Gateway::MessageUpdatePayload.from_json(data)
call_event message_update, payload
when "MESSAGE_DELETE"
payload = Gateway::MessageDeletePayload.from_json(data)
call_event message_delete, payload
when "MESSAGE_DELETE_BULK"
payload = Gateway::MessageDeleteBulkPayload.from_json(data)
call_event message_delete_bulk, payload
when "PRESENCE_UPDATE"
payload = Gateway::PresenceUpdatePayload.from_json(data)
if payload.user.full?
member = GuildMember.new(payload)
@cache.try &.cache(member, payload.guild_id)
end
call_event presence_update, payload
when "TYPING_START"
payload = Gateway::TypingStartPayload.from_json(data)
call_event typing_start, payload
when "USER_UPDATE"
payload = User.from_json(data)
call_event user_update, payload
when "VOICE_STATE_UPDATE"
payload = VoiceState.from_json(data)
call_event voice_state_update, payload
when "VOICE_SERVER_UPDATE"
payload = Gateway::VoiceServerUpdatePayload.from_json(data)
call_event voice_server_update, payload
when "WEBHOOKS_UPDATE"
payload = Gateway::WebhooksUpdatePayload.from_json(data)
call_event webhooks_update, payload
else
LOGGER.warn "Unsupported dispatch: #{type} #{data}"
end
end
private def handle_reconnect
# We want the reconnection to happen instantly, and we want a resume to be
# attempted, so set the respective parameters
reconnect(should_suspend: true, backoff_override: 0.0)
end
private def handle_invalid_session
@session.try &.invalidate
identify
end
private def handle_heartbeat_ack
LOGGER.debug "Heartbeat ACK received"
@last_heartbeat_acked = true
end
# :nodoc:
macro event(name, payload_type)
def on_{{name}}(&handler : {{payload_type}} ->)
(@on_{{name}}_handlers ||= [] of {{payload_type}} ->) << handler
end
end
# Called when the bot receives any kind of dispatch at all, even one that
# is otherwise unsupported. This can be useful for statistics, e. g. how
# many gateway events are received per second. It can also be useful to
# handle new API changes not yet supported by the lib.
#
# The parameter passed to the event will be a tuple of `{type, data}`, where
# `type` is the event type (e.g. "MESSAGE_CREATE") and `data` is the
# unprocessed JSON event data.
event dispatch, {String, IO::Memory}
# Called when the bot has successfully initiated a session with Discord. It
# marks the point when gateway packets can be set (e. g. `#status_update`).
#
# Note that this event may be called multiple times over the course of a
# bot lifetime, as it is also called when the client reconnects with a new
# session.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#ready)
event ready, Gateway::ReadyPayload
# Called when the client has successfully resumed an existing connection
# after reconnecting.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#resumed)
event resumed, Gateway::ResumedPayload
# Called when a channel has been created on a server the bot has access to,
# or when somebody has started a DM channel with the bot.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#channel-create)
event channel_create, Channel
# Called when a channel's properties are updated, like the name or
# permission overwrites.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#channel-update)
event channel_update, Channel
# Called when a channel the bot has access to is deleted. This is not called
# for other users closing the DM channel with the bot, only for the bot
# closing the DM channel with a user.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#channel-delete)
event channel_delete, Channel
# Called when a channel's pinned messages are updated, where a pin was
# either added or removed.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#channel-pins-update)
event channel_pins_update, Gateway::ChannelPinsUpdatePayload
# Called when the bot is added to a guild, a guild unavailable due to an
# outage becomes available again, or the guild is streamed after READY.
# To verify that it is the first case, you can check the `unavailable`
# property in `Gateway::GuildCreatePayload`.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-create)
event guild_create, Gateway::GuildCreatePayload
# Called when a guild's properties, like name or verification level, are
# updated.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-update)
event guild_update, Guild
# Called when the bot leaves a guild or a guild becomes unavailable due to
# an outage. To verify that it is the former case, you can check the
# `unavailable` property.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-delete)
event guild_delete, Gateway::GuildDeletePayload
# Called when somebody is banned from a guild. A `#on_guild_member_remove`
# event is also called.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-ban-add)
event guild_ban_add, Gateway::GuildBanPayload
# Called when somebody is unbanned from a guild.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-ban-remove)
event guild_ban_remove, Gateway::GuildBanPayload
# Called when a guild's emoji are updated.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-emoji-update)
event guild_emoji_update, Gateway::GuildEmojiUpdatePayload
# Called when a guild's integrations (Twitch, YouTube) are updated.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-integrations-update)
event guild_integrations_update, Gateway::GuildIntegrationsUpdatePayload
# Called when somebody other than the bot joins a guild.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-member-add)
event guild_member_add, Gateway::GuildMemberAddPayload
# Called when a member object is updated. This happens when somebody
# changes their nickname or has their roles changed.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-member-update)
event guild_member_update, Gateway::GuildMemberUpdatePayload
# Called when somebody other than the bot leaves a guild.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-member-remove)
event guild_member_remove, Gateway::GuildMemberRemovePayload
# Called when Discord sends a chunk of member objects after a
# `#request_guild_members` call. If a `Cache` is set up, this is handled
# automatically.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-members-chunk)
event guild_members_chunk, Gateway::GuildMembersChunkPayload
# Called when a role is created on a guild.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-role-create)
event guild_role_create, Gateway::GuildRolePayload
# Called when a role's properties are updated, for example name or colour.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-role-update)
event guild_role_update, Gateway::GuildRolePayload
# Called when a role is deleted.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#guild-role-delete)
event guild_role_delete, Gateway::GuildRoleDeletePayload
# Called when a message is sent to a channel the bot has access to. This
# may be any sort of text channel, no matter private or guild.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#message-create)
event message_create, Message
# Called when a reaction is added to a message.
event message_reaction_add, Gateway::MessageReactionPayload
# Called when a reaction is removed from a message.
event message_reaction_remove, Gateway::MessageReactionPayload
# Called when all reactions are removed at once from a message.
event message_reaction_remove_all, Gateway::MessageReactionRemoveAllPayload
# Called when a message is updated. Most commonly this is done for edited
# messages, but the event is also sent when embed information for an
# existing message is updated.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#message-update)
event message_update, Gateway::MessageUpdatePayload
# Called when a single message is deleted.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#message-delete)
event message_delete, Gateway::MessageDeletePayload
# Called when multiple messages are deleted at once, due to a bot using the
# bulk_delete endpoint.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#message-delete-bulk)
event message_delete_bulk, Gateway::MessageDeleteBulkPayload
# Called when a user updates their status (online/idle/offline), the game
# they are playing, or their streaming status. Also called when a user's
# properties (user/avatar/discriminator) are changed.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#presence-update)
event presence_update, Gateway::PresenceUpdatePayload
# Called when somebody starts typing in a channel the bot has access to.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#typing-start)
event typing_start, Gateway::TypingStartPayload
# Called when the user properties of the bot itself are changed.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#user-update)
event user_update, User
# Called when somebody joins or leaves a voice channel, moves to a different
# one, or is muted/unmuted/deafened/undeafened.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#voice-state-update)
event voice_state_update, VoiceState
# Called when a guild's voice server changes. This event is called with
# the current voice server when initially connecting to voice, and it is
# called again with the new voice server when the current server fails over
# to a new one, or when the guild's voice region changes.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#voice-server-update)
event voice_server_update, Gateway::VoiceServerUpdatePayload
# Sent when a guild channel's webhook is created, updated, or deleted.
#
# [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#webhooks-update)
event webhooks_update, Gateway::WebhooksUpdatePayload
end
module Gateway
alias ShardKey = {shard_id: Int32, num_shards: Int32}
class Session
getter session_id
property sequence
def initialize(@session_id : String)
@sequence = 0_i64
@suspended = false
@invalid = false
end
def suspend
@suspended = true
end
def suspended? : Bool
@suspended
end
def invalidate
@invalid = true
end
def invalid? : Bool
@invalid
end
def should_resume? : Bool
suspended? && !invalid?
end
end
end
end

View File

@@ -0,0 +1,150 @@
require "json"
module Discord
# Parser for the DCA file format, a simple wrapper around Opus made
# specifically for Discord bots.
class DCAParser
# Magic string that identifies a DCA1 file
DCA1_MAGIC = "DCA1"
# The parsed metadata, or nil if it could not be parsed.
getter metadata : DCA1Mappings::Metadata?
# Create a new parser. It will read from the given *io*. If *raw* is set,
# the file is assumed to be a DCA0 file, without any metadata. If the file's
# metadata doesn't conform to the DCA1 specification and *strict_metadata*
# is set, then the parsing will fail with an error; if it is not set then
# the metadata will silently be `nil`.
def initialize(@io : IO, raw = false, @strict_metadata = true)
unless raw
verify_magic
parse_metadata
end
end
# Reads the next frame from the IO. If there is nothing left to read, it
# will return `nil`.
#
# If *reuse_buffer* is true, a large buffer will be allocated once and
# reused for future calls of this method, reducing the load on the GC and
# potentially reusing memory use overall; if it is false, a new buffer of
# just the correct size will be allocated every time. Note that if the
# buffer is reused, the returned data is only valid until the next call to
# `next_frame`.
def next_frame(reuse_buffer = false) : Bytes?
begin
header = @io.read_bytes(Int16, IO::ByteFormat::LittleEndian)
raise "Negative frame header (#{header} < 0)" if header < 0
buf = if reuse_buffer
full_buf = @reused_buffer ||= Bytes.new(Int16::MAX)
full_buf[0, header]
else
Bytes.new(header)
end
@io.read_fully(buf)
buf
rescue IO::EOFError
nil
end
end
# Continually reads frames from the IO until there are none left. Each frame
# is passed to the given *block*.
def parse(&block : Bytes ->)
loop do
buf = next_frame
if buf
block.call(buf)
else
break
end
end
end
private def verify_magic
magic = @io.read_string(4)
if magic != DCA1_MAGIC
raise "File is not a DCA1 file (magic is #{magic}, should be DCA1)"
end
end
private def parse_metadata
# The header of the metadata part is the four-byte size of the following
# metadata payload.
metadata_size = @io.read_bytes(Int32, IO::ByteFormat::LittleEndian)
metadata_io = IO::Sized.new(@io, read_size: metadata_size)
begin
@metadata = DCA1Mappings::Metadata.from_json(metadata_io)
rescue e : JSON::ParseException
raise e if @strict_metadata
end
metadata_io.skip_to_end
end
end
# Mappings for DCA1 metadata
module DCA1Mappings
struct Metadata
JSON.mapping(
dca: DCA,
opus: Opus,
info: Info?,
origin: Origin?,
extra: JSON::Any
)
end
struct DCA
JSON.mapping(
version: Int32,
tool: Tool
)
end
struct Tool
JSON.mapping(
name: String,
version: String,
url: String?,
author: String?
)
end
struct Opus
JSON.mapping(
mode: String,
sample_rate: Int32,
frame_size: Int32,
abr: Int32?,
vbr: Bool,
channels: Int32
)
end
struct Info
JSON.mapping(
title: String?,
artist: String?,
album: String?,
genre: String?,
comments: String?,
cover: String?
)
end
struct Origin
JSON.mapping(
source: String?,
abr: Int32?,
channels: Int32?,
encoding: String?,
url: String?
)
end
end
end

View File

@@ -0,0 +1,71 @@
require "http/client/response"
require "json"
module Discord
# This exception is raised in `REST#request` when a request fails in general,
# without returning a special error response.
class StatusException < Exception
getter response : HTTP::Client::Response
def initialize(@response : HTTP::Client::Response)
end
# The status code of the response that caused this exception, for example
# 500 or 418.
def status_code : Int32
@response.status_code
end
# The status message of the response that caused this exception, for example
# "Internal Server Error" or "I'm A Teapot".
def status_message : String
@response.status_message
end
def message
"#{@response.status_code} #{@response.status_message}"
end
def to_s(io)
io << @response.status_code << " " << @response.status_message
end
end
# An API error response.
struct APIError
JSON.mapping(
code: Int32,
message: String
)
end
# This exception is raised in `REST#request` when a request fails with an
# API error response that has a code and a descriptive message.
class CodeException < StatusException
getter error : APIError
def initialize(@response : HTTP::Client::Response, @error : APIError)
end
# The API error code that was returned by Discord, for example 20001 or
# 50016.
def error_code : Int32
@error.code
end
# The API error message that was returned by Discord, for example "Bots
# cannot use this endpoint" or "Provided too few or too many messages to
# delete. Must provide at least 2 and fewer than 100 messages to delete.".
def error_message : String
@error.message
end
def message
"#{@response.status_code} #{@response.status_message}: Code #{@error.code} - #{@error.message}"
end
def to_s(io)
io << @response.status_code << " " << @response.status_message << ": Code " << @error.code << " - " << @error.message
end
end
end

View File

@@ -0,0 +1,12 @@
require "logger"
# The logger class is monkey patched to have a property for the IO.
class Logger
property io
end
module Discord
# The built in logger.
LOGGER = Logger.new(STDOUT)
LOGGER.progname = "discordcr"
end

View File

@@ -0,0 +1,234 @@
require "./converters"
module Discord
enum MessageType : UInt8
Default = 0
RecipientAdd = 1
RecipientRemove = 2
Call = 3
ChannelNameChange = 4
ChannelIconChange = 5
ChannelPinnedMessage = 6
GuildMemberJoin = 7
end
struct Message
JSON.mapping(
type: {type: MessageType, converter: MessageTypeConverter},
content: String,
id: {type: UInt64, converter: SnowflakeConverter},
channel_id: {type: UInt64, converter: SnowflakeConverter},
author: User,
timestamp: {type: Time, converter: TimestampConverter},
tts: Bool,
mention_everyone: Bool,
mentions: Array(User),
mention_roles: {type: Array(UInt64), converter: SnowflakeArrayConverter},
attachments: Array(Attachment),
embeds: Array(Embed),
pinned: Bool?,
reactions: Array(Reaction)?,
activity: Activity?
)
end
enum ActivityType : UInt8
Join = 1
Spectate = 2
Listen = 3
JoinRequest = 5
end
struct Activity
JSON.mapping(
type: ActivityType,
party_id: String?
)
end
enum ChannelType : UInt8
GuildText = 0
DM = 1
Voice = 2
GroupDM = 3
end
struct Channel
# :nodoc:
def initialize(private_channel : PrivateChannel)
@id = private_channel.id
@type = private_channel.type
@recipients = private_channel.recipients
@last_message_id = private_channel.last_message_id
end
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
type: {type: ChannelType, converter: ChannelTypeConverter},
guild_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
name: String?,
permission_overwrites: Array(Overwrite)?,
topic: String?,
last_message_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
bitrate: UInt32?,
user_limit: UInt32?,
recipients: Array(User)?,
nsfw: Bool?,
icon: Bool?,
owner_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
application_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
position: Int32?,
parent_id: {type: UInt64?, converter: MaybeSnowflakeConverter}
)
end
struct PrivateChannel
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
type: {type: ChannelType, converter: ChannelTypeConverter},
recipients: Array(User),
last_message_id: {type: UInt64?, converter: MaybeSnowflakeConverter}
)
end
struct Overwrite
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
type: String,
allow: Permissions,
deny: Permissions
)
end
struct Reaction
JSON.mapping(
emoji: ReactionEmoji,
count: UInt32,
me: Bool
)
end
struct ReactionEmoji
JSON.mapping(
id: {type: UInt64?, converter: MaybeSnowflakeConverter},
name: String
)
end
struct Embed
def initialize(@title : String? = nil, @type : String = "rich",
@description : String? = nil, @url : String? = nil,
@timestamp : Time? = nil, @colour : UInt32? = nil,
@footer : EmbedFooter? = nil, @image : EmbedImage? = nil,
@thumbnail : EmbedThumbnail? = nil, @author : EmbedAuthor? = nil,
@fields : Array(EmbedField)? = nil)
end
JSON.mapping(
title: String?,
type: String,
description: String?,
url: String?,
timestamp: {type: Time?, converter: TimestampConverter},
colour: {type: UInt32?, key: "color"},
footer: EmbedFooter?,
image: EmbedImage?,
thumbnail: EmbedThumbnail?,
video: EmbedVideo?,
provider: EmbedProvider?,
author: EmbedAuthor?,
fields: Array(EmbedField)?
)
{% unless flag?(:correct_english) %}
def color
colour
end
{% end %}
end
struct EmbedThumbnail
def initialize(@url : String)
end
JSON.mapping(
url: String,
proxy_url: String?,
height: UInt32?,
width: UInt32?
)
end
struct EmbedVideo
JSON.mapping(
url: String,
height: UInt32,
width: UInt32
)
end
struct EmbedImage
def initialize(@url : String)
end
JSON.mapping(
url: String,
proxy_url: String?,
height: UInt32?,
width: UInt32?
)
end
struct EmbedProvider
JSON.mapping(
name: String,
url: String?
)
end
struct EmbedAuthor
def initialize(@name : String? = nil, @url : String? = nil, @icon_url : String? = nil)
end
JSON.mapping(
name: String?,
url: String?,
icon_url: String?,
proxy_icon_url: String?
)
end
struct EmbedFooter
def initialize(@text : String? = nil, @icon_url : String? = nil)
end
JSON.mapping(
text: String?,
icon_url: String?,
proxy_icon_url: String?
)
end
struct EmbedField
def initialize(@name : String, @value : String, @inline : Bool = false)
end
JSON.mapping(
name: String,
value: String,
inline: Bool
)
end
struct Attachment
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
filename: String,
size: UInt32,
url: String,
proxy_url: String,
height: UInt32?,
width: UInt32?
)
end
end

View File

@@ -0,0 +1,90 @@
require "json"
require "time/format"
module Discord
# :nodoc:
module TimestampConverter
def self.from_json(parser : JSON::PullParser)
time_str = parser.read_string
begin
Time::Format.new("%FT%T.%6N%:z").parse(time_str)
rescue Time::Format::Error
Time::Format.new("%FT%T%:z").parse(time_str)
end
end
def self.to_json(value : Time, builder : JSON::Builder)
Time::Format.new("%FT%T.%6N%:z").to_json(value, builder)
end
end
# :nodoc:
module SnowflakeConverter
def self.from_json(parser : JSON::PullParser) : UInt64
parser.read_string.to_u64
end
def self.to_json(value : UInt64, builder : JSON::Builder)
builder.scalar(value.to_s)
end
end
# :nodoc:
module MaybeSnowflakeConverter
def self.from_json(parser : JSON::PullParser) : UInt64?
str = parser.read_string_or_null
if str
str.to_u64
else
nil
end
end
def self.to_json(value : UInt64?, builder : JSON::Builder)
if value
builder.scalar(value.to_s)
else
builder.null
end
end
end
# :nodoc:
module SnowflakeArrayConverter
def self.from_json(parser : JSON::PullParser) : Array(UInt64)
Array(String).new(parser).map &.to_u64
end
def self.to_json(value : Array(UInt64), builder : JSON::Builder)
value.map(&.to_s).to_json(builder)
end
end
# :nodoc:
module MessageTypeConverter
def self.from_json(parser : JSON::PullParser)
if value = parser.read?(UInt8)
MessageType.new(value)
else
raise "Unexpected message type value: #{parser.read_raw}"
end
end
def self.to_json(value : MessageType, builder : JSON::Builder)
value.to_json(builder)
end
end
# :nodoc:
module ChannelTypeConverter
def self.from_json(parser : JSON::PullParser)
if value = parser.read?(UInt8)
ChannelType.new(value)
else
raise "Unexpected channel type value: #{parser.read_raw}"
end
end
end
end

View 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

View File

@@ -0,0 +1,374 @@
require "./converters"
require "./user"
require "./channel"
require "./guild"
module Discord
module Gateway
struct ReadyPayload
JSON.mapping(
v: UInt8,
user: User,
private_channels: Array(PrivateChannel),
guilds: Array(UnavailableGuild),
session_id: String
)
end
struct ResumedPayload
JSON.mapping(
_trace: Array(String)
)
end
struct IdentifyPacket
def initialize(token, properties, large_threshold, compress, shard)
@op = Discord::Client::OP_IDENTIFY
@d = IdentifyPayload.new(token, properties, large_threshold, compress, shard)
end
JSON.mapping(
op: Int32,
d: IdentifyPayload
)
end
struct IdentifyPayload
def initialize(@token, @properties, @compress, @large_threshold, @shard)
end
JSON.mapping({
token: String,
properties: IdentifyProperties,
compress: Bool,
large_threshold: Int32,
shard: Tuple(Int32, Int32)?,
})
end
struct IdentifyProperties
def initialize(@os, @browser, @device, @referrer, @referring_domain)
end
JSON.mapping(
os: {key: "$os", type: String},
browser: {key: "$browser", type: String},
device: {key: "$device", type: String},
referrer: {key: "$referrer", type: String},
referring_domain: {key: "$referring_domain", type: String}
)
end
struct ResumePacket
def initialize(token, session_id, seq)
@op = Discord::Client::OP_RESUME
@d = ResumePayload.new(token, session_id, seq)
end
JSON.mapping(
op: Int32,
d: ResumePayload
)
end
# :nodoc:
struct ResumePayload
def initialize(@token, @session_id, @seq)
end
JSON.mapping(
token: String,
session_id: String,
seq: Int64
)
end
struct StatusUpdatePacket
def initialize(status, game, afk, since)
@op = Discord::Client::OP_STATUS_UPDATE
@d = StatusUpdatePayload.new(status, game, afk, since)
end
JSON.mapping(
op: Int32,
d: StatusUpdatePayload
)
end
# :nodoc:
struct StatusUpdatePayload
def initialize(@status, @game, @afk, @since)
end
JSON.mapping(
status: {type: String?, emit_null: true},
game: {type: GamePlaying?, emit_null: true},
afk: Bool,
since: {type: Int64, nilable: true, emit_null: true}
)
end
struct VoiceStateUpdatePacket
def initialize(guild_id, channel_id, self_mute, self_deaf)
@op = Discord::Client::OP_VOICE_STATE_UPDATE
@d = VoiceStateUpdatePayload.new(guild_id, channel_id, self_mute, self_deaf)
end
JSON.mapping(
op: Int32,
d: VoiceStateUpdatePayload
)
end
# :nodoc:
struct VoiceStateUpdatePayload
def initialize(@guild_id, @channel_id, @self_mute, @self_deaf)
end
JSON.mapping(
guild_id: UInt64,
channel_id: {type: UInt64?, emit_null: true},
self_mute: Bool,
self_deaf: Bool
)
end
struct RequestGuildMembersPacket
def initialize(guild_id, query, limit)
@op = Discord::Client::OP_REQUEST_GUILD_MEMBERS
@d = RequestGuildMembersPayload.new(guild_id, query, limit)
end
JSON.mapping(
op: Int32,
d: RequestGuildMembersPayload
)
end
# :nodoc:
struct RequestGuildMembersPayload
def initialize(@guild_id, @query, @limit)
end
JSON.mapping(
guild_id: UInt64,
query: String,
limit: Int32
)
end
struct HelloPayload
JSON.mapping(
heartbeat_interval: UInt32,
_trace: Array(String)
)
end
# This one is special from simply Guild since it also has fields for members
# and presences.
struct GuildCreatePayload
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
name: String,
icon: String?,
splash: String?,
owner_id: {type: UInt64, converter: SnowflakeConverter},
region: String,
afk_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
afk_timeout: Int32?,
verification_level: UInt8,
roles: Array(Role),
emoji: {type: Array(Emoji), key: "emojis"},
features: Array(String),
large: Bool,
voice_states: Array(VoiceState),
unavailable: Bool?,
member_count: Int32,
members: Array(GuildMember),
channels: Array(Channel),
presences: Array(Presence),
widget_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
default_message_notifications: UInt8,
explicit_content_filter: UInt8,
system_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter}
)
{% unless flag?(:correct_english) %}
def emojis
emoji
end
{% end %}
end
struct GuildDeletePayload
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
unavailable: Bool?
)
end
struct GuildBanPayload
JSON.mapping(
user: User,
guild_id: {type: UInt64, converter: SnowflakeConverter}
)
end
struct GuildEmojiUpdatePayload
JSON.mapping(
guild_id: {type: UInt64, converter: SnowflakeConverter},
emoji: {type: Array(Emoji), key: "emojis"}
)
{% unless flag?(:correct_english) %}
def emojis
emoji
end
{% end %}
end
struct GuildIntegrationsUpdatePayload
JSON.mapping(
guild_id: {type: UInt64, converter: SnowflakeConverter}
)
end
struct GuildMemberAddPayload
JSON.mapping(
user: User,
nick: String?,
roles: {type: Array(UInt64), converter: SnowflakeArrayConverter},
joined_at: {type: Time?, converter: TimestampConverter},
deaf: Bool,
mute: Bool,
guild_id: {type: UInt64, converter: SnowflakeConverter}
)
end
struct GuildMemberUpdatePayload
JSON.mapping(
user: User,
roles: {type: Array(UInt64), converter: SnowflakeArrayConverter},
nick: {type: String, nilable: true},
guild_id: {type: UInt64, converter: SnowflakeConverter}
)
end
struct GuildMemberRemovePayload
JSON.mapping(
user: User,
guild_id: {type: UInt64, converter: SnowflakeConverter}
)
end
struct GuildMembersChunkPayload
JSON.mapping(
guild_id: {type: UInt64, converter: SnowflakeConverter},
members: Array(GuildMember)
)
end
struct GuildRolePayload
JSON.mapping(
guild_id: {type: UInt64, converter: SnowflakeConverter},
role: Role
)
end
struct GuildRoleDeletePayload
JSON.mapping(
guild_id: {type: UInt64, converter: SnowflakeConverter},
role_id: {type: UInt64, converter: SnowflakeConverter}
)
end
struct MessageReactionPayload
JSON.mapping(
user_id: {type: UInt64, converter: SnowflakeConverter},
channel_id: {type: UInt64, converter: SnowflakeConverter},
message_id: {type: UInt64, converter: SnowflakeConverter},
emoji: ReactionEmoji
)
end
struct MessageReactionRemoveAllPayload
JSON.mapping(
channel_id: {type: UInt64, converter: SnowflakeConverter},
message_id: {type: UInt64, converter: SnowflakeConverter}
)
end
struct MessageUpdatePayload
JSON.mapping(
type: UInt8?,
content: String?,
id: {type: UInt64, converter: SnowflakeConverter},
channel_id: {type: UInt64, converter: SnowflakeConverter},
author: User?,
timestamp: {type: Time?, converter: TimestampConverter},
tts: Bool?,
mention_everyone: Bool?,
mentions: Array(User)?,
mention_roles: {type: Array(UInt64)?, converter: SnowflakeArrayConverter},
attachments: Array(Attachment)?,
embeds: Array(Embed)?,
pinned: Bool?
)
end
struct MessageDeletePayload
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
channel_id: {type: UInt64, converter: SnowflakeConverter}
)
end
struct MessageDeleteBulkPayload
JSON.mapping(
ids: {type: Array(UInt64), converter: SnowflakeArrayConverter},
channel_id: {type: UInt64, converter: SnowflakeConverter}
)
end
struct PresenceUpdatePayload
JSON.mapping(
user: PartialUser,
roles: {type: Array(UInt64), converter: SnowflakeArrayConverter},
game: GamePlaying?,
nick: String?,
guild_id: {type: UInt64, converter: SnowflakeConverter},
status: String
)
end
struct TypingStartPayload
JSON.mapping(
channel_id: {type: UInt64, converter: SnowflakeConverter},
user_id: {type: UInt64, converter: SnowflakeConverter},
timestamp: {type: Time, converter: Time::EpochConverter}
)
end
struct VoiceServerUpdatePayload
JSON.mapping(
token: String,
guild_id: {type: UInt64, converter: SnowflakeConverter},
endpoint: String
)
end
struct WebhooksUpdatePayload
JSON.mapping(
guild_id: {type: UInt64, converter: SnowflakeConverter},
channel_id: {type: UInt64, converter: SnowflakeConverter}
)
end
struct ChannelPinsUpdatePayload
JSON.mapping(
last_pin_timestamp: {type: Time, converter: TimestampConverter},
channel_id: {type: UInt64, converter: SnowflakeConverter}
)
end
end
end

View File

@@ -0,0 +1,188 @@
require "./converters"
require "./voice"
module Discord
struct Guild
# :nodoc:
def initialize(payload : Gateway::GuildCreatePayload)
@id = payload.id
@name = payload.name
@icon = payload.icon
@splash = payload.splash
@owner_id = payload.owner_id
@region = payload.region
@afk_channel_id = payload.afk_channel_id
@afk_timeout = payload.afk_timeout
@verification_level = payload.verification_level
@roles = payload.roles
@emoji = payload.emoji
@features = payload.features
@widget_channel_id = payload.widget_channel_id
@default_message_notifications = payload.default_message_notifications
@explicit_content_filter = payload.explicit_content_filter
@system_channel_id = payload.system_channel_id
end
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
name: String,
icon: String?,
splash: String?,
owner_id: {type: UInt64, converter: SnowflakeConverter},
region: String,
afk_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
afk_timeout: Int32?,
embed_enabled: Bool?,
embed_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
verification_level: UInt8,
roles: Array(Role),
emoji: {type: Array(Emoji), key: "emojis"},
features: Array(String),
widget_enabled: {type: Bool, nilable: true},
widget_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
default_message_notifications: UInt8,
explicit_content_filter: UInt8,
system_channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter}
)
{% unless flag?(:correct_english) %}
def emojis
emoji
end
{% end %}
end
struct UnavailableGuild
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
unavailable: Bool
)
end
struct GuildEmbed
JSON.mapping(
enabled: Bool,
channel_id: {type: UInt64, converter: SnowflakeConverter}
)
end
struct GuildMember
# :nodoc:
def initialize(payload : Gateway::GuildMemberAddPayload | GuildMember, roles : Array(UInt64), nick : String?)
initialize(payload)
@nick = nick
@roles = roles
end
# :nodoc:
def initialize(payload : Gateway::GuildMemberAddPayload | GuildMember)
@user = payload.user
@nick = payload.nick
@roles = payload.roles
@joined_at = payload.joined_at
@deaf = payload.deaf
@mute = payload.mute
end
# :nodoc:
def initialize(payload : Gateway::PresenceUpdatePayload)
@user = User.new(payload.user)
@nick = payload.nick
@roles = payload.roles
# Presence updates have no joined_at or deaf/mute, thanks Discord
end
JSON.mapping(
user: User,
nick: String?,
roles: {type: Array(UInt64), converter: SnowflakeArrayConverter},
joined_at: {type: Time?, converter: TimestampConverter},
deaf: Bool?,
mute: Bool?
)
end
struct Integration
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
name: String,
type: String,
enabled: Bool,
syncing: Bool,
role_id: {type: UInt64, converter: SnowflakeConverter},
expire_behaviour: {type: UInt8, key: "expire_behavior"},
expire_grace_period: Int32,
user: User,
account: IntegrationAccount,
synced_at: {type: Time, converter: Time::EpochConverter}
)
{% unless flag?(:correct_english) %}
def expire_behavior
expire_behaviour
end
{% end %}
end
struct IntegrationAccount
JSON.mapping(
id: String,
name: String
)
end
struct Emoji
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
name: String,
roles: {type: Array(UInt64), converter: SnowflakeArrayConverter},
require_colons: Bool,
managed: Bool
)
end
struct Role
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
name: String,
permissions: Permissions,
colour: {type: UInt32, key: "color"},
hoist: Bool,
position: Int32,
managed: Bool,
mentionable: Bool
)
{% unless flag?(:correct_english) %}
def color
colour
end
{% end %}
end
struct GuildBan
JSON.mapping(
user: User,
reason: String?
)
end
struct GamePlaying
def initialize(@name = nil, @type = nil, @url = nil)
end
JSON.mapping(
name: String?,
type: Int64? | String?,
url: String?
)
end
struct Presence
JSON.mapping(
user: PartialUser,
game: GamePlaying?,
status: String
)
end
end

View File

@@ -0,0 +1,43 @@
require "./converters"
require "./user"
module Discord
struct Invite
JSON.mapping(
code: String,
guild: InviteGuild,
channel: InviteChannel
)
end
struct InviteMetadata
JSON.mapping(
code: String,
guild: InviteGuild,
channel: InviteChannel,
inviter: User,
users: UInt32,
max_uses: UInt32,
max_age: UInt32,
temporary: Bool,
created_at: {type: Time, converter: TimestampConverter},
revoked: Bool
)
end
struct InviteGuild
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
name: String,
splash_hash: String?
)
end
struct InviteChannel
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
name: String,
type: UInt8
)
end
end

View File

@@ -0,0 +1,20 @@
require "./converters"
require "./user"
module Discord
# An OAuth2 application, as registered with Discord, that can hold
# information about a `Client`'s associated bot user account and owner,
# among other OAuth2 properties.
struct OAuth2Application
JSON.mapping({
id: {type: UInt64, converter: SnowflakeConverter},
name: String,
icon: String?,
description: String?,
rpc_origins: Array(String)?,
bot_public: Bool,
bot_require_code_grant: Bool,
owner: User,
})
end
end

View File

@@ -0,0 +1,38 @@
module Discord
@[Flags]
enum Permissions : UInt64
CreateInstantInvite = 1
KickMembers = 1 << 1
BanMembers = 1 << 2
Administrator = 1 << 3
ManageChannels = 1 << 4
ManageGuild = 1 << 5
AddReactions = 1 << 6
ReadMessages = 1 << 10
SendMessages = 1 << 11
SendTTSMessages = 1 << 12
ManageMessages = 1 << 13
EmbedLinks = 1 << 14
AttachFiles = 1 << 15
ReadMessageHistory = 1 << 16
MentionEveryone = 1 << 17
UseExternalEmojis = 1 << 18
Connect = 1 << 20
Speak = 1 << 21
MuteMembers = 1 << 22
DeafenMembers = 1 << 23
MoveMembers = 1 << 24
UseVAD = 1 << 25
ChangeNickname = 1 << 26
ManageNicknames = 1 << 27
ManageRoles = 1 << 28
ManageWebhooks = 1 << 29
ManageEmojis = 1 << 30
def self.new(pull : JSON::PullParser)
# see https://github.com/crystal-lang/crystal/issues/3448
# #from_value errors
Permissions.new(pull.read_int.to_u64)
end
end
end

View File

@@ -0,0 +1,63 @@
require "./converters"
module Discord
module REST
# A response to the Get Gateway REST API call.
struct GatewayResponse
JSON.mapping(
url: String
)
end
# A response to the Get Gateway Bot REST API call.
struct GatewayBotResponse
JSON.mapping(
url: String,
shards: Int32
)
end
# A response to the Get Guild Prune Count REST API call.
struct PruneCountResponse
JSON.mapping(
pruned: UInt32
)
end
# A response to the Get Guild Vanity URL REST API call.
struct GuildVanityURLResponse
JSON.mapping(
code: String
)
end
# A request payload to rearrange channels in a `Guild` by a REST API call.
struct ModifyChannelPositionPayload
def initialize(@id : UInt64, @position : Int32,
@parent_id : UInt64 | ChannelParent = ChannelParent::Unchanged,
@lock_permissions : Bool? = nil)
end
def to_json(builder : JSON::Builder)
builder.object do
builder.field("id") do
SnowflakeConverter.to_json(@id, builder)
end
builder.field("position", @position)
case parent = @parent_id
when UInt64
SnowflakeConverter.to_json(parent, builder)
when ChannelParent::None
builder.field("parent_id", nil)
when ChannelParent::Unchanged
# no field
end
builder.field("lock_permissions", @lock_permissions) unless @lock_permissions.nil?
end
end
end
end
end

View File

@@ -0,0 +1,60 @@
require "./converters"
module Discord
struct User
# :nodoc:
def initialize(partial : PartialUser)
@username = partial.username.not_nil!
@id = partial.id
@discriminator = partial.discriminator.not_nil!
@avatar = partial.avatar
@email = partial.email
@bot = partial.bot
end
JSON.mapping(
username: String,
id: {type: UInt64, converter: SnowflakeConverter},
discriminator: String,
avatar: String?,
email: String?,
bot: Bool?,
mfa_enabled: Bool?,
verified: Bool?
)
end
struct PartialUser
JSON.mapping(
username: String?,
id: {type: UInt64, converter: SnowflakeConverter},
discriminator: String?,
avatar: String?,
email: String?,
bot: Bool?
)
def full? : Bool
!@username.nil? && !@discriminator.nil? && !@avatar.nil?
end
end
struct UserGuild
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
name: String,
icon: String?,
owner: Bool,
permissions: Permissions
)
end
struct Connection
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
name: String,
type: String,
revoked: Bool
)
end
end

View File

@@ -0,0 +1,29 @@
require "./converters"
module Discord
struct VoiceState
JSON.mapping(
guild_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
channel_id: {type: UInt64?, converter: MaybeSnowflakeConverter},
user_id: {type: UInt64, converter: SnowflakeConverter},
session_id: String,
deaf: Bool,
mute: Bool,
self_deaf: Bool,
self_mute: Bool,
suppress: Bool
)
end
struct VoiceRegion
JSON.mapping(
id: String,
name: String,
sample_hostname: String,
sample_port: UInt16,
custom: Bool?,
vip: Bool,
optimal: Bool
)
end
end

View File

@@ -0,0 +1,106 @@
require "./converters"
module Discord
# :nodoc:
module VWS
struct IdentifyPacket
def initialize(server_id, user_id, session_id, token)
@op = Discord::VoiceClient::OP_IDENTIFY
@d = IdentifyPayload.new(server_id, user_id, session_id, token)
end
JSON.mapping(
op: Int32,
d: IdentifyPayload
)
end
struct IdentifyPayload
def initialize(@server_id, @user_id, @session_id, @token)
end
JSON.mapping(
server_id: UInt64,
user_id: UInt64,
session_id: String,
token: String
)
end
struct SelectProtocolPacket
def initialize(protocol, data)
@op = Discord::VoiceClient::OP_SELECT_PROTOCOL
@d = SelectProtocolPayload.new(protocol, data)
end
JSON.mapping(
op: Int32,
d: SelectProtocolPayload
)
end
struct SelectProtocolPayload
def initialize(@protocol, @data)
end
JSON.mapping(
protocol: String,
data: ProtocolData
)
end
struct ProtocolData
def initialize(@address, @port, @mode)
end
JSON.mapping(
address: String,
port: UInt16,
mode: String
)
end
struct ReadyPayload
JSON.mapping(
ssrc: Int32,
port: Int32,
modes: Array(String),
heartbeat_interval: Int32
)
end
struct SessionDescriptionPayload
JSON.mapping(
secret_key: Array(UInt8)
)
end
struct SpeakingPacket
def initialize(speaking, delay)
@op = Discord::VoiceClient::OP_SPEAKING
@d = SpeakingPayload.new(speaking, delay)
end
JSON.mapping(
op: Int32,
d: SpeakingPayload
)
end
struct SpeakingPayload
def initialize(@speaking, @delay)
end
JSON.mapping(
speaking: Bool,
delay: Int32
)
end
struct HelloPayload
JSON.mapping(
heartbeat_interval: Int32
)
end
end
end

View File

@@ -0,0 +1,16 @@
require "./converters"
require "./user"
module Discord
struct Webhook
JSON.mapping(
id: {type: UInt64, converter: SnowflakeConverter},
guild_id: {type: UInt64?, converter: SnowflakeConverter},
channel_id: {type: UInt64, converter: SnowflakeConverter},
user: User?,
name: String,
avatar: String?,
token: String
)
end
end

View File

@@ -0,0 +1,129 @@
module Discord::Mention
record User, id : UInt64, start : Int32, size : Int32
record Role, id : UInt64, start : Int32, size : Int32
record Channel, id : UInt64, start : Int32, size : Int32
record Emoji, animated : Bool, name : String, id : UInt64, start : Int32, size : Int32
record Everyone, start : Int32 do
def size
9
end
end
record Here, start : Int32 do
def size
5
end
end
alias MentionType = User | Role | Channel | Emoji | Everyone | Here
# Returns an array of mentions found in a string
def self.parse(string : String)
Parser.new(string).parse
end
# Parses a string for mentions, yielding for each mention found
def self.parse(string : String, &block : MentionType ->)
Parser.new(string).parse(&block)
end
# :nodoc:
class Parser
def initialize(@string : String)
@reader = Char::Reader.new string
end
delegate has_next?, pos, current_char, next_char, peek_next_char, to: @reader
def parse(&block : MentionType ->)
while has_next?
start = pos
animated = false
case current_char
when '<'
case next_char
when '@'
case peek_next_char
when '&'
next_char # Skip role mention indicator
if next_char.ascii_number?
snowflake = scan_snowflake(pos)
yield Role.new(snowflake, start, pos - start) if has_next? && current_char == '>'
end
when .ascii_number?, '!'
next_char # Skip mention indicator
next_char if current_char == '!' # Skip optional nickname indicator
if current_char.ascii_number?
snowflake = scan_snowflake(pos)
yield User.new(snowflake, start, pos - start + 1) if current_char == '>'
end
end
when '#'
next_char # Skip channel mention indicator
if peek_next_char.ascii_number?
snowflake = scan_snowflake(pos)
yield Channel.new(snowflake, start, pos - start + 1) if current_char == '>'
end
when ':', 'a'
if current_char == 'a'
next unless peek_next_char == ':'
animated = true
next_char
end
next_char
name = scan_word(pos)
if current_char == ':' && peek_next_char.ascii_number?
next_char
snowflake = scan_snowflake(pos)
yield Emoji.new(animated, name, snowflake, start, pos - start + 1) if current_char == '>'
end
end
when '@'
word = scan_word(pos)
case word
when "@everyone"
yield Everyone.new(start)
when "@here"
yield Here.new(start)
end
else
next_char
end
end
end
def parse
results = [] of MentionType
parse { |mention| results << mention }
results
end
private def scan_snowflake(start)
while next_char.ascii_number?
# Nothing to do
end
@string[start..pos - 1].to_u64
end
private def scan_word(start)
while has_next?
case next_char
when .ascii_letter?, .ascii_number?
# Nothing to do
else
break
end
end
@string[start..pos - 1]
end
end
end

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -0,0 +1,3 @@
module Discord
VERSION = "0.3.0"
end

View File

@@ -0,0 +1,298 @@
require "uri"
require "./mappings/gateway"
require "./mappings/vws"
require "./websocket"
require "./sodium"
module Discord
class VoiceClient
UDP_PROTOCOL = "udp"
# The mode that tells Discord we want to send encrypted audio
ENCRYPTED_MODE = "xsalsa20_poly1305"
OP_IDENTIFY = 0
OP_SELECT_PROTOCOL = 1
OP_READY = 2
OP_HEARTBEAT = 3
OP_SESSION_DESCRIPTION = 4
OP_SPEAKING = 5
OP_HELLO = 8
# The heartbeat is the same every time, so it can be a constant
HEARTBEAT_JSON = {op: OP_HEARTBEAT, d: nil}.to_json
@udp : VoiceUDP
@sequence : UInt16 = 0_u16
@time : UInt32 = 0_u32
@endpoint : String
@server_id : UInt64
@session_id : String
@token : String
@heartbeat_interval : Int32?
# Creates a new voice client. The *payload* should be a payload received
# from Discord as part of a VOICE_SERVER_UPDATE dispatch, received after
# sending a voice state update (gateway op 4) packet. The *session* should
# be the session currently in use by the gateway client on which the
# aforementioned dispatch was received, and the *user_id* should be the
# user ID of the account on which the voice client is created. (It is
# received as part of the gateway READY dispatch, for example)
def initialize(payload : Discord::Gateway::VoiceServerUpdatePayload,
session : Discord::Gateway::Session, @user_id : UInt64)
@endpoint = payload.endpoint.gsub(":80", "")
@server_id = payload.guild_id
@session_id = session.session_id
@token = payload.token
@websocket = Discord::WebSocket.new(
host: @endpoint,
path: "/",
port: 443,
tls: true
)
@websocket.on_message(&->on_message(Discord::WebSocket::Packet))
@websocket.on_close(&->on_close(String))
@udp = VoiceUDP.new
end
# Initiates the connection process and blocks forever afterwards.
def run
spawn { heartbeat_loop }
@websocket.run
end
# Closes the VWS connection, in effect disconnecting from voice.
delegate close, to: @websocket
# Sets the handler that should be run once the voice client has connected
# successfully.
def on_ready(&@ready_handler : ->)
end
# Sends a packet to indicate to Discord whether or not we are speaking
# right now
def send_speaking(speaking : Bool, delay : Int32 = 0)
packet = VWS::SpeakingPacket.new(speaking, delay)
@websocket.send(packet.to_json)
end
# Plays a single opus packet
def play_opus(buf : Bytes)
increment_packet_metadata
@udp.send_audio(buf, @sequence, @time)
end
# Increment sequence and time
private def increment_packet_metadata
@sequence += 1
@time += 960
end
private def heartbeat_loop
loop do
if @heartbeat_interval
@websocket.send(HEARTBEAT_JSON)
sleep @heartbeat_interval.not_nil!.milliseconds
else
sleep 1
end
end
end
private def on_message(packet : Discord::WebSocket::Packet)
LOGGER.debug("VWS packet received: #{packet} #{packet.data.to_s}")
case packet.opcode
when OP_READY
payload = VWS::ReadyPayload.from_json(packet.data)
handle_ready(payload)
when OP_SESSION_DESCRIPTION
payload = VWS::SessionDescriptionPayload.from_json(packet.data)
handle_session_description(payload)
when OP_HELLO
payload = VWS::HelloPayload.from_json(packet.data)
handle_hello(payload)
end
end
private def on_close(message : String)
if message.bytesize < 2
LOGGER.warn("VWS closed with data: #{message.bytes}")
return nil
end
code = IO::Memory.new(message.byte_slice(0, 2)).read_bytes(UInt16, IO::ByteFormat::BigEndian)
reason = message.byte_slice(2, message.bytesize - 2)
LOGGER.warn("VWS closed with code #{code}, reason: #{reason}")
nil
end
private def handle_ready(payload : VWS::ReadyPayload)
# We get a new heartbeat interval here that replaces the old one
@heartbeat_interval = payload.heartbeat_interval
udp_connect(payload.port.to_u32, payload.ssrc.to_u32)
end
private def udp_connect(port, ssrc)
@udp.connect(@endpoint, port, ssrc)
@udp.send_discovery
ip, port = @udp.receive_discovery_reply
send_select_protocol(UDP_PROTOCOL, ip, port, ENCRYPTED_MODE)
end
private def send_identify(server_id, user_id, session_id, token)
packet = VWS::IdentifyPacket.new(server_id, user_id, session_id, token)
@websocket.send(packet.to_json)
end
private def send_select_protocol(protocol, address, port, mode)
data = VWS::ProtocolData.new(address, port, mode)
packet = VWS::SelectProtocolPacket.new(protocol, data)
@websocket.send(packet.to_json)
end
private def handle_session_description(payload : VWS::SessionDescriptionPayload)
@udp.secret_key = Bytes.new(payload.secret_key.to_unsafe, payload.secret_key.size)
# Once the secret key has been received, we are ready to send audio data.
# Notify the user of this
spawn { @ready_handler.try(&.call) }
end
private def handle_hello(payload : VWS::HelloPayload)
@heartbeat_interval = payload.heartbeat_interval
send_identify(@server_id, @user_id, @session_id, @token)
end
end
# Client for Discord's voice UDP protocol, on which the actual audio data is
# sent. There should be no reason to manually use this class: use
# `VoiceClient` instead which uses this class internally.
class VoiceUDP
@secret_key : Bytes?
property secret_key
def initialize
@socket = UDPSocket.new
end
def connect(endpoint : String, port : UInt32, ssrc : UInt32)
@ssrc = ssrc
@socket.connect(endpoint, port)
end
# Sends a discovery packet to Discord, telling them that we want to know our
# IP so we can select the protocol on the VWS
def send_discovery
io = IO::Memory.new(70)
io.write_bytes(@ssrc.not_nil!, IO::ByteFormat::BigEndian)
io.write(Bytes.new(70 - sizeof(UInt32), 0_u8))
@socket.write(io.to_slice)
end
# Awaits a response to the discovery request and returns our local IP and
# port once the response is received
def receive_discovery_reply : {String, UInt16}
buf = Bytes.new(70)
@socket.receive(buf)
io = IO::Memory.new(buf)
io.seek(4) # The first four bytes are just the SSRC again, we don't care about that
ip = io.read_string(64).delete("\0")
port = io.read_bytes(UInt16, IO::ByteFormat::BigEndian)
{ip, port}
end
# Sends 20 ms of opus audio data to Discord, with the specified sequence and
# time (used on the receiving client to synchronise packets)
def send_audio(buf, sequence, time)
header = create_header(sequence, time)
buf = encrypt_audio(header, buf)
new_buf = Bytes.new(header.size + buf.size)
header.copy_to(new_buf)
buf.copy_to(new_buf + header.size)
@socket.write(new_buf)
end
private def create_header(sequence : UInt16, time : UInt32) : Bytes
io = IO::Memory.new(12)
# Write the magic bytes required by Discord
io.write_byte(0x80_u8)
io.write_byte(0x78_u8)
# Write the actual information in the header
io.write_bytes(sequence, IO::ByteFormat::BigEndian)
io.write_bytes(time, IO::ByteFormat::BigEndian)
io.write_bytes(@ssrc.not_nil!, IO::ByteFormat::BigEndian)
io.to_slice
end
private def encrypt_audio(header : Bytes, buf : Bytes) : Bytes
raise "No secret key was set!" unless @secret_key
nonce = Bytes.new(24, 0_u8) # 24 null bytes
header.copy_to(nonce) # First 12 bytes of nonce is the header
# Sodium constants
zero_bytes = Sodium.crypto_secretbox_xsalsa20poly1305_zerobytes
box_zero_bytes = Sodium.crypto_secretbox_xsalsa20poly1305_boxzerobytes
# Prepend the buf with zero_bytes zero bytes
message = Bytes.new(buf.size + zero_bytes, 0_u8)
buf.copy_to(message + zero_bytes)
# Create a buffer for the ciphertext
c = Bytes.new(message.size)
# Encrypt
Sodium.crypto_secretbox_xsalsa20poly1305(c, message, message.bytesize, nonce, @secret_key.not_nil!)
# The resulting ciphertext buffer has box_zero_bytes zero bytes prepended;
# we don't want them in the result, so move the slice forward by that many
# bytes
c + box_zero_bytes
end
end
# Utility function that runs the given block and measures the time it takes,
# then sleeps the given time minus that time. This is useful for voice code
# because (in most cases) voice data should be sent to Discord at a rate of
# one frame every 20 ms, and if the processing and sending takes a certain
# amount of time, then noticeable choppiness can be heard.
def self.timed_run(total_time : Time::Span)
t1 = Time.now
yield
delta = Time.now - t1
sleep_time = {total_time - delta, Time::Span.zero}.max
sleep sleep_time
end
# Runs the given block every *time_span*. This method takes into account the
# execution time for the block to keep the intervals accurate.
#
# Note that if the block takes longer to execute than the given *time_span*,
# there will be no delay: the next iteration follows immediately, with no
# attempt to get in sync.
def self.every(time_span : Time::Span)
loop do
timed_run(time_span) { yield }
end
end
end

View File

@@ -0,0 +1,82 @@
require "http"
module Discord
# Internal wrapper around HTTP::WebSocket to decode the Discord-specific
# payload format used in the gateway and VWS.
class WebSocket
# :nodoc:
struct Packet
getter opcode, sequence, data, event_type
def initialize(@opcode : Int64?, @sequence : Int64?, @data : IO::Memory, @event_type : String?)
end
def inspect(io : IO)
io << "Discord::WebSocket::Packet(@opcode="
opcode.inspect(io)
io << " @sequence="
sequence.inspect(io)
io << " @data="
data.to_s.inspect(io)
io << " @event_type="
event_type.inspect(io)
io << ')'
end
end
def initialize(@host : String, @path : String, @port : Int32, @tls : Bool)
@websocket = HTTP::WebSocket.new(
host: @host,
path: @path,
port: @port,
tls: @tls
)
end
def on_message(&handler : Packet ->)
@websocket.on_message do |message|
payload = parse_message(message)
handler.call(payload)
end
end
def on_close(&handler : String ->)
@websocket.on_close(&handler)
end
delegate run, close, send, to: @websocket
private def parse_message(message : String)
parser = JSON::PullParser.new(message)
opcode = nil
sequence = nil
event_type = nil
data = IO::Memory.new
parser.read_object do |key|
case key
when "op"
opcode = parser.read_int
when "d"
# Read the raw JSON into memory
JSON.build(data) do |builder|
parser.read_raw(builder)
end
when "s"
sequence = parser.read_int_or_null
when "t"
event_type = parser.read_string_or_null
else
# Unknown field
parser.skip
end
end
# Rewind to beginning of JSON
data.rewind
Packet.new(opcode, sequence, data, event_type)
end
end
end

View File

@@ -1,4 +1,16 @@
require "discordcr"
token = "Bot NDIzNTkyMjczMTUxMDAwNTc2.DghCOQ.TITKUFX_WZDIh2j6kiprGTQ0Chw"
id = 423592273151000576_u64
prefix = '+'
client = Discord::Client.new(token: token, client_id: id)
client.on_message_create do |payload|
if payload.content.starts_with? prefix
client.create_message(payload.channel_id, "Pong!")
end
end
client.run()

View File

@@ -0,0 +1,6 @@
version: 1.0
shards:
discordcr:
github: z64/discordcr
commit: 5c722de5c25a6020466e80bc10d7904a0e30e7d5

View File

@@ -0,0 +1,19 @@
name: First-Program
version: 0.1.0
# authors:
# - name <email@example.com>
# description: |
# Short description of First-Program
dependencies:
discordcr:
github: z64/discordcr
branch: crystal-0.25
# development_dependencies:
# webmock:
# github: manastech/webmock.cr
license: MIT