diff --git a/C#/this dot/this dot.sln b/C#/this dot/this dot.sln new file mode 100644 index 0000000..ae51197 --- /dev/null +++ b/C#/this dot/this dot.sln @@ -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 diff --git a/C#/this dot/this dot/App.config b/C#/this dot/this dot/App.config new file mode 100644 index 0000000..731f6de --- /dev/null +++ b/C#/this dot/this dot/App.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/C#/this dot/this dot/Program.cs b/C#/this dot/this dot/Program.cs new file mode 100644 index 0000000..fbfd5df --- /dev/null +++ b/C#/this dot/this dot/Program.cs @@ -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++; + } + } +} diff --git a/C#/this dot/this dot/Properties/AssemblyInfo.cs b/C#/this dot/this dot/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..b122084 --- /dev/null +++ b/C#/this dot/this dot/Properties/AssemblyInfo.cs @@ -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")] diff --git a/C#/this dot/this dot/this dot.csproj b/C#/this dot/this dot/this dot.csproj new file mode 100644 index 0000000..669a6c3 --- /dev/null +++ b/C#/this dot/this dot/this dot.csproj @@ -0,0 +1,52 @@ + + + + + Debug + AnyCPU + {2119289F-E327-4138-82E7-414D9FB8DD9E} + Exe + this_dot + this dot + v4.6.1 + 512 + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Crystal/First-Program/a.out b/Crystal/First-Program/a.out new file mode 100644 index 0000000..e69de29 diff --git a/Crystal/First-Program/lib/discordcr.sha1 b/Crystal/First-Program/lib/discordcr.sha1 new file mode 100644 index 0000000..d66a88c --- /dev/null +++ b/Crystal/First-Program/lib/discordcr.sha1 @@ -0,0 +1 @@ +5c722de5c25a6020466e80bc10d7904a0e30e7d5 \ No newline at end of file diff --git a/Crystal/First-Program/lib/discordcr/.gitignore b/Crystal/First-Program/lib/discordcr/.gitignore new file mode 100644 index 0000000..c32016b --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/.gitignore @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/.travis.yml b/Crystal/First-Program/lib/discordcr/.travis.yml new file mode 100644 index 0000000..0c8223e --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/.travis.yml @@ -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" diff --git a/Crystal/First-Program/lib/discordcr/LICENSE b/Crystal/First-Program/lib/discordcr/LICENSE new file mode 100644 index 0000000..e67534e --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/LICENSE @@ -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. diff --git a/Crystal/First-Program/lib/discordcr/README.md b/Crystal/First-Program/lib/discordcr/README.md new file mode 100644 index 0000000..04c5b84 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/README.md @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/deploy.sh b/Crystal/First-Program/lib/discordcr/deploy.sh new file mode 100644 index 0000000..d9b8cdc --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/deploy.sh @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/deploy_key.enc b/Crystal/First-Program/lib/discordcr/deploy_key.enc new file mode 100644 index 0000000..edbd451 Binary files /dev/null and b/Crystal/First-Program/lib/discordcr/deploy_key.enc differ diff --git a/Crystal/First-Program/lib/discordcr/examples/mention_parser.cr b/Crystal/First-Program/lib/discordcr/examples/mention_parser.cr new file mode 100644 index 0000000..ad0a7e9 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/examples/mention_parser.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/examples/multicommand.cr b/Crystal/First-Program/lib/discordcr/examples/multicommand.cr new file mode 100644 index 0000000..cadb9b5 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/examples/multicommand.cr @@ -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 ==> 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 diff --git a/Crystal/First-Program/lib/discordcr/examples/ping.cr b/Crystal/First-Program/lib/discordcr/examples/ping.cr new file mode 100644 index 0000000..21b33cf --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/examples/ping.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/examples/ping_with_response_time.cr b/Crystal/First-Program/lib/discordcr/examples/ping_with_response_time.cr new file mode 100644 index 0000000..babdfe4 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/examples/ping_with_response_time.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/examples/voice_send.cr b/Crystal/First-Program/lib/discordcr/examples/voice_send.cr new file mode 100644 index 0000000..c55e2bb --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/examples/voice_send.cr @@ -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 + + # 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 + # + # 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 diff --git a/Crystal/First-Program/lib/discordcr/examples/welcome.cr b/Crystal/First-Program/lib/discordcr/examples/welcome.cr new file mode 100644 index 0000000..005ffbf --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/examples/welcome.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/shard.yml b/Crystal/First-Program/lib/discordcr/shard.yml new file mode 100644 index 0000000..6f9c875 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/shard.yml @@ -0,0 +1,7 @@ +name: discordcr +version: 0.3.0 + +authors: + - meew0 + +license: MIT diff --git a/Crystal/First-Program/lib/discordcr/spec/discordcr_spec.cr b/Crystal/First-Program/lib/discordcr/spec/discordcr_spec.cr new file mode 100644 index 0000000..941e931 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/spec/discordcr_spec.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/spec/mention_spec.cr b/Crystal/First-Program/lib/discordcr/spec/mention_spec.cr new file mode 100644 index 0000000..e9108e2 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/spec/mention_spec.cr @@ -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>", + 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<@abc><@!abc>", + into: [] of Discord::Mention) + end + end +end diff --git a/Crystal/First-Program/lib/discordcr/spec/rest_spec.cr b/Crystal/First-Program/lib/discordcr/spec/rest_spec.cr new file mode 100644 index 0000000..72f0a77 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/spec/rest_spec.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/spec/spec_helper.cr b/Crystal/First-Program/lib/discordcr/spec/spec_helper.cr new file mode 100644 index 0000000..86ab001 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../src/discordcr" diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr.cr b/Crystal/First-Program/lib/discordcr/src/discordcr.cr new file mode 100644 index 0000000..ac238bd --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr.cr @@ -0,0 +1,5 @@ +require "./discordcr/*" + +module Discord + # TODO Put your code here +end diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/cache.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/cache.cr new file mode 100644 index 0000000..5b587bc --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/cache.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/client.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/client.cr new file mode 100644 index 0000000..321d35e --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/client.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/dca.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/dca.cr new file mode 100644 index 0000000..2376588 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/dca.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/errors.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/errors.cr new file mode 100644 index 0000000..812f939 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/errors.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/logger.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/logger.cr new file mode 100644 index 0000000..685a8e5 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/logger.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/channel.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/channel.cr new file mode 100644 index 0000000..1b115dd --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/channel.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/converters.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/converters.cr new file mode 100644 index 0000000..c238d21 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/converters.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/enums.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/enums.cr new file mode 100644 index 0000000..0d2a3fd --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/enums.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/gateway.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/gateway.cr new file mode 100644 index 0000000..99016ec --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/gateway.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/guild.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/guild.cr new file mode 100644 index 0000000..2d69546 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/guild.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/invite.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/invite.cr new file mode 100644 index 0000000..f9d3d84 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/invite.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/oauth2.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/oauth2.cr new file mode 100644 index 0000000..3c25f43 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/oauth2.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/permissions.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/permissions.cr new file mode 100644 index 0000000..1e065db --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/permissions.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/rest.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/rest.cr new file mode 100644 index 0000000..bbe4487 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/rest.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/user.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/user.cr new file mode 100644 index 0000000..9526d07 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/user.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/voice.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/voice.cr new file mode 100644 index 0000000..58d066e --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/voice.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/vws.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/vws.cr new file mode 100644 index 0000000..dbc7bd1 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/vws.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/webhook.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/webhook.cr new file mode 100644 index 0000000..3c69a2f --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mappings/webhook.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/mention.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/mention.cr new file mode 100644 index 0000000..b657a62 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/mention.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/rest.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/rest.cr new file mode 100644 index 0000000..77234a2 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/rest.cr @@ -0,0 +1,1680 @@ +require "http/client" +require "http/formdata" +require "openssl/ssl/context" +require "time/format" + +require "./mappings/*" +require "./version" +require "./errors" + +module Discord + module REST + SSL_CONTEXT = OpenSSL::SSL::Context::Client.new + USER_AGENT = "DiscordBot (https://github.com/meew0/discordcr, #{Discord::VERSION})" + API_BASE = "https://discordapp.com/api/v6" + + alias RateLimitKey = {route_key: Symbol, major_parameter: UInt64?} + + # Like `#request`, but does not do error checking beyond 429. + def raw_request(route_key : Symbol, major_parameter : UInt64?, method : String, path : String, headers : HTTP::Headers, body : String?) + mutexes = (@mutexes ||= Hash(RateLimitKey, Mutex).new) + global_mutex = (@global_mutex ||= Mutex.new) + + headers["Authorization"] = @token + headers["User-Agent"] = USER_AGENT + + request_done = false + rate_limit_key = {route_key: route_key, major_parameter: major_parameter} + + until request_done + mutexes[rate_limit_key] ||= Mutex.new + + # Make sure to catch up with existing mutexes - they may be locked from + # another fiber. + mutexes[rate_limit_key].synchronize { } + global_mutex.synchronize { } + + response = HTTP::Client.exec(method: method, url: API_BASE + path, headers: headers, body: body, tls: SSL_CONTEXT) + + if response.status_code == 429 || response.headers["X-RateLimit-Remaining"]? == "0" + # We got rate limited! + if response.headers["Retry-After"]? + # Retry-After is in ms, convert to seconds first + retry_after = response.headers["Retry-After"].to_i / 1000.0 + else + # Calculate the difference between the HTTP Date header, which + # represents the time the response was sent on Discord's side, and + # the reset header which represents when the rate limit will get + # reset. + origin_time = HTTP.parse_time(response.headers["Date"]).not_nil! + reset_time = Time.epoch(response.headers["X-RateLimit-Reset"].to_u64) # gotta prevent that Y2k38 + retry_after = reset_time - origin_time + end + + if response.headers["X-RateLimit-Global"]? + global_mutex.synchronize { sleep retry_after } + else + mutexes[rate_limit_key].synchronize { sleep retry_after } + end + + # If we actually got a 429, i. e. the request failed, we need to + # retry it. + request_done = true unless response.status_code == 429 + else + request_done = true + end + end + + response.not_nil! + end + + # Makes a REST request to Discord, with the given *method* to the given + # *path*, with the given *headers* set and with the given *body* being sent. + # The *route_key* should uniquely identify the route used, for rate limiting + # purposes. The *major_parameter* should be set to the guild or channel ID, + # if either of those appears as the first parameter in the route. + # + # This method also does rate limit handling, so if a rate limit is + # encountered, it may take longer than usual. (In case you're worried, this + # won't block events from being processed.) It also performs other kinds + # of error checking, so if a request fails (with a status code that is not + # 429) you will be notified of that. + def request(route_key : Symbol, major_parameter : UInt64?, method : String, path : String, headers : HTTP::Headers, body : String?) + response = raw_request(route_key, major_parameter, method, path, headers, body) + + unless response.success? + raise StatusException.new(response) unless response.content_type == "application/json" + + begin + error = APIError.from_json(response.body) + rescue + raise StatusException.new(response) + end + raise CodeException.new(response, error) + end + + response + end + + # :nodoc: + def encode_tuple(**tuple) + JSON.build do |builder| + builder.object do + tuple.each do |key, value| + next if value.nil? + builder.field(key) { value.to_json(builder) } + end + end + end + end + + # Gets the gateway URL to connect to. + # + # [API docs for this method](https://discordapp.com/developers/docs/topics/gateway#get-gateway) + def get_gateway + response = request( + :gateway, + nil, + "GET", + "/gateway", + HTTP::Headers.new, + nil + ) + + GatewayResponse.from_json(response.body) + end + + # Gets the gateway Bot URL to connect to, and the recommended amount of shards to make. + # + # [API docs for this method](https://discordapp.com/developers/docs/topics/gateway#get-gateway-bot) + def get_gateway_bot + response = request( + :gateway_bot, + nil, + "GET", + "/gateway/bot", + HTTP::Headers.new, + nil + ) + + GatewayBotResponse.from_json(response.body) + end + + # Gets the OAuth2 application tied to a client. + # + # [API docs for this method](https://discordapp.com/developers/docs/topics/oauth2#get-current-application-information) + def get_oauth2_application + response = request( + :ouath2_applications_me, + nil, + "GET", + "/oauth2/applications/@me", + HTTP::Headers.new, + nil + ) + + OAuth2Application.from_json(response.body) + end + + # Gets a channel by ID. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#get-channel) + def get_channel(channel_id : UInt64) + response = request( + :channels_cid, + channel_id, + "GET", + "/channels/#{channel_id}", + HTTP::Headers.new, + nil + ) + + Channel.from_json(response.body) + end + + # Modifies a channel with new properties. Requires the "Manage Channel" + # permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#modify-channel) + def modify_channel(channel_id : UInt64, name : String? = nil, position : UInt32? = nil, + topic : String? = nil, bitrate : UInt32? = nil, user_limit : UInt32? = nil, + nsfw : Bool? = nil) + json = encode_tuple( + name: name, + position: position, + topic: topic, + bitrate: bitrate, + user_limit: user_limit + ) + + response = request( + :channels_cid, + channel_id, + "PATCH", + "/channels/#{channel_id}", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + Channel.from_json(response.body) + end + + # Deletes a channel by ID. Requires the "Manage Channel" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#deleteclose-channel) + def delete_channel(channel_id : UInt64) + request( + :channels_cid, + channel_id, + "DELETE", + "/channels/#{channel_id}", + HTTP::Headers.new, + nil + ) + end + + # Gets a list of messages from the channel's history. Requires the "Read + # Message History" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#get-channel-messages) + def get_channel_messages(channel_id : UInt64, limit : UInt8 = 50, before : UInt64? = nil, after : UInt64? = nil, around : UInt64? = nil) + path = "/channels/#{channel_id}/messages?limit=#{limit}" + path += "&before=#{before}" if before + path += "&after=#{after}" if after + path += "&around=#{around}" if around + + response = request( + :channels_cid_messages, + channel_id, + "GET", + path, + HTTP::Headers.new, + nil + ) + + Array(Message).from_json(response.body) + end + + # Gets a single message from the channel's history. Requires the "Read + # Message History" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#get-channel-message) + def get_channel_message(channel_id : UInt64, message_id : UInt64) + response = request( + :channels_cid_messages_mid, + channel_id, + "GET", + "/channels/#{channel_id}/messages/#{message_id}", + HTTP::Headers.new, + nil + ) + + Message.from_json(response.body) + end + + # Sends a message to the channel. Requires the "Send Messages" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#create-message) + # + # The `embed` parameter can be used to append a rich embed to the message + # which allows for displaying certain kinds of data in a more structured + # way. An example: + # + # ``` + # embed = Discord::Embed.new( + # title: "Title of Embed", + # description: "Description of embed. This can be a long text. Neque porro quisquam est, qui dolorem ipsum, quia dolor sit, amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt, ut labore et dolore magnam aliquam quaerat voluptatem.", + # timestamp: Time.now, + # url: "https://example.com", + # image: Discord::EmbedImage.new( + # url: "https://example.com/image.png", + # ), + # fields: [ + # Discord::EmbedField.new( + # name: "Name of Field", + # value: "Value of Field", + # ), + # ], + # ) + # + # client.create_message(channel_id, "The content of the message. This will display separately above the embed. This string can be empty.", embed) + # ``` + # + # For more details on the format of the `embed` object, look at the + # [relevant documentation](https://discordapp.com/developers/docs/resources/channel#embed-object). + def create_message(channel_id : UInt64, content : String, embed : Embed? = nil, tts : Bool = false) + response = request( + :channels_cid_messages, + channel_id, + "POST", + "/channels/#{channel_id}/messages", + HTTP::Headers{"Content-Type" => "application/json"}, + {content: content, tts: tts, embed: embed}.to_json + ) + + Message.from_json(response.body) + end + + # Adds a reaction to a message. The `emoji` property must be in the format + # `name:id` for custom emoji. For unicode emoji it can simply be the UTF-8 + # encoded characters. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#create-reaction) + def create_reaction(channel_id : UInt64, message_id : UInt64, emoji : String) + response = request( + :channels_cid_messages_mid_reactions_emoji_me, + channel_id, + "PUT", + "/channels/#{channel_id}/messages/#{message_id}/reactions/#{emoji}/@me", + HTTP::Headers.new, + nil + ) + end + + # Removes the bot's own reaction from a message. The `emoji` property must + # be in the format `name:id` for custom emoji. For unicode emoji it can + # simply be the UTF-8 encoded characters. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#delete-own-reaction) + def delete_own_reaction(channel_id : UInt64, message_id : UInt64, emoji : String) + request( + :channels_cid_messages_mid_reactions_emoji_me, + channel_id, + "DELETE", + "/channels/#{channel_id}/messages/#{message_id}/reactions/#{emoji}/@me", + HTTP::Headers.new, + nil + ) + end + + # Removes another user's reaction from a message. The `emoji` property must + # be in the format `name:id` for custom emoji. For unicode emoji it can + # simply be the UTF-8 encoded characters. Requires the "Manage Messages" + # permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#delete-user-reaction) + def delete_user_reaction(channel_id : UInt64, message_id : UInt64, emoji : String, user_id : UInt64) + request( + :channels_cid_messages_mid_reactions_emoji_uid, + channel_id, + "DELETE", + "/channels/#{channel_id}/messages/#{message_id}/reactions/#{emoji}/#{user_id}", + HTTP::Headers.new, + nil + ) + end + + # Returns all users that have reacted with a specific emoji. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#get-reactions) + def get_reactions(channel_id : UInt64, message_id : UInt64, emoji : String) + response = request( + :channels_cid_messages_mid_reactions_emoji_me, + channel_id, + "GET", + "/channels/#{channel_id}/messages/#{message_id}/reactions/#{emoji}", + HTTP::Headers.new, + nil + ) + + Array(User).from_json(response.body) + end + + # Removes all reactions from a message. Requires the "Manage Messages" + # permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#delete-all-reactions) + def delete_all_reactions(channel_id : UInt64, message_id : UInt64) + request( + :channels_cid_messages_mid_reactions, + channel_id, + "DELETE", + "/channels/#{channel_id}/messages/#{message_id}/reactions", + HTTP::Headers.new, + nil + ) + end + + # Uploads a file to a channel. Requires the "Send Messages" and "Attach + # Files" permissions. + # + # If the specified `file` is a `File` object and no filename is specified, + # the file's filename will be used instead. If it is an `IO` without + # filename information, Discord will generate a placeholder filename. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#create-message) + # (same as `#create_message` -- this method implements form data bodies + # while `#create_message` implements JSON bodies) + def upload_file(channel_id : UInt64, content : String?, file : IO, filename : String? = nil) + io = IO::Memory.new + + unless filename + if file.is_a? File + filename = File.basename(file.path) + else + filename = "" + end + end + + builder = HTTP::FormData::Builder.new(io) + builder.field("content", content) if content + builder.file("file", file, HTTP::FormData::FileMetadata.new(filename: filename)) + builder.finish + + response = request( + :channels_cid_messages, + channel_id, + "POST", + "/channels/#{channel_id}/messages", + HTTP::Headers{"Content-Type" => builder.content_type}, + io.to_s + ) + + Message.from_json(response.body) + end + + # Edits an existing message on the channel. This only works for messages + # sent by the bot itself - you can't edit others' messages. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#edit-message) + def edit_message(channel_id : UInt64, message_id : UInt64, content : String, embed : Embed? = nil) + response = request( + :channels_cid_messages_mid, + channel_id, + "PATCH", + "/channels/#{channel_id}/messages/#{message_id}", + HTTP::Headers{"Content-Type" => "application/json"}, + {content: content, embed: embed}.to_json + ) + + Message.from_json(response.body) + end + + # Deletes a message from the channel. Requires the message to either have + # been sent by the bot itself or the bot to have the "Manage Messages" + # permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#delete-message) + def delete_message(channel_id : UInt64, message_id : UInt64) + response = request( + :channels_cid_messages_mid, + channel_id, + "DELETE", + "/channels/#{channel_id}/messages/#{message_id}", + HTTP::Headers.new, + nil + ) + end + + # Deletes multiple messages at once from the channel. The maximum amount is + # 100 messages, the minimum is 2. Requires the "Manage Messages" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#bulk-delete-messages) + def bulk_delete_messages(channel_id : UInt64, message_ids : Array(UInt64)) + response = request( + :channels_cid_messages_bulk_delete, + channel_id, + "POST", + "/channels/#{channel_id}/messages/bulk_delete", + HTTP::Headers{"Content-Type" => "application/json"}, + {messages: message_ids}.to_json + ) + end + + # Edits an existing permission overwrite on a channel with new permissions, + # or creates a new one. The *overwrite_id* should be either a user or a role + # ID. Requires the "Manage Permissions" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#edit-channel-permissions) + def edit_channel_permissions(channel_id : UInt64, overwrite_id : UInt64, + type : String, allow : Permissions, deny : Permissions) + json = encode_tuple( + allow: allow, + deny: deny, + type: type + ) + + response = request( + :channels_cid_permissions_oid, + channel_id, + "PUT", + "/channels/#{channel_id}/permissions/#{overwrite_id}", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + end + + # Gets a list of invites for this channel. Requires the "Manage Channel" + # permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#get-channel-invites) + def get_channel_invites(channel_id : UInt64) + response = request( + :channels_cid_invites, + channel_id, + "GET", + "/channels/#{channel_id}/invites", + HTTP::Headers.new, + nil + ) + + Array(InviteMetadata).from_json(response.body) + end + + # Creates a new invite for the channel. Requires the "Create Instant Invite" + # permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#create-channel-invite) + def create_channel_invite(channel_id : UInt64, max_age : UInt32 = 0_u32, + max_uses : UInt32 = 0_u32, temporary : Bool = false) + json = encode_tuple( + max_age: max_age, + max_uses: max_uses, + temporary: temporary + ) + + response = request( + :channels_cid_invites, + channel_id, + "POST", + "/channels/#{channel_id}/invites", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + Invite.from_json(response.body) + end + + # Deletes a permission overwrite from a channel. Requires the "Manage + # Permissions" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#delete-channel-permission) + def delete_channel_permission(channel_id : UInt64, overwrite_id : UInt64) + response = request( + :channels_cid_permissions_oid, + channel_id, + "DELETE", + "/channels/#{channel_id}/permissions/#{overwrite_id}", + HTTP::Headers.new, + nil + ) + end + + # Causes the bot to appear as typing on the channel. This generally lasts + # 10 seconds, but should be refreshed every five seconds. Requires the + # "Send Messages" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#trigger-typing-indicator) + def trigger_typing_indicator(channel_id : UInt64) + response = request( + :channels_cid_typing, + channel_id, + "POST", + "/channels/#{channel_id}/typing", + HTTP::Headers.new, + nil + ) + end + + # Get a list of messages pinned to this channel. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#get-pinned-messages) + def get_pinned_messages(channel_id : UInt64) + response = request( + :channels_cid_pins, + channel_id, + "GET", + "/channels/#{channel_id}/pins", + HTTP::Headers.new, + nil + ) + + Array(Message).from_json(response.body) + end + + # Pins a new message to this channel. Requires the "Manage Messages" + # permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#add-pinned-channel-message) + def add_pinned_channel_message(channel_id : UInt64, message_id : UInt64) + response = request( + :channels_cid_pins_mid, + channel_id, + "PUT", + "/channels/#{channel_id}/pins/#{message_id}", + HTTP::Headers.new, + nil + ) + end + + # Unpins a message from this channel. Requires the "Manage Messages" + # permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/channel#delete-pinned-channel-message) + def delete_pinned_channel_message(channel_id : UInt64, message_id : UInt64) + response = request( + :channels_cid_pins_mid, + channel_id, + "DELETE", + "/channels/#{channel_id}/pins/#{message_id}", + HTTP::Headers.new, + nil + ) + end + + # Gets a guild by ID. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#get-guild) + def get_guild(guild_id : UInt64) + response = request( + :guilds_gid, + guild_id, + "GET", + "/guilds/#{guild_id}", + HTTP::Headers.new, + nil + ) + + Guild.from_json(response.body) + end + + # Modifies an existing guild with new properties. Requires the "Manage + # Server" permission. + # NOTE: To remove a guild's icon, you can send an empty string for the `icon` argument. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#modify-guild) + def modify_guild(guild_id : UInt64, name : String? = nil, region : String? = nil, + verification_level : UInt8? = nil, afk_channel_id : UInt64? = nil, + afk_timeout : Int32? = nil, icon : String? = nil, owner_id : UInt64? = nil, + splash : String? = nil) + json = encode_tuple( + name: name, + region: region, + verification_level: verification_level, + afk_channel_id: afk_channel_id, + afk_timeout: afk_timeout, + icon: icon, + owner_id: owner_id, + splash: splash + ) + + response = request( + :guilds_gid, + guild_id, + "PATCH", + "/guilds/#{guild_id}", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + Guild.from_json(response.body) + end + + # Deletes a guild. Requires the bot to be the server owner. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#delete-guild) + def delete_guild(guild_id : UInt64) + response = request( + :guilds_gid, + guild_id, + "DELETE", + "/guilds/#{guild_id}", + HTTP::Headers.new, + nil + ) + + Guild.from_json(response.body) + end + + # Gets a list of emoji on the guild. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/emoji#list-guild-emojis) + def get_guild_emojis(guild_id : UInt64) + response = request( + :guild_gid_emojis, + guild_id, + "GET", + "/guilds/#{guild_id}/emojis", + HTTP::Headers.new, + nil + ) + + Array(Emoji).from_json(response.body) + end + + # Modifies a guild emoji. Requires the "Manage Emojis" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/emoji#modify-guild-emoji) + def modify_guild_emoji(guild_id : UInt64, emoji_id : UInt64, name : String) + response = request( + :guilds_gid_emojis, + guild_id, + "PATCH", + "/guilds/#{guild_id}/emojis/#{emoji_id}", + HTTP::Headers.new, + {name: name}.to_json + ) + + Emoji.from_json(response.body) + end + + # Creates a guild emoji. Requires the "Manage Emojis" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/emoji#create-guild-emoji) + def create_guild_emoji(guild_id : UInt64, name : String, image : String) + json = encode_tuple( + name: name, + image: image, + ) + + response = request( + :guild_gid_emojis, + guild_id, + "POST", + "/guilds/#{guild_id}/emojis", + HTTP::Headers.new, + json + ) + + Emoji.from_json(response.body) + end + + # Gets a list of channels in a guild. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#get-guild-channels) + def get_guild_channels(guild_id : UInt64) + response = request( + :guilds_gid_channels, + guild_id, + "GET", + "/guilds/#{guild_id}/channels", + HTTP::Headers.new, + nil + ) + + Array(Channel).from_json(response.body) + end + + # Creates a new channel on this guild. Requires the "Manage Channels" + # permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#create-guild-channel) + def create_guild_channel(guild_id : UInt64, name : String, type : ChannelType, + bitrate : UInt32?, user_limit : UInt32?) + json = encode_tuple( + name: name, + type: type, + bitrate: bitrate, + user_limit: user_limit + ) + + response = request( + :guilds_gid_channels, + guild_id, + "POST", + "/guilds/#{guild_id}/channels", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + Channel.from_json(response.body) + end + + # Gets the vanity URL of a guild. Requires the guild to be partnered. + # + # There are no API docs for this method. + def get_guild_vanity_url(guild_id : UInt64) + response = request( + :guilds_gid_vanityurl, + guild_id, + "GET", + "/guilds/#{guild_id}/vanity-url", + HTTP::Headers.new, + nil + ) + + GuildVanityURLResponse.from_json(response.body).code + end + + # Sets the vanity URL on this guild. Requires the guild to be + # partnered. + # + # There are no API docs for this method. + def modify_guild_vanity_url(guild_id : UInt64, code : String) + request( + :guilds_gid_vanityurl, + guild_id, + "PATCH", + "/guilds/#{guild_id}/vanity-url", + HTTP::Headers.new, + {code: code}.to_json + ) + end + + # Modifies the position of channels within a guild. Requires the + # "Manage Channels" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#modify-guild-channel-positions) + def modify_guild_channel_positions(guild_id : UInt64, + positions : Array(ModifyChannelPositionPayload)) + request( + :guilds_gid_channels, + guild_id, + "PATCH", + "/guilds/#{guild_id}/channels", + HTTP::Headers{"Content-Type" => "application/json"}, + positions.to_json + ) + end + + # Gets a specific member by both IDs. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#get-guild-member) + def get_guild_member(guild_id : UInt64, user_id : UInt64) + response = request( + :guilds_gid_members_uid, + guild_id, + "GET", + "/guilds/#{guild_id}/members/#{user_id}", + HTTP::Headers.new, + nil + ) + + GuildMember.from_json(response.body) + end + + # Gets multiple guild members at once. The *limit* can be at most 1000. + # The resulting list will be sorted by user IDs, use the *after* parameter + # to specify what ID to start at. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#list-guild-members) + def list_guild_members(guild_id : UInt64, limit : Int32 = 1000, after : UInt64 = 0_u64) + path = "/guilds/#{guild_id}/members?limit=#{limit}&after=#{after}" + + response = request( + :guilds_gid_members, + guild_id, + "GET", + path, + HTTP::Headers.new, + nil + ) + + Array(GuildMember).from_json(response.body) + end + + # Adds a user to the guild, provided you have a valid OAuth2 access token + # for the user with the `guilds.join` scope. + # + # NOTE: The bot must be a member of the target guild, and have permissions + # to create instant invites. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#add-guild-member) + def add_guild_member(guild_id : UInt64, user_id : UInt64, + access_token : String, nick : String? = nil, + roles : Array(UInt64)? = nil, mute : Bool? = nil, + deaf : Bool? = nil) + json = encode_tuple( + access_token: access_token, + nick: nick, + roles: roles, + mute: mute, + deaf: deaf + ) + + response = request( + :guilds_gid_members_uid, + guild_id, + "PUT", + "/guilds/#{guild_id}/members/#{user_id}", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + GuildMember.from_json(response.body) + end + + # Changes a specific member's properties. Requires: + # + # - the "Manage Roles" permission and the role to change to be lower + # than the bot's highest role if changing the roles, + # - the "Manage Nicknames" permission when changing the nickname, + # - the "Mute Members" permission when changing mute status, + # - the "Deafen Members" permission when changing deaf status, + # - and the "Move Members" permission as well as the "Connect" permission + # to the new channel when changing voice channel ID. + # + # NOTE: To remove a member's nickname, you can send an empty string for the `nick` argument. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#modify-guild-member) + def modify_guild_member(guild_id : UInt64, user_id : UInt64, nick : String? = nil, + roles : Array(UInt64)? = nil, mute : Bool? = nil, deaf : Bool? = nil, + channel_id : UInt64? = nil) + json = encode_tuple( + nick: nick, + roles: roles, + mute: mute, + deaf: deaf, + channel_id: channel_id + ) + + request( + :guilds_gid_members_uid, + guild_id, + "PATCH", + "/guilds/#{guild_id}/members/#{user_id}", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + end + + # Modifies the nickname of the current user in a guild. + # + # NOTE: To remove a nickname, you can send an empty string for the `nick` argument. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#modify-current-user-nick) + def modify_current_user_nick(guild_id : UInt64, nick : String) + request( + :guilds_gid_members_me, + guild_id, + "PATCH", + "/guilds/#{guild_id}/members/@me/nick", + HTTP::Headers{"Content-Type" => "application/json"}, + {nick: nick}.to_json + ) + end + + # Kicks a member from the server. Requires the "Kick Members" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#remove-guild-member) + def remove_guild_member(guild_id : UInt64, user_id : UInt64) + request( + :guilds_gid_members_uid, + guild_id, + "DELETE", + "/guilds/#{guild_id}/members/#{user_id}", + HTTP::Headers.new, + nil + ) + end + + # Adds a role to a member. Requires the "Manage Roles" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#add-guild-member-role) + def add_guild_member_role(guild_id : UInt64, user_id : UInt64, role_id : UInt64) + request( + :guilds_gid_members_uid_roles_rid, + guild_id, + "PUT", + "/guilds/#{guild_id}/members/#{user_id}/roles/#{role_id}", + HTTP::Headers.new, + nil + ) + end + + # Removes a role from a member. Requires the "Manage Roles" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#remove-guild-member-role) + def remove_guild_member_role(guild_id : UInt64, user_id : UInt64, role_id : UInt64) + request( + :guilds_gid_members_uid_roles_rid, + guild_id, + "DELETE", + "/guilds/#{guild_id}/members/#{user_id}/roles/#{role_id}", + HTTP::Headers.new, + nil + ) + end + + # Gets a list of members banned from this server. Requires the "Ban Members" + # permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#get-guild-bans) + def get_guild_bans(guild_id : UInt64) + response = request( + :guilds_gid_bans, + guild_id, + "GET", + "/guilds/#{guild_id}/bans", + HTTP::Headers.new, + nil + ) + + Array(GuildBan).from_json(response.body) + end + + # Bans a member from the guild. Requires the "Ban Members" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#create-guild-ban) + def create_guild_ban(guild_id : UInt64, user_id : UInt64) + request( + :guilds_gid_bans_uid, + guild_id, + "PUT", + "/guilds/#{guild_id}/bans/#{user_id}", + HTTP::Headers.new, + nil + ) + end + + # Unbans a member from the guild. Requires the "Ban Members" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#remove-guild-ban) + def remove_guild_ban(guild_id : UInt64, user_id : UInt64) + request( + :guilds_gid_bans_uid, + guild_id, + "DELETE", + "/guilds/#{guild_id}/bans/#{user_id}", + HTTP::Headers.new, + nil + ) + end + + # Get a list of roles on the guild. Requires the "Manage Roles" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#get-guild-roles) + def get_guild_roles(guild_id : UInt64) + response = request( + :guilds_gid_roles, + guild_id, + "GET", + "/guilds/#{guild_id}/roles", + HTTP::Headers.new, + nil + ) + + Array(Role).from_json(response.body) + end + + # Creates a new role on the guild. Requires the "Manage Roles" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#create-guild-role) + def create_guild_role(guild_id : UInt64, name : String? = nil, + permissions : Permissions? = nil, colour : UInt32 = 0_u32, + hoist : Bool = false, mentionable : Bool = false) + json = encode_tuple( + name: name, + permissions: permissions, + color: colour, + hoist: hoist, + mentionable: mentionable + ) + + response = request( + :get_guild_roles, + guild_id, + "POST", + "/guilds/#{guild_id}/roles", + HTTP::Headers.new, + json + ) + + Role.from_json(response.body) + end + + # Changes a role's properties. Requires the "Manage Roles" permission as + # well as the role to be lower than the bot's highest role. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#modify-guild-role) + def modify_guild_role(guild_id : UInt64, role_id : UInt64, name : String? = nil, + permissions : Permissions? = nil, colour : UInt32? = nil, + position : Int32? = nil, hoist : Bool? = nil) + json = encode_tuple( + name: name, + permissions: permissions, + color: colour, + position: position, + hoist: hoist + ) + + response = request( + :guilds_gid_roles_rid, + guild_id, + "PATCH", + "/guilds/#{guild_id}/roles/#{role_id}", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + Role.from_json(response.body) + end + + # Deletes a role. Requires the "Manage Roles" permission as well as the role + # to be lower than the bot's highest role. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#delete-guild-role) + def delete_guild_role(guild_id : UInt64, role_id : UInt64) + response = request( + :guilds_gid_roles_rid, + guild_id, + "DELETE", + "/guilds/#{guild_id}/roles/#{role_id}", + HTTP::Headers.new, + nil + ) + + Role.from_json(response.body) + end + + # Get a number of members that would be pruned with the given number of + # days. Requires the "Kick Members" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#get-guild-prune-count) + def get_guild_prune_count(guild_id : UInt64, days : UInt32) + response = request( + :guilds_gid_prune, + guild_id, + "GET", + "/guilds/#{guild_id}/prune?days=#{days}", + HTTP::Headers.new, + nil + ) + + PruneCountResponse.new(response.body) + end + + # Prunes all members from this guild which haven't been seen for more than + # *days* days. Requires the "Kick Members" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#begin-guild-prune) + def begin_guild_prune(guild_id : UInt64, days : UInt32) + response = request( + :guilds_gid_prune, + guild_id, + "POST", + "/guilds/#{guild_id}/prune?days=#{days}", + HTTP::Headers.new, + nil + ) + + PruneCountResponse.new(response.body) + end + + # Gets a list of voice regions available for this guild. This may include + # VIP regions for VIP servers. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#get-guild-voice-regions) + def get_guild_voice_regions(guild_id : UInt64) + response = request( + :guilds_gid_regions, + guild_id, + "GET", + "/guilds/#{guild_id}/regions", + HTTP::Headers.new, + nil + ) + + Array(VoiceRegion).from_json(response.body) + end + + # Gets a list of integrations (Twitch, YouTube, etc.) for this guild. + # Requires the "Manage Guild" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#get-guild-integrations) + def get_guild_integrations(guild_id : UInt64) + response = request( + :guilds_gid_integrations, + guild_id, + "GET", + "/guilds/#{guild_id}/integrations", + HTTP::Headers.new, + nil + ) + + Array(Integration).from_json(response.body) + end + + # Creates a new integration for this guild. Requires the "Manage Guild" + # permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#create-guild-integration) + def create_guild_integration(guild_id : UInt64, type : String, id : UInt64) + json = encode_tuple( + type: type, + id: id + ) + + request( + :guilds_gid_integrations, + guild_id, + "POST", + "/guilds/#{guild_id}/integrations", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + end + + # Modifies an existing guild integration. Requires the "Manage Guild" + # permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#modify-guild-integration) + def modify_guild_integration(guild_id : UInt64, integration_id : UInt64, + expire_behaviour : UInt8, + expire_grace_period : Int32, + enable_emoticons : Bool) + json = encode_tuple( + expire_behavior: expire_behaviour, + expire_grace_period: expire_grace_period, + enable_emoticons: enable_emoticons + ) + + request( + :guilds_gid_integrations_iid, + guild_id, + "PATCH", + "/guilds/#{guild_id}/integrations/#{integration_id}", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + end + + # Deletes a guild integration. Requires the "Manage Guild" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#delete-guild-integration) + def delete_guild_integration(guild_id : UInt64, integration_id : UInt64) + request( + :guilds_gid_integrations_iid, + guild_id, + "DELETE", + "/guilds/#{guild_id}/integrations/#{integration_id}", + HTTP::Headers.new, + nil + ) + end + + # Syncs an integration. Requires the "Manage Guild" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#sync-guild-integration) + def sync_guild_integration(guild_id : UInt64, integration_id : UInt64) + request( + :guilds_gid_integrations_iid_sync, + guild_id, + "POST", + "/guilds/#{guild_id}/integrations/#{integration_id}/sync", + HTTP::Headers.new, + nil + ) + end + + # Gets embed data for a guild. Requires the "Manage Guild" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#get-guild-embed) + def get_guild_embed(guild_id : UInt64) + response = request( + :guilds_gid_embed, + guild_id, + "GET", + "/guilds/#{guild_id}/embed", + HTTP::Headers.new, + nil + ) + + GuildEmbed.from_json(response.body) + end + + # Modifies embed data for a guild. Requires the "Manage Guild" permission. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/guild#modify-guild-embed) + def modify_guild_embed(guild_id : UInt64, enabled : Bool, + channel_id : UInt64) + json = encode_tuple( + enabled: enabled, + channel_id: channel_id + ) + + response = request( + :guilds_gid_embed, + guild_id, + "PATCH", + "/guilds/#{guild_id}/embed", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + GuildEmbed.from_json(response.body) + end + + # Gets a specific user by ID. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/user#get-user) + def get_user(user_id : UInt64) + response = request( + :users_uid, + nil, + "GET", + "/users/#{user_id}", + HTTP::Headers.new, + nil + ) + + User.from_json(response.body) + end + + # Gets the current bot user. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/user#get-current-user) + def get_current_user + response = request( + :users_me, + nil, + "GET", + "/users/@me", + HTTP::Headers.new, + nil + ) + + User.from_json(response.body) + end + + # Modifies the current bot user, changing the username and avatar. + # NOTE: To remove the current user's avatar, you can send an empty string for the `avatar` argument. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/user#modify-current-user) + def modify_current_user(username : String? = nil, avatar : String? = nil) + json = encode_tuple( + username: username, + avatar: avatar + ) + + response = request( + :users_me, + nil, + "PATCH", + "/users/@me", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + User.from_json(response.body) + end + + # Gets a list of user guilds the current user is on. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/user#get-current-user-guilds) + def get_current_user_guilds(limit : UInt8 = 100_u8, before : UInt64 = 0_u64, after : UInt64 = 0_u64) + params = HTTP::Params.build do |form| + form.add "limit", limit.to_s + + if before > 0 + form.add "before", before.to_s + end + + if after > 0 + form.add "after", after.to_s + end + end + + path = "/users/@me/guilds?#{params}" + response = request( + :users_me_guilds, + nil, + "GET", + path, + HTTP::Headers.new, + nil + ) + + Array(UserGuild).from_json(response.body) + end + + # Makes the bot leave a guild. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/user#leave-guild) + def leave_guild(guild_id : UInt64) + request( + :users_me_guilds_gid, + nil, + "DELETE", + "/users/@me/guilds/#{guild_id}", + HTTP::Headers.new, + nil + ) + end + + # Gets a list of DM channels the bot has access to. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/user#get-user-dms) + def get_user_dms + response = request( + :users_me_channels, + nil, + "GET", + "/users/@me/channels", + HTTP::Headers.new, + nil + ) + + Array(PrivateChannel).from_json(response.body) + end + + # Creates a new DM channel with a given recipient. If there was already one + # before, it will be reopened. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/user#create-dm) + def create_dm(recipient_id : UInt64) + response = request( + :users_me_channels, + nil, + "POST", + "/users/@me/channels", + HTTP::Headers{"Content-Type" => "application/json"}, + {recipient_id: recipient_id}.to_json + ) + + PrivateChannel.from_json(response.body) + end + + # Gets a list of connections the user has set up (Twitch, YouTube, etc.) + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/user#get-users-connections) + def get_users_connections + response = request( + :users_me_connections, + nil, + "GET", + "/users/@me/connections", + HTTP::Headers.new, + nil + ) + + Array(Connection).from_json(response.body) + end + + # Gets a specific invite by its code. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/invite#get-invite) + def get_invite(code : String) + response = request( + :invites_code, + nil, + "GET", + "/invites/#{code}", + HTTP::Headers.new, + nil + ) + + Invite.from_json(response.body) + end + + # Deletes (revokes) an invite. Requires the "Manage Channel" permission for + # the channel the invite is for, or the "Manage Server" permission for the + # server. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/invite#delete-invite) + def delete_invite(code : String) + response = request( + :invites_code, + nil, + "DELETE", + "/invites/#{code}", + HTTP::Headers.new, + nil + ) + + Invite.from_json(response.body) + end + + # Makes a user accept an invite. Will not work for bots. + # For example, this can be used with a `Client` instantiated with an OAuth2 + # `Bearer` token that has been granted the `guilds.join` scope. + # ``` + # client = Discord::Client.new token: "Bearer XYZ" + # client.accept_invite("ABCdef") + # ``` + # [API docs for this method](https://discordapp.com/developers/docs/resources/invite#accept-invite) + def accept_invite(code : String) + response = request( + :invites_code, + nil, + "POST", + "/invites/#{code}", + HTTP::Headers.new, + nil + ) + + Invite.from_json(response.body) + end + + # Gets a list of voice regions newly created servers have access to. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/voice#list-voice-regions) + def list_voice_regions + response = request( + :voice_regions, + nil, + "GET", + "/voice/regions", + HTTP::Headers.new, + nil + ) + + Array(VoiceRegion).from_json(response.body) + end + + # Get a webhook. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/webhook#get-webhook). + def get_webhook(webhook_id : UInt64) + response = request( + :webhooks_wid, + webhook_id, + "GET", + "/webhooks/#{webhook_id}", + HTTP::Headers.new, + nil + ) + Webhook.from_json(response.body) + end + + # Get a webhook, with a token. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/webhook#get-webhook-with-token). + def get_webhook(webhook_id : UInt64, token : String) + response = request( + :webhooks_wid, + webhook_id, + "GET", + "/webhooks/#{webhook_id}/#{token}", + HTTP::Headers.new, + nil + ) + Webhook.from_json(response.body) + end + + # Get an array of guild webhooks. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/webhook#get-guild-webhooks). + def get_guild_webhooks(guild_id : UInt64) + response = request( + :guilds_gid_webhooks, + guild_id, + "GET", + "/guilds/#{guild_id}/webhooks", + HTTP::Headers.new, + nil + ) + Array(Webhook).from_json(response.body) + end + + # Create a channel webhook. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/webhook#create-webhook). + def create_channel_webhook(channel_id : UInt64, name : String, + avatar : String) + json = { + name: name, + avatar: avatar, + }.to_json + + response = request( + :channels_cid_webhooks, + channel_id, + "POST", + "/channels/#{channel_id}/webhooks", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + Webhook.from_json(response.body) + end + + # Get an array of channel webhooks. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/webhook#get-channel-webhooks). + def get_channel_webhooks(channel_id : UInt64) + response = request( + :channels_cid_webhooks, + channel_id, + "GET", + "/channels/#{channel_id}/webhooks", + HTTP::Headers.new, + nil + ) + + Array(Webhook).from_json(response.body) + end + + # Modify a webhook. Accepts optional parameters `name`, `avatar`, and `channel_id`. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/webhook#modify-webhook). + def modify_webhook(webhook_id : UInt64, name : String? = nil, avatar : String? = nil, + channel_id : UInt64? = nil) + json = encode_tuple( + name: name, + avatar: avatar, + channel_id: channel_id + ) + + response = request( + :webhooks_wid, + webhook_id, + "PATCH", + "/webhooks/#{webhook_id}", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + Webhook.from_json(response.body) + end + + # Modify a webhook, with a token. Accepts optional parameters `name`, `avatar`, and `channel_id`. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/webhook#modify-webhook-with-token). + def modify_webhook_with_token(webhook_id : UInt64, token : String, name : String? = nil, + avatar : String? = nil) + json = encode_tuple( + name: name, + avatar: avatar + ) + + response = request( + :webhooks_wid, + webhook_id, + "PATCH", + "/webhooks/#{webhook_id}/#{token}", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + Webhook.from_json(response.body) + end + + # Deletes a webhook. User must be owner. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/webhook#delete-webhook) + def delete_webhook(webhook_id : UInt64) + request( + :webhooks_wid, + webhook_id, + "DELETE", + "/webhooks/#{webhook_id}", + HTTP::Headers.new, + nil + ) + end + + # Deletes a webhook with a token. Does not require authentication. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/webhook#delete-webhook-with-token) + def delete_webhook(webhook_id : UInt64, token : String) + request( + :webhooks_wid, + webhook_id, + "DELETE", + "/webhooks/#{webhook_id}/#{token}", + HTTP::Headers.new, + nil + ) + end + + # Executes a webhook, with a token. + # + # [API docs for this method](https://discordapp.com/developers/docs/resources/webhook#execute-webhook) + def execute_webhook(webhook_id : UInt64, token : String, content : String? = nil, + file : String? = nil, embeds : Array(Embed)? = nil, + tts : Bool? = nil, avatar_url : String? = nil, + username : String? = nil, wait : Bool? = false) + json = encode_tuple( + content: content, + file: file, + embeds: embeds, + tts: tts, + avatar_url: avatar_url, + username: username + ) + + response = request( + :webhooks_wid, + webhook_id, + "POST", + "/webhooks/#{webhook_id}/#{token}?wait=#{wait}", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + # Expecting response + Message.from_json(response.body) if wait + end + end +end diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/sodium.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/sodium.cr new file mode 100644 index 0000000..ee33795 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/sodium.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/version.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/version.cr new file mode 100644 index 0000000..61777bd --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/version.cr @@ -0,0 +1,3 @@ +module Discord + VERSION = "0.3.0" +end diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/voice.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/voice.cr new file mode 100644 index 0000000..f375408 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/voice.cr @@ -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 diff --git a/Crystal/First-Program/lib/discordcr/src/discordcr/websocket.cr b/Crystal/First-Program/lib/discordcr/src/discordcr/websocket.cr new file mode 100644 index 0000000..11b4b93 --- /dev/null +++ b/Crystal/First-Program/lib/discordcr/src/discordcr/websocket.cr @@ -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 diff --git a/Crystal/First-Program/main.cr b/Crystal/First-Program/main.cr index fd40910..a26e736 100644 --- a/Crystal/First-Program/main.cr +++ b/Crystal/First-Program/main.cr @@ -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() \ No newline at end of file diff --git a/Crystal/First-Program/shard.lock b/Crystal/First-Program/shard.lock new file mode 100644 index 0000000..60bfe17 --- /dev/null +++ b/Crystal/First-Program/shard.lock @@ -0,0 +1,6 @@ +version: 1.0 +shards: + discordcr: + github: z64/discordcr + commit: 5c722de5c25a6020466e80bc10d7904a0e30e7d5 + diff --git a/Crystal/First-Program/shard.yml b/Crystal/First-Program/shard.yml new file mode 100644 index 0000000..a6ea78d --- /dev/null +++ b/Crystal/First-Program/shard.yml @@ -0,0 +1,19 @@ +name: First-Program +version: 0.1.0 + +# authors: +# - name + +# description: | +# Short description of First-Program + +dependencies: + discordcr: + github: z64/discordcr + branch: crystal-0.25 + +# development_dependencies: +# webmock: +# github: manastech/webmock.cr + +license: MIT