commit 60773bde4deb9c9e35e405cf484e0ca8213562c3 Author: Elisiei Yehorov Date: Sun Apr 19 17:27:33 2026 +0200 chore: bot diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87b5c3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.direnv +.env + diff --git a/dictionary.json b/dictionary.json new file mode 100644 index 0000000..384a404 --- /dev/null +++ b/dictionary.json @@ -0,0 +1 @@ +{"maricon":[{"id":"6e0fb443-07cb-4bee-b75a-f2db3bab8138","definition":"julandrón","author":"ulysses_ck","upvotes":0,"downvotes":0},{"id":"88d75fd7-0ff2-459e-b725-bde0b8ae566f","definition":"julay","author":"ulysses_ck","upvotes":0,"downvotes":0}],"wachin":[{"id":"314004df-148b-4b16-ac8c-3cb8d9f2fb90","definition":"un wachin en toda regla","author":"binladenjunior","upvotes":3,"downvotes":0},{"id":"a3c3b7a9-358c-4df3-ac00-b576cfda3db7","definition":"un nene","author":"binladenjunior","upvotes":0,"downvotes":0}],"pilcherio":[{"id":"d511726c-f162-4f5b-8498-a5110d421cc8","definition":"nicherio pero de pilchas","author":"ulysses_ck","upvotes":0,"downvotes":0}],"mar":[{"id":"f4ec0fd9-1bb1-4b48-8ada-793e9d94ea72","definition":"agua","author":"ulysses_ck","upvotes":0,"downvotes":0}],"xd":[{"id":"7c96064a-44fe-4657-935f-fb6f2bc479b3","definition":"xd","author":"binladenjunior","upvotes":0,"downvotes":0}],"duvet":[{"id":"30adafad-1ef8-40ea-9ad8-c842f799647e","definition":"discord 2","author":"binladenjunior","upvotes":0,"downvotes":0},{"id":"55df677d-90d1-4109-a01a-f8fab9a73550","definition":"discord pero peor","author":"binladenjunior","upvotes":0,"downvotes":0}],"g4":[{"id":"c31996c8-49b5-4bc2-87e3-78c500a80a2f","definition":"g4lvatron","author":"binladenjunior","upvotes":0,"downvotes":0}],"colombia":[{"id":"9514d7fb-d2ac-418c-b9f3-6a3efde6f431","definition":"negros","author":"ulysses_ck","upvotes":0,"downvotes":0}],"\"a":[{"id":"bdfac7ec-8055-4cf6-a7d1-6f7280296c50","definition":"pelo\" a peluche","author":"ulysses_ck","upvotes":0,"downvotes":0}],"eh?":[{"id":"d5ee1ef4-bcce-4289-aed8-c916a26dc60f","definition":"jajaja","author":"binladenjunior","upvotes":0,"downvotes":0}],"jajaja":[{"id":"4e69173f-d3b8-4a5b-98e3-b651c4e9e68b","definition":"eh?","author":"ulysses_ck","upvotes":0,"downvotes":0}],"under":[{"id":"6e44fe4d-b235-4d71-87be-79e40eb942da","definition":"wachin q no lo juna nadie","author":"ulysses_ck","upvotes":0,"downvotes":0}],"berretin":[{"id":"3363b314-f230-4e8d-8011-91087cff53c1","definition":"dícese del beef, mayormente entre estrellas del under que curten el mambo piola tranki.","author":"binladenjunior","upvotes":0,"downvotes":0}],"curtir":[{"id":"9944db6e-4265-4211-9f72-ca0389492277","definition":"tener calle","author":"ulysses_ck","upvotes":0,"downvotes":0}],"mericrisma":[{"id":"d68aff54-5011-45a6-8ad2-eca966d45cdb","definition":"estrella del under cocainómana","author":"binladenjunior","upvotes":0,"downvotes":0}],"rustaceo":[{"id":"a663d9fc-d2af-435f-ba96-e44ea65a10e4","definition":"trolo","author":"ulysses_ck","upvotes":0,"downvotes":0},{"id":"f6ec1b89-2db4-4a9b-8d3b-524a678d410a","definition":"persona de bajo coeficiente intelectual, su nombre se debe a su relación con el \"lenguaje\" de programación, Rust","author":"binladenjunior","upvotes":0,"downvotes":0},{"id":"e38311fb-36f9-4d4c-86b2-2104ccb615f2","definition":"un wachin, mayormente tranny, q programa en rust(lang)","author":"ulysses_ck","upvotes":0,"downvotes":0}],"gopher":[{"id":"39f2665e-9c84-43ac-a3f2-4d7044910a5b","definition":"genio","author":"ulysses_ck","upvotes":0,"downvotes":0}],"tranny":[{"id":"dc5a9c90-7971-418e-b36a-e476657f1f1e","definition":"forma despectiva de decir transexual (inglés)","author":"binladenjunior","upvotes":0,"downvotes":0}],"coletoide":[{"id":"198a188b-e949-4bb5-90dd-2d0f352f594f","definition":"coletas pero con más flama","author":"ulysses_ck","upvotes":0,"downvotes":0}],"bodrio":[{"id":"1b87a991-7a93-4c52-afef-6412b0647037","definition":"que es aburrido","author":"ulysses_ck","upvotes":0,"downvotes":0}],"podrio":[{"id":"7a4f25a6-5fea-46f3-b3b6-55341bd5324f","definition":"bodrio pero con p","author":"ulysses_ck","upvotes":0,"downvotes":0}],"virgo":[{"id":"2bf26bdd-4c84-4feb-9f0e-5d9e1d0ad4d7","definition":"dícese de un individuo cuyas actitudes compadecen a alguien con escasa actividad sexual y/o afecto femenino","author":"ulysses_ck","upvotes":0,"downvotes":0},{"id":"630ee4b2-f628-4a09-b0d0-7d2347901870","definition":"virgen","author":"ulysses_ck","upvotes":0,"downvotes":0}],"nicheword":[{"id":"397338d9-ae63-45fe-89c3-a18d5627b8b5","definition":"palabras del nicherio","author":"ulysses_ck","upvotes":0,"downvotes":0}],"smokedope2016":[{"id":"a995e0bf-80ab-439d-8c1e-ddaa21278891","definition":"dios del under","author":"binladenjunior","upvotes":0,"downvotes":0}],"milo":[{"id":"a58d7df3-ab8b-4e4d-9db3-96a5202075cb","definition":"j marronazo j","author":"ulysses_ck","upvotes":0,"downvotes":0}],"junar":[{"id":"23afd20d-1c76-4374-8e7b-51846b59eaa7","definition":"conocer a alguien","author":"binladenjunior","upvotes":0,"downvotes":0}],"marron":[{"id":"783a9059-fa24-4273-9898-7dfd926b047a","definition":"persona de color lodo","author":"ulysses_ck","upvotes":0,"downvotes":0}],"hoe":[{"id":"55f079b3-172c-4751-82cb-616bd3d6a008","definition":"una hoe, una perra","author":"binladenjunior","upvotes":0,"downvotes":0}],"sidehoe":[{"id":"a57d59f4-9eca-413c-9091-153a171d37c5","definition":"el plan b","author":"ulysses_ck","upvotes":0,"downvotes":0}],"chusqui":[{"id":"f769842d-2b87-423b-8023-28fbc9a71e91","definition":"situationship fallida de elisiei, también llamado \"chuski\", \"alvaro\" o \"payaso\" a solas","author":"binladenjunior","upvotes":0,"downvotes":0}],"chusquear":[{"id":"0cb22574-306c-4960-96f8-60ecabe76391","definition":"coito","author":"ulysses_ck","upvotes":0,"downvotes":0},{"id":"1d1b546f-69f0-49ce-aadd-4c9b2bd2d471","definition":"verbo, eufemismo para \"follar\" creado por el chusqui","author":"binladenjunior","upvotes":0,"downvotes":0}],"virgen":[{"id":"098afdd2-e104-4297-b11b-3291b6265e31","definition":"persona que nunca ha hecho chusqui chusqui","author":"binladenjunior","upvotes":0,"downvotes":0}],"govir":[{"id":"b91b0388-855a-4d73-b646-18cec0165c7c","definition":"virgo pero con las silabas giradas","author":"binladenjunior","upvotes":0,"downvotes":0}],"dobolu":[{"id":"82e38572-596f-4741-ac27-e14a7eac6852","definition":"boludo pero dado vuelta","author":"ulysses_ck","upvotes":0,"downvotes":0}],"ungo":[{"id":"f2352354-601f-4b83-83d0-75706d836156","definition":"❤️","author":"binladenjunior","upvotes":1,"downvotes":0}]} \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..c74234b --- /dev/null +++ b/flake.lock @@ -0,0 +1,25 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1772773019, + "narHash": "sha256-E1bxHxNKfDoQUuvriG71+f+s/NT0qWkImXsYZNFFfCs=", + "rev": "aca4d95fce4914b3892661bcb80b8087293536c6", + "revCount": 958961, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.958961%2Brev-aca4d95fce4914b3892661bcb80b8087293536c6/019cc7ad-65c5-7d4e-9860-842d09d8f4fa/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/NixOS/nixpkgs/0.1" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1cb72d7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,32 @@ +{ + description = "minimal flake for crystal dev"; + + inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1"; + + outputs = inputs: let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forEachSupportedSystem = f: + inputs.nixpkgs.lib.genAttrs supportedSystems ( + system: + f { + pkgs = import inputs.nixpkgs {inherit system;}; + } + ); + in { + devShells = forEachSupportedSystem ( + {pkgs}: { + default = pkgs.mkShell { + packages = with pkgs; [ + crystal + shards + ]; + }; + } + ); + }; +} diff --git a/lib/.shards.info b/lib/.shards.info new file mode 100644 index 0000000..708aed9 --- /dev/null +++ b/lib/.shards.info @@ -0,0 +1,6 @@ +--- +version: 1.0 +shards: + discordcr: + git: https://github.com/shardlab/discordcr.git + version: 0.4.0+git.commit.0e03deb8ffa247814f2fec4e197cba7a62534f85 diff --git a/lib/discordcr/.github/workflows/build_examples.yml b/lib/discordcr/.github/workflows/build_examples.yml new file mode 100644 index 0000000..ac6e09c --- /dev/null +++ b/lib/discordcr/.github/workflows/build_examples.yml @@ -0,0 +1,32 @@ +name: Build + +on: + push: + branches: + - master + tags: + - v* + paths-ignore: + - "**.md" + pull_request: + branches: + - master + paths-ignore: + - "**.md" +jobs: + deploy: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - uses: MeilCli/setup-crystal-action@v4 + with: + crystal_version: 1.2.1 + shards_version: 0.16.0 + - name: Install dependencies + run: shards install + - name: Run tests + run: crystal spec + - name: Run crystal tool format + run: crystal tool format --check + - name: Build examples + run: find examples -name "*.cr" | xargs -L 1 crystal build --no-codegen diff --git a/lib/discordcr/.github/workflows/deploy_docs.yml b/lib/discordcr/.github/workflows/deploy_docs.yml new file mode 100644 index 0000000..7523aae --- /dev/null +++ b/lib/discordcr/.github/workflows/deploy_docs.yml @@ -0,0 +1,34 @@ +name: Deploy Docs + +on: + push: + branches: + - master + tags: + - v* + paths-ignore: + - "CHANGELOG.md" +jobs: + deploy: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - uses: MeilCli/setup-crystal-action@v4 + with: + crystal_version: 1.2.1 + shards_version: 0.16.0 + - name: Install dependencies + run: shards install + - name: Run crystal doc + run: crystal doc + - name: Extract branch name + shell: bash + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + id: extract_branch + - name: Deploy to gh-pages + uses: peaceiris/actions-gh-pages@v3.7.0-8 + with: + personal_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: docs + destination_dir: ${{ steps.extract_branch.outputs.branch }} + diff --git a/lib/discordcr/.gitignore b/lib/discordcr/.gitignore new file mode 100644 index 0000000..c32016b --- /dev/null +++ b/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/lib/discordcr/.travis.yml b/lib/discordcr/.travis.yml new file mode 100644 index 0000000..0c8223e --- /dev/null +++ b/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/lib/discordcr/LICENSE b/lib/discordcr/LICENSE new file mode 100644 index 0000000..e67534e --- /dev/null +++ b/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/lib/discordcr/README.md b/lib/discordcr/README.md new file mode 100644 index 0000000..c4b6511 --- /dev/null +++ b/lib/discordcr/README.md @@ -0,0 +1,101 @@ +[![docs](https://img.shields.io/badge/docs-v0.4.0-green.svg?style=flat-square)](https://dcr.shardlab.dev/v0.4.0/) [![docs](https://img.shields.io/badge/docs-master-red.svg?style=flat-square)](https://dcr.shardlab.dev/master/) + +### Important Notice +This is the **new official source of discordcr**!\ +The [old repo](https://github.com/discordcr/discordcr) has not been updated for countless months +and new features, along with library breaking changes, are coming fast.\ +There is no guarantee any code/updates will be pushed to the old repo again, so as it stands,\ +this will be where all new code will be pushed and where all new PRs and Issues should be created. + +Thanks! + +# discordcr + +(The "cr" stands for "creative name".) + +discordcr is a minimalist [Discord](https://discord.com/) API library for +[Crystal](https://crystal-lang.org/), designed to be a complement to +[discordrb](https://github.com/shardlab/discordrb) for users who want more control +and performance and who care less about ease-of-use. + +discordcr isn't designed for beginners to the Discord API - while experience +with making bots isn't *required*, it's certainly recommended. If you feel +overwhelmed by the complex documentation, try +[discordrb](https://github.com/shardlab/discordrb) first and then check back. + +Unlike many other libs which handle a lot of stuff, like caching or resolving, +themselves automatically, discordcr requires the user to do such things +manually. It also doesn't provide any advanced abstractions for REST calls; +the methods perform the HTTP request with the given data but nothing else. +This means that the user has full control over them, but also full +responsibility. discordcr does not support user accounts; it may work but +likely doesn't. + +## Installation + +Add this to your application's `shard.yml`: + +```yaml +dependencies: + discordcr: + github: shardlab/discordcr +``` + +## Usage + +An example bot can be found +[here](https://github.com/shardlab/discordcr/blob/master/examples/ping.cr). More +examples will come in the future. + +A short overview of library structure: the `Client` class includes the `REST` +module, which handles the REST parts of Discord's API; the `Client` itself +handles the gateway, i. e. the interactive parts such as receiving messages. It +is possible to use only the REST parts by never calling the `#run` method on a +`Client`, which is what does the actual gateway connection. + +The example linked above has an example of an event (`on_message_create`) that +is called through the gateway, and of a REST call (`client.create_message`). +Other gateway events and REST calls work much in the same way - see the +documentation for what specific events and REST calls do. + +Caching is done using a separate `Cache` class that needs to be added into +clients manually: + +```cr +client = Discord::Client.new # ... +cache = Discord::Cache.new(client) +client.cache = cache +``` + +Resolution requests for objects can now be done on the `cache` object instead of +directly over REST, this ensures that if an object is needed more than once +there will still only be one request to Discord. (There may even be no request +at all, if the requested data has already been obtained over the gateway.) +An example of how to use the cache once it has been instantiated: + +```cr +# Get the username of the user with ID 66237334693085184 +user = cache.resolve_user(66237334693085184_u64) +user = cache.resolve_user(66237334693085184_u64) # won't do a request to Discord +puts user.username +``` + +Apart from this, API documentation is also available, at + +https://dcr.shardlab.dev/v0.4.0 for v0.4.0 + +https://dcr.shardlab.dev/master for latest + +## Contributing + +1. Fork it (https://github.com/shardlab/discordcr/fork) +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request + +## Contributors + +- [meew0](https://github.com/meew0) - creator, maintainer +- [RX14](https://github.com/RX14) - Crystal expert, maintainer +- [PixeL](https://github.com/PixelInc) - Maintainer diff --git a/lib/discordcr/deploy.sh b/lib/discordcr/deploy.sh new file mode 100644 index 0000000..d9b8cdc --- /dev/null +++ b/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/lib/discordcr/deploy_key.enc b/lib/discordcr/deploy_key.enc new file mode 100644 index 0000000..edbd451 Binary files /dev/null and b/lib/discordcr/deploy_key.enc differ diff --git a/lib/discordcr/examples/mention_parser.cr b/lib/discordcr/examples/mention_parser.cr new file mode 100644 index 0000000..ad0a7e9 --- /dev/null +++ b/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/lib/discordcr/examples/multicommand.cr b/lib/discordcr/examples/multicommand.cr new file mode 100644 index 0000000..50b4332 --- /dev/null +++ b/lib/discordcr/examples/multicommand.cr @@ -0,0 +1,37 @@ +# multicommand.cr is an example that uses a simple command "dispatcher" +# via a case statement. +# This example features a few commands: +# » !help ==> sends a dm (direct message) to the user +# with information +# » !about ==> prints about information in a code block +# » !echo ==> echos args +# » !date ==> prints the current date + +require "../src/discordcr" + +# Make sure to replace this fake data with actual data when running. +client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64) + +# Command Prefix +PREFIX = "!" + +client.on_message_create do |payload| + command = payload.content + case command + when PREFIX + "help" + client.create_message(client.create_dm(payload.author.id).id, "Help is on the way!") + when PREFIX + "about" + block = "```\nBot developed by discordcr\n```" + client.create_message(payload.channel_id, block) + when .starts_with? PREFIX + "echo" + # !echo is a good example of a command with arguments (suffix) + suffix = command.split(' ')[1..-1].join(" ") + client.create_message(payload.channel_id, suffix) + when PREFIX + "date" + client.create_message(payload.channel_id, Time.utc.to_s("%D")) + else + # Ignore. + end +end + +client.run diff --git a/lib/discordcr/examples/ping.cr b/lib/discordcr/examples/ping.cr new file mode 100644 index 0000000..21b33cf --- /dev/null +++ b/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/lib/discordcr/examples/ping_with_response_time.cr b/lib/discordcr/examples/ping_with_response_time.cr new file mode 100644 index 0000000..ecb11f8 --- /dev/null +++ b/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 - payload.timestamp + client.edit_message(m.channel_id, m.id, "Pong! Time taken: #{time.total_milliseconds} ms.") + end +end + +client.run diff --git a/lib/discordcr/examples/voice_send.cr b/lib/discordcr/examples/voice_send.cr new file mode 100644 index 0000000..0ecd3a9 --- /dev/null +++ b/lib/discordcr/examples/voice_send.cr @@ -0,0 +1,160 @@ +# This is a simple music bot that can connect to a voice channel and play back +# some music in DCA format. It demonstrates how to use VoiceClient and +# DCAParser. +# +# For more information on the DCA file format, see +# https://github.com/bwmarrin/dca. + +require "../src/discordcr" + +# Make sure to replace this fake data with actual data when running. +client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64) +cache = Discord::Cache.new(client) +client.cache = cache + +# ID of the current user, required to create a voice client +current_user_id = nil + +# The ID of the (text) channel in which the connect command was run, so the +# "Voice connected." message is sent to the correct channel +connect_channel_id = nil + +# Where the created voice client will eventually be stored +voice_client = nil + +client.on_ready do |payload| + current_user_id = payload.user.id +end + +client.on_message_create do |payload| + if payload.content.starts_with? "!connect " + # Used as: + # !connect + + # Parse the command arguments + ids = payload.content[9..-1].split(' ').map(&.to_u64) + + client.create_message(payload.channel_id, "Connecting...") + connect_channel_id = payload.channel_id + client.voice_state_update(ids[0].to_u64, ids[1].to_u64, false, false) + elsif payload.content.starts_with? "!vs" + # Used as: + # !vs + + reply = begin + vs = cache.resolve_voice_state(payload.guild_id.not_nil!, payload.author.id) + vc = cache.resolve_channel(vs.channel_id.not_nil!) + + # The voice region will be nil if the channel is set to automatically determine it. + rtc_region = vc.rtc_region || "Automatic" + + "You are connected to channel #{vs.channel_id} (region: #{rtc_region}) at guild #{vs.guild_id}" + rescue + "No voice state" + end + client.create_message(payload.channel_id, reply) + elsif payload.content.starts_with? "!vr" + # Used as: + # !vr [guild ID] + + regions : Array(Discord::VoiceRegion) = if payload.content.size > 4 + id = payload.content[4..-1] + client.get_guild_voice_regions(id.to_u64) + else + client.list_voice_regions + end + + client.create_message(payload.channel_id, "Voice Regions: #{regions.map(&.name).join(", ")}") + elsif payload.content.starts_with? "!play_dca " + # Used as: + # !play_dca + # + # 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/lib/discordcr/examples/welcome.cr b/lib/discordcr/examples/welcome.cr new file mode 100644 index 0000000..005ffbf --- /dev/null +++ b/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/lib/discordcr/lib b/lib/discordcr/lib new file mode 120000 index 0000000..a96aa0e --- /dev/null +++ b/lib/discordcr/lib @@ -0,0 +1 @@ +.. \ No newline at end of file diff --git a/lib/discordcr/shard.yml b/lib/discordcr/shard.yml new file mode 100644 index 0000000..ec41cff --- /dev/null +++ b/lib/discordcr/shard.yml @@ -0,0 +1,10 @@ +name: discordcr +version: 0.4.0 +crystal: 1.0.0 + +authors: + - meew0 + - Chris Hobbs (RX14) + - z64 + +license: MIT diff --git a/lib/discordcr/spec/cdn_spec.cr b/lib/discordcr/spec/cdn_spec.cr new file mode 100644 index 0000000..4802330 --- /dev/null +++ b/lib/discordcr/spec/cdn_spec.cr @@ -0,0 +1,216 @@ +require "./spec_helper" + +describe Discord::CDN do + it "builds a custom emoji URL" do + url = Discord::CDN.custom_emoji(1, :png, 16) + url.should eq "https://cdn.discordapp.com/emojis/1.png?size=16" + end + + it "builds a guild icon URL" do + url = Discord::CDN.guild_icon(1, "hash", :png, 16) + url.should eq "https://cdn.discordapp.com/icons/1/hash.png?size=16" + end + + it "builds a guild splash URL" do + url = Discord::CDN.guild_splash(1, "hash", :png, 16) + url.should eq "https://cdn.discordapp.com/splashes/1/hash.png?size=16" + end + + it "builds a default user avatar URL" do + url = Discord::CDN.default_user_avatar("0001") + url.should eq "https://cdn.discordapp.com/embed/avatars/1.png" + + url = Discord::CDN.default_user_avatar("0007") + url.should eq "https://cdn.discordapp.com/embed/avatars/2.png" + end + + describe "user_avatar" do + it "builds a user avatar URL" do + url = Discord::CDN.user_avatar(1, "hash", :png, 16) + url.should eq "https://cdn.discordapp.com/avatars/1/hash.png?size=16" + end + + context "without format" do + it "detects an animated avatar" do + url = Discord::CDN.user_avatar(1_u64, "a_hash", 16) + url.should eq "https://cdn.discordapp.com/avatars/1/a_hash.gif?size=16" + end + + it "defaults to webp" do + url = Discord::CDN.user_avatar(1_u64, "hash", 16) + url.should eq "https://cdn.discordapp.com/avatars/1/hash.webp?size=16" + end + end + end + + it "builds an application icon URL" do + url = Discord::CDN.application_icon(1, "hash", :png, 16) + url.should eq "https://cdn.discordapp.com/app-icons/1/hash.png?size=16" + end + + it "builds an application asset URL" do + url = Discord::CDN.application_asset(1, 2, :png, 16) + url.should eq "https://cdn.discordapp.com/app-assets/1/2.png?size=16" + end + + it "raises on an invalid size" do + expect_raises(ArgumentError, "Size 17 is not between 16 and 2048 and a power of 2") do + Discord::CDN.custom_emoji(1, :png, 17) + end + + expect_raises(ArgumentError, "Size 0 is not between 16 and 2048 and a power of 2") do + Discord::CDN.custom_emoji(1, :png, 0) + end + end +end + +describe Discord::User do + user_with_default_avatar = Discord::User.from_json <<-JSON + { + "id": "1", + "username": "foo", + "avatar": null, + "discriminator": "0007" + } + JSON + + user_with_avatar = Discord::User.from_json <<-JSON + { + "id": "1", + "username": "foo", + "avatar": "hash", + "discriminator": "0007" + } + JSON + + user_with_animated_avatar = Discord::User.from_json <<-JSON + { + "id": "1", + "username": "foo", + "avatar": "a_hash", + "discriminator": "0007" + } + JSON + + describe "#avatar_url" do + it "returns avatar URL with the given format and size" do + user = user_with_avatar + user.avatar_url(:png, 16).should eq Discord::CDN.user_avatar(user.id, user.avatar.not_nil!, :png, 16) + end + + it "returns default avatar URL with the given format and size" do + user = user_with_default_avatar + user.avatar_url(:png, 16).should eq Discord::CDN.default_user_avatar(user.discriminator) + end + + context "without format" do + it "returns default avatar URL" do + user = user_with_default_avatar + user.avatar_url.should eq Discord::CDN.default_user_avatar(user.discriminator) + end + + it "returns avatar URL" do + user = user_with_avatar + user.avatar_url.should eq Discord::CDN.user_avatar(user.id, user.avatar.not_nil!) + end + + it "returns animated avatar URL" do + user = user_with_animated_avatar + user.avatar_url.should eq Discord::CDN.user_avatar(user.id, user.avatar.not_nil!) + end + end + end +end + +describe Discord::Guild do + guild_with_icon_and_splash = Discord::Guild.from_json <<-JSON + { + "id": "1", + "name": "name", + "icon": "hash", + "splash": "hash", + "owner_id": "2", + "region": "region", + "verification_level": 1, + "roles": [], + "emojis": [], + "features": [], + "default_message_notifications": 1, + "explicit_content_filter": 1, + "premium_tier": 0 + } + JSON + + it "#icon_url" do + guild = guild_with_icon_and_splash + guild.icon_url(:png, 16).should eq Discord::CDN.guild_icon(guild.id, guild.icon.not_nil!, :png, 16) + end + + it "#splash_url" do + guild = guild_with_icon_and_splash + guild.splash_url(:png, 16).should eq Discord::CDN.guild_splash(guild.id, guild.splash.not_nil!, :png, 16) + end +end + +describe Discord::Emoji do + emoji = Discord::Emoji.from_json <<-JSON + { + "id": "1", + "name": "name", + "roles": [], + "require_colons": true, + "managed": false, + "animated": false + } + JSON + + animated_emoji = Discord::Emoji.from_json <<-JSON + { + "id": "1", + "name": "name", + "roles": [], + "require_colons": true, + "managed": false, + "animated": true + } + JSON + + describe "#image_url" do + it "returns an image URL with given format and size" do + emoji.image_url(:png, 16).should eq Discord::CDN.custom_emoji(emoji.id.not_nil!, :png, 16) + end + + context "without format" do + it "returns a webp, or gif if animated" do + emoji.image_url.should eq Discord::CDN.custom_emoji(emoji.id.not_nil!, :png, 128) + animated_emoji.image_url.should eq Discord::CDN.custom_emoji(animated_emoji.id.not_nil!, :gif, 128) + end + end + end +end + +describe Discord::OAuth2Application do + describe "#icon_url" do + application_with_icon = Discord::OAuth2Application.from_json <<-JSON + { + "id": "1", + "name": "name", + "icon": "hash", + "bot_public": true, + "bot_require_code_grant": false, + "owner": { + "id": "1", + "username": "username", + "discriminator": "0001" + }, + "summary": "some summary", + "verify_key": "key" + } + JSON + + it "returns a CDN URL with the given format and size" do + application = application_with_icon + application.icon_url(:png, 16).should eq Discord::CDN.application_icon(application.id, application.icon.not_nil!, :png, 16) + end + end +end diff --git a/lib/discordcr/spec/discordcr_spec.cr b/lib/discordcr/spec/discordcr_spec.cr new file mode 100644 index 0000000..6139f6e --- /dev/null +++ b/lib/discordcr/spec/discordcr_spec.cr @@ -0,0 +1,139 @@ +require "yaml" +require "./spec_helper" + +struct StructWithTime + include JSON::Serializable + + @[JSON::Field(converter: Discord::TimestampConverter)] + property data : Time +end + +struct StructWithMaybeTime + include JSON::Serializable + + @[JSON::Field(converter: Discord::MaybeTimestampConverter, emit_null: true)] + property data : Time? +end + +describe Discord do + describe "VERSION" do + it "matches shards.yml" do + version = YAML.parse(File.read(File.join(__DIR__, "..", "shard.yml")))["version"].as_s + version.should eq(Discord::VERSION) + end + end + + describe Discord::TimestampConverter do + it "parses a time with floating point accuracy" do + json = %({"data":"2017-11-16T13:09:18.291000+00:00"}) + + obj = StructWithTime.from_json(json) + obj.data.should be_a Time + end + + it "parses a time without floating point accuracy" do + json = %({"data":"2017-11-15T02:23:35+00:00"}) + + obj = StructWithTime.from_json(json) + obj.data.should be_a Time + end + + it "serializes" do + json = %({"data":"2017-11-16T13:09:18.291000+00:00"}) + obj = StructWithTime.from_json(json) + obj.to_json.should eq json + end + + it "raises on null" do + json = %({"data":null}) + expect_raises(JSON::ParseException) do + StructWithTime.from_json(json) + end + end + end + + describe Discord::MaybeTimestampConverter do + it "parses a time" do + json = %({"data":"2017-11-16T13:09:18.291000+00:00"}) + StructWithMaybeTime.from_json(json).data.should be_a Time + end + + it "parses null" do + json = %({"data":null}) + StructWithMaybeTime.from_json(json).data.should be_nil + end + + it "serializes a time" do + json = %({"data":"2017-11-16T13:09:18.291000+00:00"}) + obj = StructWithMaybeTime.from_json(json) + obj.to_json.should eq json + end + + it "serializes null" do + json = %({"data":null}) + obj = StructWithMaybeTime.from_json(json) + obj.to_json.should eq json + end + end + + describe Discord::REST::ModifyChannelPositionPayload do + describe "#to_json" do + context "parent_id is ChannelParent::Unchanged" do + it "doesn't emit parent_id" do + payload = {Discord::REST::ModifyChannelPositionPayload.new(0_u64, 0, Discord::REST::ChannelParent::Unchanged, true)} + payload.to_json.should eq %([{"id":"0","position":0,"lock_permissions":true}]) + end + end + + context "parent_id is ChannelParent::None" do + it "emits null for parent_id" do + payload = {Discord::REST::ModifyChannelPositionPayload.new(0_u64, 0, Discord::REST::ChannelParent::None, true)} + payload.to_json.should eq %([{"id":"0","position":0,"parent_id":null,"lock_permissions":true}]) + end + end + end + end + + describe Discord::WebSocket::Packet do + it "inspects" do + packet = Discord::WebSocket::Packet.new(0_i64, 1_i64, IO::Memory.new("foo"), "test") + packet.inspect.should eq %(Discord::WebSocket::Packet(@opcode=0 @sequence=1 @data="foo" @event_type="test")) + end + + it "serializes" do + json = %({"op":0,"s":1,"d":"foo","t":"test"}) + packet = Discord::WebSocket::Packet.new(0_i64, 1_i64, IO::Memory.new(%("foo")), "test") + packet.to_json.should eq json + end + + it "parses" do + json = %({"op":0,"s":1,"d":"foo","t":"test"}) + packet = Discord::WebSocket::Packet.from_json(json) + packet.opcode.should eq 0 + packet.sequence.should eq 1 + packet.data.to_s.should eq %("foo") + packet.event_type.should eq "test" + end + end + + describe Discord::TimeSpanMillisecondsConverter do + it ".from_json" do + parser = JSON::PullParser.new("300") + span = Discord::TimeSpanMillisecondsConverter.from_json(parser) + span.should eq 300.milliseconds + end + + it ".to_json" do + json = JSON.build do |builder| + Discord::TimeSpanMillisecondsConverter.to_json(300.milliseconds, builder) + end + json.should eq "300" + end + end + + it ".shard_id" do + part = 3_u64 << 22 + shard = Discord.shard_id(part, 2) + shard.should eq 1 + end +end diff --git a/lib/discordcr/spec/mention_spec.cr b/lib/discordcr/spec/mention_spec.cr new file mode 100644 index 0000000..3370d29 --- /dev/null +++ b/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, 7)]) + + 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/lib/discordcr/spec/paginator_spec.cr b/lib/discordcr/spec/paginator_spec.cr new file mode 100644 index 0000000..28a9e60 --- /dev/null +++ b/lib/discordcr/spec/paginator_spec.cr @@ -0,0 +1,63 @@ +require "./spec_helper" + +describe Discord::Paginator do + context "direction up" do + it "requests all pages until empty" do + data = { + [1, 2, 3], + [4, 5], + [] of Int32, + [6, 7], + } + + index = 0 + paginator = Discord::Paginator(Int32).new(nil, :down) do |last_page| + if last_page + last_page.should eq data[index - 1] + end + index += 1 + data[index - 1] + end + + paginator.to_a.should eq [1, 2, 3, 4, 5] + end + end + + context "direction down" do + it "requests all pages until empty" do + data = { + [6, 7], + [4, 5], + [] of Int32, + [1, 2, 3], + } + + index = 0 + paginator = Discord::Paginator(Int32).new(nil, :up) do |last_page| + if last_page + last_page.should eq data[index - 1] + end + index += 1 + data[index - 1] + end + + paginator.to_a.should eq [7, 6, 5, 4] + end + end + + it "only returns up to limit items" do + data = { + [1, 2, 3], + [4, 5], + [] of Int32, + } + + index = 0 + paginator = Discord::Paginator(Int32).new(2, :down) do |last_page| + index += 1 + data[index - 1] + end + + paginator.to_a.should eq [1, 2] + end +end diff --git a/lib/discordcr/spec/rest_spec.cr b/lib/discordcr/spec/rest_spec.cr new file mode 100644 index 0000000..72f0a77 --- /dev/null +++ b/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/lib/discordcr/spec/snowflake_spec.cr b/lib/discordcr/spec/snowflake_spec.cr new file mode 100644 index 0000000..3cbb458 --- /dev/null +++ b/lib/discordcr/spec/snowflake_spec.cr @@ -0,0 +1,57 @@ +require "./spec_helper" + +describe Discord::Snowflake do + describe Discord::DISCORD_EPOCH do + it "is 2015-01-01" do + expected = Time.utc(2015, 1, 1) + Discord::DISCORD_EPOCH.should eq expected.to_unix_ms + end + end + + it "#to_json" do + snowflake = Discord::Snowflake.new(0_u64) + json = JSON.build do |builder| + snowflake.to_json(builder) + end + json.should eq %("0") + end + + it ".from_json" do + parser = JSON::PullParser.new(%("0")) + snowflake = Discord::Snowflake.new(parser) + snowflake.value.should eq 0_u64 + end + + describe Array(Discord::Snowflake) do + it "can be sorted" do + snowflake_a = Discord::Snowflake.new(2_u64) + snowflake_b = Discord::Snowflake.new(1_u64) + snowflake_c = Discord::Snowflake.new(0_u64) + + array = [snowflake_a, snowflake_b, snowflake_c] + array.sort.should eq [snowflake_c, snowflake_b, snowflake_a] + end + end + + describe "#creation_time" do + it "returns the time the snowflake was created" do + time = Time.utc(2018, 4, 18) + snowflake = Discord::Snowflake.new(time) + snowflake.creation_time.should eq time + end + end + + it "compares to uint64" do + snowflake = Discord::Snowflake.new(1_u64) + (snowflake == 1_u64).should be_true + (snowflake == 0_u64).should be_false + end +end + +describe UInt64 do + it "compares to snowflake" do + snowflake = Discord::Snowflake.new(1_u64) + (1_u64 == snowflake).should be_true + (0_u64 == snowflake).should be_false + end +end diff --git a/lib/discordcr/spec/spec_helper.cr b/lib/discordcr/spec/spec_helper.cr new file mode 100644 index 0000000..86ab001 --- /dev/null +++ b/lib/discordcr/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../src/discordcr" diff --git a/lib/discordcr/spec/voice_spec.cr b/lib/discordcr/spec/voice_spec.cr new file mode 100644 index 0000000..7e84b38 --- /dev/null +++ b/lib/discordcr/spec/voice_spec.cr @@ -0,0 +1,48 @@ +require "./spec_helper" + +private def with_voice_udp + server = UDPSocket.new + server.bind("localhost", 0) + port = server.local_address.port + client = Discord::VoiceUDP.new + client.connect("localhost", port.to_u32, 1_u32) + yield server, client + server.close + client.socket.close +end + +describe Discord::VoiceUDP do + it "sends discovery" do + with_voice_udp do |server, client| + client.send_discovery + data = Bytes.new(74) + server.receive(data) + data[4, 4].should eq Bytes[0, 0, 0, 1] + end + end + + it "receives discovery reply" do + with_voice_udp do |server, client| + io = IO::Memory.new + io.write Bytes.new(8) + io.print("ip address".ljust(64, '\0')) + io.write_bytes(2_u16, IO::ByteFormat::BigEndian) + data = io.to_slice + server.send(data, to: client.socket.local_address) + + ip, port = client.receive_discovery_reply + ip.should eq "ip address" + port.should eq 2_u16 + end + end + + it "creates voice header" do + with_voice_udp do |server, client| + data = client.create_header(1_u16, 2_u32) + data[0, 2].should eq Bytes[0x80, 0x78] + data[2, 2].should eq Bytes[0, 1] + data[4, 4].should eq Bytes[0, 0, 0, 2] + data[8, 4].should eq Bytes[0, 0, 0, 1] + end + end +end diff --git a/lib/discordcr/src/discordcr.cr b/lib/discordcr/src/discordcr.cr new file mode 100644 index 0000000..07ec639 --- /dev/null +++ b/lib/discordcr/src/discordcr.cr @@ -0,0 +1,6 @@ +require "log" +require "./discordcr/*" + +module Discord + Log = ::Log.for("discord") +end diff --git a/lib/discordcr/src/discordcr/cache.cr b/lib/discordcr/src/discordcr/cache.cr new file mode 100644 index 0000000..e7360b9 --- /dev/null +++ b/lib/discordcr/src/discordcr/cache.cr @@ -0,0 +1,354 @@ +require "./mappings/*" + +module Discord + # A cache is a utility class that stores various kinds of Discord objects, + # like `User`s, `Role`s etc. Its purpose is to reduce both the load on + # Discord's servers and reduce the latency caused by having to do an API call. + # It is recommended to use caching for bots that interact heavily with + # Discord-provided data, like for example administration bots, as opposed to + # bots that only interact by sending and receiving messages. For that latter + # kind, caching is usually even counter-productive as it only unnecessarily + # increases memory usage. + # + # Caching can either be used standalone, in a purely REST-based way: + # ``` + # client = Discord::Client.new(token: "Bot token", client_id: 123_u64) + # cache = Discord::Cache.new(client) + # + # puts cache.resolve_user(66237334693085184) # will perform API call + # puts cache.resolve_user(66237334693085184) # will not perform an API call, as the data is now cached + # ``` + # + # It can also be integrated more deeply into a `Client` (specifically one that + # uses a gateway connection) to reduce cache misses even more by automatically + # caching data received over the gateway: + # ``` + # client = Discord::Client.new(token: "Bot token", client_id: 123_u64) + # cache = Discord::Cache.new(client) + # client.cache = cache # Integrate the cache into the client + # ``` + # + # Note that if a cache is *not* used this way, its data will slowly go out of + # sync with Discord, and unless it is used in an environment with few changes + # likely to occur, a client without a gateway connection should probably + # refrain from caching at all. + class Cache + # A map of cached users. These aren't necessarily all the users in servers + # the bot has access to, but rather all the users that have been seen by + # the bot in the past (and haven't been deleted by means of `delete_user`). + getter users + + # A map of cached channels, i. e. all channels on all servers the bot is on, + # as well as all DM channels. + getter channels + + # A map of guilds (servers) the bot is on. Doesn't ignore guilds temporarily + # deleted due to an outage; so if an outage is going on right now the + # affected guilds would be missing here too. + getter guilds + + # A map of cached stage instances, i. e. all stage instances on all servers + # the bot is on, represented as {channel ID => Stage instance}. + getter stage_instances + + # A double map of members on servers, represented as {guild ID => {user ID + # => member}}. Will only contain previously and currently online members as + # well as all members that have been chunked (see + # `Client#request_guild_members`). + getter members + + # A map of all roles on servers the bot is on. Does not discriminate by + # guild, as role IDs are unique even across guilds. + getter roles + + # Mapping of users to the respective DM channels the bot has open with them, + # represented as {user ID => channel ID}. + getter dm_channels + + # Mapping of guilds to the roles on them, represented as {guild ID => + # [role IDs]}. + getter guild_roles + + # Mapping of guilds to the channels on them, represented as {guild ID => + # [channel IDs]}. + getter guild_channels + + # Mapping of guilds to the channels with Stage instances on them, represented as {guild ID => + # [channel IDs]}. + getter guild_stage_instances + + # Mapping of users in guild to voice states, represented as {guild ID => + # {user ID => voice state}} + getter voice_states + + # Creates a new cache with a *client* that requests (in case of cache + # misses) should be done on. + def initialize(@client : Client) + @users = Hash(UInt64, User).new + @channels = Hash(UInt64, Channel).new + @guilds = Hash(UInt64, Guild).new + @members = Hash(UInt64, Hash(UInt64, GuildMember)).new + @roles = Hash(UInt64, Role).new + @stage_instances = Hash(UInt64, StageInstance).new + + @dm_channels = Hash(UInt64, UInt64).new + + @guild_roles = Hash(UInt64, Array(UInt64)).new + @guild_channels = Hash(UInt64, Array(UInt64)).new + @guild_stage_instances = Hash(UInt64, Array(UInt64)).new + + @voice_states = Hash(UInt64, Hash(UInt64, VoiceState)).new + end + + # Resolves a user by its *ID*. If the requested object is not cached, it + # will do an API call. + def resolve_user(id : UInt64 | Snowflake) : User + id = id.to_u64 + @users.fetch(id) { @users[id] = @client.get_user(id) } + end + + # Resolves a channel by its *ID*. If the requested object is not cached, it + # will do an API call. + def resolve_channel(id : UInt64 | Snowflake) : Channel + id = id.to_u64 + @channels.fetch(id) { @channels[id] = @client.get_channel(id) } + end + + # Resolves a guild by its *ID*. If the requested object is not cached, it + # will do an API call. + def resolve_guild(id : UInt64 | Snowflake) : Guild + id = id.to_u64 + @guilds.fetch(id) { @guilds[id] = @client.get_guild(id) } + end + + # Resolves a member by the *guild_id* of the guild the member is on, and the + # *user_id* of the member itself. An API request will be performed if the + # object is not cached. + def resolve_member(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake) : GuildMember + guild_id = guild_id.to_u64 + user_id = user_id.to_u64 + local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new + local_members.fetch(user_id) { local_members[user_id] = @client.get_guild_member(guild_id, user_id) } + end + + # Resolves a role by its *ID*. No API request will be performed if the role + # is not cached, because there is no endpoint for individual roles; however + # all roles should be cached at all times so it won't be a problem. + def resolve_role(id : UInt64 | Snowflake) : Role + @roles[id.to_u64] # There is no endpoint for getting an individual role, so we will have to ignore that case for now. + end + + # Resolves a Stage instance by the *channel ID* it is on. + # An API request will be performed if the object is not cached. + def resolve_stage_instance(channel_id : UInt64 | Snowflake) : StageInstance + channel_id = channel_id.to_u64 + @stage_instances.fetch(channel_id) do + stage_instance = @client.get_stage_instance(channel_id) + cache(stage_instance) + add_guild_stage_instance(stage_instance.guild_id, stage_instance.channel_id) + stage_instance + end + end + + # Resolves the ID of a DM channel with a particular user by the recipient's + # *recipient_id*. If there is no such channel cached, one will be created. + def resolve_dm_channel(recipient_id : UInt64 | Snowflake) : UInt64 + recipient_id = recipient_id.to_u64 + @dm_channels.fetch(recipient_id) do + channel = @client.create_dm(recipient_id) + cache(Channel.new(channel)) + @dm_channels[recipient_id] = channel.id.to_u64 + end + end + + # Resolves the current user's profile. Requires no parameters since the + # endpoint has none either. If there is a gateway connection this should + # always be cached. + def resolve_current_user : User + @current_user ||= @client.get_current_user + end + + # Resolves a voice state by *guild ID* and *user ID*. No API request will be + # performed if voice state is not cached, because there is no endpoint for + # it. If there is a gateway connection this should always be cached. + def resolve_voice_state(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake) : VoiceState + @voice_states[guild_id.to_u64][user_id.to_u64] + end + + # Deletes a user from the cache given its *ID*. + def delete_user(id : UInt64 | Snowflake) + @users.delete(id.to_u64) + end + + # Deletes a channel from the cache given its *ID*. + def delete_channel(id : UInt64 | Snowflake) + @channels.delete(id.to_u64) + end + + # Deletes a guild from the cache given its *ID*. + def delete_guild(id : UInt64 | Snowflake) + @guilds.delete(id.to_u64) + end + + # Deletes a stage instance from the cache given the *channel_id* it belongs to. + def delete_stage_instance(channel_id : UInt64 | Snowflake) + @stage_instances.delete(channel_id.to_u64) + end + + # Deletes a member from the cache given its *user_id* and the *guild_id* it + # is on. + def delete_member(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake) + guild_id = guild_id.to_u64 + user_id = user_id.to_u64 + @members[guild_id]?.try &.delete(user_id) + end + + # Deletes a role from the cache given its *ID*. + def delete_role(id : UInt64 | Snowflake) + @roles.delete(id.to_u64) + end + + # Deletes a DM channel with a particular user given the *recipient_id*. + def delete_dm_channel(recipient_id : UInt64 | Snowflake) + @dm_channels.delete(recipient_id.to_u64) + end + + # Deletes the current user from the cache, if that will ever be necessary. + def delete_current_user + @current_user = nil + end + + # Deletes voice state for user in guild from cache. + def delete_voice_state(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake) + guild_id = guild_id.to_u64 + user_id = user_id.to_u64 + @voice_states[guild_id]?.try &.delete(user_id) + end + + # Adds a specific *user* to the cache. + def cache(user : User) + @users[user.id.to_u64] = user + end + + # Adds a specific *channel* to the cache. + def cache(channel : Channel) + @channels[channel.id.to_u64] = channel + end + + # Adds a specific *guild* to the cache. + def cache(guild : Guild) + @guilds[guild.id.to_u64] = guild + end + + # Adds a specific *member* to the cache, given the *guild_id* it is on. + def cache(member : GuildMember, guild_id : UInt64 | Snowflake) + guild_id = guild_id.to_u64 + local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new + local_members[member.user.id.to_u64] = member + end + + # Adds a specific *role* to the cache. + def cache(role : Role) + @roles[role.id.to_u64] = role + end + + # Adds a specific *Stage instance* to the cache. + def cache(stage_instance : StageInstance) + @stage_instances[stage_instance.channel_id.to_u64] = stage_instance + end + + # Adds a specific *voice state* to the cache. + def cache(voice_state : VoiceState) + user_id = voice_state.user_id.to_u64 + guild_id = voice_state.guild_id.not_nil!.to_u64 + user_voice_states = @voice_states[guild_id] ||= Hash(UInt64, VoiceState).new + user_voice_states[user_id] = voice_state + end + + # Adds a particular DM channel to the cache, given the *channel_id* and the + # *recipient_id*. + def cache_dm_channel(channel_id : UInt64 | Snowflake, recipient_id : UInt64 | Snowflake) + channel_id = channel_id.to_u64 + recipient_id = recipient_id.to_u64 + @dm_channels[recipient_id] = channel_id + end + + # Caches the current user. + def cache_current_user(@current_user : User); end + + # Adds multiple *members* at once to the cache, given the *guild_id* they + # all share. This method exists to slightly reduce the overhead of + # processing chunks; outside of that it is likely not of much use. + def cache_multiple_members(members : Array(GuildMember), guild_id : UInt64 | Snowflake) + guild_id = guild_id.to_u64 + local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new + members.each do |member| + local_members[member.user.id.to_u64] = member + end + end + + # Returns all roles of a guild, identified by its *guild_id*. + def guild_roles(guild_id : UInt64 | Snowflake) : Array(UInt64) + @guild_roles[guild_id.to_u64] + end + + # Marks a role, identified by the *role_id*, as belonging to a particular + # guild, identified by the *guild_id*. + def add_guild_role(guild_id : UInt64 | Snowflake, role_id : UInt64 | Snowflake) + guild_id = guild_id.to_u64 + role_id = role_id.to_u64 + local_roles = @guild_roles[guild_id] ||= [] of UInt64 + local_roles << role_id + end + + # Marks a role as not belonging to a particular guild anymore. + def remove_guild_role(guild_id : UInt64 | Snowflake, role_id : UInt64 | Snowflake) + guild_id = guild_id.to_u64 + role_id = role_id.to_u64 + @guild_roles[guild_id]?.try { |local_roles| local_roles.delete(role_id) } + end + + # Returns all channels of a guild, identified by its *guild_id*. + def guild_channels(guild_id : UInt64 | Snowflake) : Array(UInt64) + @guild_channels[guild_id.to_u64] + end + + # Marks a channel, identified by the *channel_id*, as belonging to a particular + # guild, identified by the *guild_id*. + def add_guild_channel(guild_id : UInt64 | Snowflake, channel_id : UInt64 | Snowflake) + guild_id = guild_id.to_u64 + channel_id = channel_id.to_u64 + local_channels = @guild_channels[guild_id] ||= [] of UInt64 + local_channels << channel_id + end + + # Marks a channel as not belonging to a particular guild anymore. + def remove_guild_channel(guild_id : UInt64 | Snowflake, channel_id : UInt64 | Snowflake) + guild_id = guild_id.to_u64 + channel_id = channel_id.to_u64 + @guild_channels[guild_id]?.try { |local_channels| local_channels.delete(channel_id) } + end + + # Returns all Stage instances of a guild, identified by its *guild_id*. + def guild_stage_instances(guild_id : UInt64 | Snowflake) : Array(UInt64) + @guild_stage_instances[guild_id.to_u64] + end + + # Marks a Stage instance, identified by the *channel_id* it is on, as belonging to a particular + # guild, identified by the *guild_id*. + def add_guild_stage_instance(guild_id : UInt64 | Snowflake, channel_id : UInt64 | Snowflake) + guild_id = guild_id.to_u64 + channel_id = channel_id.to_u64 + local_instances = @guild_stage_instances[guild_id] ||= [] of UInt64 + local_instances << channel_id + end + + # Marks a Stage instance, identified by the *channel_id* it is on, as not belonging to a particular + # guild, identified by the *guild_id*, anymore. + def remove_guild_stage_instance(guild_id : UInt64 | Snowflake, channel_id : UInt64 | Snowflake) + guild_id = guild_id.to_u64 + channel_id = channel_id.to_u64 + @guild_stage_instances[guild_id]?.try { |local_instances| local_instances.delete(channel_id) } + end + end +end diff --git a/lib/discordcr/src/discordcr/cdn.cr b/lib/discordcr/src/discordcr/cdn.cr new file mode 100644 index 0000000..c8868fc --- /dev/null +++ b/lib/discordcr/src/discordcr/cdn.cr @@ -0,0 +1,215 @@ +# This module contains methods for building URLs to resources on Discord's CDN +# for things like guild icons and avatars. +# +# NOTE: All `size` arguments for CDN methods must be a power of 2 between 16 +# and 2048. If an invalid size is given, `ArgumentError` will be raised. +# +# [API Documentation for image formatting](https://discord.com/developers/docs/reference#image-formatting) +module Discord::CDN + extend self + + # Base CDN URL + BASE_URL = "https://cdn.discordapp.com" + + # Available image formats for custom emoji + enum CustomEmojiFormat + PNG + GIF + + def to_s + case self + when PNG + "png" + when GIF + "gif" + end + end + + def to_s(io : IO) + io << to_s + end + end + + # Available image formats for guild icons + enum GuildIconFormat + PNG + JPEG + WebP + + def to_s + case self + when PNG + "png" + when JPEG + "jpeg" + when WebP + "webp" + end + end + + def to_s(io : IO) + io << to_s + end + end + + # Available image formats for guild splashes + enum GuildSplashFormat + PNG + JPEG + WebP + + def to_s + case self + when PNG + "png" + when JPEG + "jpeg" + when WebP + "webp" + end + end + + def to_s(io : IO) + io << to_s + end + end + + # Available image formats for user avatars + enum UserAvatarFormat + PNG + JPEG + WebP + GIF + + def to_s + case self + when PNG + "png" + when JPEG + "jpeg" + when WebP + "webp" + when GIF + "gif" + end + end + + def to_s(io : IO) + io << to_s + end + end + + # Available image formats for application icons + enum ApplicationIconFormat + PNG + JPEG + WebP + GIF + + def to_s + case self + when PNG + "png" + when JPEG + "jpeg" + when WebP + "webp" + when GIF + "gif" + end + end + + def to_s(io : IO) + io << to_s + end + end + + enum ApplicationAssetFormat + PNG + JPEG + WebP + + def to_s + case self + when PNG + "png" + when JPEG + "jpeg" + when WebP + "webp" + end + end + end + + private def check_size(value : Int32) + in_range = (16..2048).includes?(value) + power_of_two = (value > 0) && ((value & (value - 1)) == 0) + unless in_range && power_of_two + raise ArgumentError.new("Size #{value} is not between 16 and 2048 and a power of 2") + end + end + + # Produces a CDN URL for a custom emoji in the given `format` and `size` + def custom_emoji(id : UInt64 | Snowflake, + format : CustomEmojiFormat = CustomEmojiFormat::PNG, + size : Int32 = 128) + check_size(size) + "#{BASE_URL}/emojis/#{id}.#{format}?size=#{size}" + end + + # Produces a CDN URL for a guild icon in the given `format` and `size` + def guild_icon(id : UInt64 | Snowflake, icon : String, + format : GuildIconFormat = GuildIconFormat::WebP, + size : Int32 = 128) + check_size(size) + "#{BASE_URL}/icons/#{id}/#{icon}.#{format}?size=#{size}" + end + + # Produces a CDN URL for a guild splash in the given `format` and `size` + def guild_splash(id : UInt64 | Snowflake, splash : String, + format : GuildSplashFormat = GuildSplashFormat::WebP, + size : Int32 = 128) + check_size(size) + "#{BASE_URL}/splashes/#{id}/#{splash}.#{format}?size=#{size}" + end + + # Produces a CDN URL for a default user avatar, calculated from the given + # discriminator value. + def default_user_avatar(user_discriminator : String) + index = user_discriminator.to_i % 5 + "#{BASE_URL}/embed/avatars/#{index}.png" + end + + # Produces a CDN URL for a user avatar in the given `size`. Given the `avatar` + # string, this will return a WebP or GIF based on the animated avatar hint. + def user_avatar(id : UInt64 | Snowflake, avatar : String, size : Int32 = 128) + if avatar.starts_with?("a_") + user_avatar(id, avatar, UserAvatarFormat::GIF, size) + else + user_avatar(id, avatar, UserAvatarFormat::WebP, size) + end + end + + # Produces a CDN URL for a user avatar in the given `format` and `size` + def user_avatar(id : UInt64 | Snowflake, avatar : String, + format : UserAvatarFormat, size : Int32 = 128) + check_size(size) + "#{BASE_URL}/avatars/#{id}/#{avatar}.#{format}?size=#{size}" + end + + # Produces a CDN URL for an application icon in the given `format` and `size` + def application_icon(id : UInt64 | Snowflake, icon : String, + format : ApplicationIconFormat = ApplicationIconFormat::WebP, + size : Int32 = 128) + check_size(size) + "#{BASE_URL}/app-icons/#{id}/#{icon}.#{format}?size=#{size}" + end + + # Produces a CDN URL for an application asset in the given `format` and `size` + def application_asset(application_id : UInt64 | Snowflake, asset_id : UInt64 | Snowflake, + format : ApplicationAssetFormat = ApplicationAssetFormat::PNG, + size : Int32 = 128) + check_size(size) + "#{BASE_URL}/app-assets/#{application_id}/#{asset_id}.#{format}?size=#{size}" + end +end diff --git a/lib/discordcr/src/discordcr/client.cr b/lib/discordcr/src/discordcr/client.cr new file mode 100644 index 0000000..5b876c5 --- /dev/null +++ b/lib/discordcr/src/discordcr/client.cr @@ -0,0 +1,1069 @@ +require "json" + +require "./rest" +require "./cache" + +module Discord + # Calculates the shard ID that would receive the gateway events from + # a guild with the given `guild_id`, based on the total number of shards. + def self.shard_id(guild_id : UInt64 | Snowflake, total_shards : Int32) + (guild_id.to_u64 >> 22) % total_shards + end + + # 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 + + Log = Discord::Log.for("client") + + # 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: "" + ) + + # Available gateway compression modes that can be requested + enum CompressMode + # Discord won't send any compressed data + None + + # Large payloads (typically `GUILD_CREATE`) will be received compressed + Large + + # All data will be received in a compressed stream + Stream + end + + # 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://discord.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. + # + # `compress` can be set to any value of `CompressMode`. `CompressMode::Stream` + # is the default and will save the most bandwidth. You can optionally change + # this to `CompressMode::Large` to request that only large payloads be received + # compressed. Compression can be disabled with `CompressMode::None`, but this + # is not recommended for production bots. + # + # When using `Compress::Stream` compression, the buffer size can be configured + # by passing `zlib_buffer_size`. + # + # 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. + # + # The *intents* value is used to request that only specific events are sent + # to this client for the current session. For details on what `Intents` values + # correspond to which gateway events, see the [API docs](https://discord.com/developers/docs/topics/gateway#gateway-intents). + # Required with API version > 6. WS is imeadiately closed with 4013, "Invalid intents" otherwise. + def initialize(@token : String, @client_id : UInt64 | Snowflake | Nil = nil, + @shard : Gateway::ShardKey? = nil, + @large_threshold : Int32 = 100, + @compress : CompressMode = CompressMode::Stream, + @zlib_buffer_size : Int32 = 10 * 1024 * 1024, + @properties : Gateway::IdentifyProperties = DEFAULT_PROPERTIES, + @intents : Gateway::Intents? = Gateway::Intents::Unprivileged) + @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 + @client_name = shard ? "Client #{shard[:shard_id]}/#{shard[:num_shards]}" : "Client" + + 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 + Log.error(exception: ex) { "[#{@client_name}] Received exception from WebSocket#run" } + Log.error { ex.inspect_with_backtrace } + end + + @send_heartbeats = false + @session.try &.suspend + + break unless @should_reconnect + + wait_for_reconnect + + Log.info { "[#{@client_name}] Reconnecting" } + @websocket = initialize_websocket + end + end + + # Closes the gateway connection permanently + def stop(code : HTTP::WebSocket::CloseCode = HTTP::WebSocket::CloseCode::NormalClosure) + @should_reconnect = false + websocket.close(code) + 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. + Log.debug { "[#{@client_name}] 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 + if @session.try(&.should_resume?) && (resume_url = @session.try(&.resume_url)) + url = URI.parse(resume_url) + else + url = URI.parse(get_gateway.url) + end + + if @compress.stream? + path = "#{url.path}/?encoding=json&v=9&compress=zlib-stream" + else + path = "#{url.path}/?encoding=json&v=9" + end + + websocket = Discord::WebSocket.new( + host: url.host.not_nil!, + path: path, + port: 443, + tls: true, + zlib_buffer_size: @zlib_buffer_size + ) + + websocket.on_message(&->on_message(Discord::WebSocket::Packet)) + + case @compress + when .large? + websocket.on_compressed(&->on_message(Discord::WebSocket::Packet)) + when .stream? + websocket.on_compressed_stream(&->on_message(Discord::WebSocket::Packet)) + when .none? + # Nothing to do. + end + + websocket.on_close(&->on_close(HTTP::WebSocket::CloseCode, String)) + + websocket + end + + private def on_close(code : HTTP::WebSocket::CloseCode, message : String) + @send_heartbeats = false + @session.try &.suspend + reason = message.empty? ? "(none)" : message + Log.warn { "[#{@client_name}] Websocket closed with code: #{code}, reason: #{reason}" } + 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 + Log.debug { "[#{@client_name}] Heartbeat received" } + websocket.send({op: 1, d: packet.sequence}.to_json) + when OP_HEARTBEAT_ACK + handle_heartbeat_ack + else + Log.warn { "[#{@client_name}] Unsupported payload: #{packet}" } + end + rescue ex : JSON::ParseException + Log.error(exception: ex) { "[#{@client_name}] An exception occurred during message parsing! Please report this." } + Log.error { ex.inspect_with_backtrace } + Log.error { "Previous exception raised with packet: #{packet}" } + rescue ex + Log.error(exception: ex) { "[#{@client_name}] A miscellaneous exception occurred during message handling." } + Log.error { ex.inspect_with_backtrace } + 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 + Log.warn { "[#{@client_name}] 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_resume: true) + next + end + + Log.debug { "[#{@client_name}] Sending heartbeat" } + + begin + seq = @session.try &.sequence || 0 + websocket.send({op: 1, d: seq}.to_json) + @last_heartbeat_acked = false + rescue ex + Log.error(exception: ex) { "[#{@client_name}] Heartbeat failed!" } + Log.error { ex.inspect_with_backtrace } + end + end + + sleep @heartbeat_interval.milliseconds + end + end + end + + private def identify + if shard = @shard + shard_tuple = shard.values + end + + compress = @compress.large? + packet = Gateway::IdentifyPacket.new(@token, @properties, compress, @large_threshold, shard_tuple, @intents) + 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_resume* 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_resume = false, backoff_override = nil) + @backoff = backoff_override if backoff_override + @send_heartbeats = false + + # Suspend the session so we resume, if desired + @session.try do |session| + if should_resume + session.suspend + else + session.invalidate + end + end + + websocket.close(4000) + 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 + Log.error(exception: ex) { "[#{@client_name}] An exception occurred in a user-defined event handler!" } + Log.error { ex.inspect_with_backtrace } + 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, payload.resume_gateway_url) + + # 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 + + Log.info { "[#{@client_name}] Received READY, v: #{payload.v}" } + call_event ready, payload + when "RESUMED" + Log.info { "[#{@client_name}] 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.dm? && 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.threads.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 + + payload.voice_states.each do |voice_state| + voice_state.guild_id = guild.id + cache voice_state + end + + payload.stage_instances.each do |stage_instance| + cache stage_instance + @cache.try &.add_guild_stage_instance(guild.id, stage_instance.channel_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 "INVITE_CREATE" + payload = Gateway::InviteCreatePayload.from_json(data) + + call_event invite_create, payload + when "INVITE_DELETE" + payload = Gateway::InviteDeletePayload.from_json(data) + + call_event invite_delete, payload + when "MESSAGE_CREATE" + payload = Message.from_json(data) + + cache payload.author + guild_id = payload.guild_id + partial_member = payload.member + if guild_id && partial_member + member = GuildMember.new(payload.author, partial_member) + @cache.try &.cache(member, guild_id) + end + + 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_REACTION_REMOVE_EMOJI" + payload = Gateway::MessageReactionRemoveEmojiPayload.from_json(data) + call_event message_reaction_remove_emoji, 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 "STAGE_INSTANCE_CREATE" + payload = StageInstance.from_json(data) + cache payload + + @cache.try &.add_guild_stage_instance(payload.guild_id, payload.channel_id) + + call_event stage_instance_create, payload + when "STAGE_INSTANCE_UPDATE" + payload = StageInstance.from_json(data) + cache payload + + call_event stage_instance_update, payload + when "STAGE_INSTANCE_DELETE" + payload = StageInstance.from_json(data) + + @cache.try &.delete_stage_instance(payload.channel_id) + @cache.try &.remove_guild_stage_instance(payload.guild_id, payload.channel_id) + + call_event stage_instance_delete, payload + when "TYPING_START" + payload = Gateway::TypingStartPayload.from_json(data) + + guild_id = payload.guild_id + member = payload.member + if guild_id && member + @cache.try &.cache(member, guild_id) + end + + 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) + + guild_id = payload.guild_id + + if guild_id && payload.channel_id.nil? + @cache.try &.delete_voice_state(guild_id, payload.user_id) + else + @cache.try &.cache(payload) + end + + member = payload.member + if guild_id && member + @cache.try &.cache(member, guild_id) + end + + 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 + when "THREAD_CREATE" + payload = Channel.from_json(data) + cache payload + + if guild_id = payload.guild_id + @cache.try &.add_guild_channel(guild_id, payload.id) + end + + call_event thread_create, payload + when "THREAD_UPDATE" + payload = Channel.from_json(data) + cache payload + + call_event thread_update, payload + when "THREAD_DELETE" + payload = Channel.from_json(data) + + @cache.try &.delete_channel(payload.id) + if guild_id = payload.guild_id + @cache.try &.remove_guild_channel(guild_id, payload.id) + end + + call_event thread_delete, payload + when "THREAD_LIST_SYNC" + payload = Gateway::ThreadListSyncPayload.from_json(data) + + payload.threads.each do |channel| + channel.guild_id = payload.guild_id + cache channel + @cache.try &.add_guild_channel(payload.guild_id, channel.id) + end + + call_event thread_list_sync, payload + when "THREAD_MEMBER_UPDATE" + payload = ThreadMember.from_json(data) + call_event thread_member_update, payload + when "THREAD_MEMBERS_UPDATE" + payload = Gateway::ThreadMembersUpdatePayload.from_json(data) + call_event thread_members_update, payload + else + Log.warn { "[#{@client_name}] 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_resume: true, backoff_override: 0.0) + end + + private def handle_invalid_session + @session.try &.invalidate + identify + end + + private def handle_heartbeat_ack + Log.debug { "[#{@client_name}] 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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.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://discord.com/developers/docs/topics/gateway#guild-role-delete) + event guild_role_delete, Gateway::GuildRoleDeletePayload + + # Called when an invite is created on a guild. + # + # [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#invite-create) + event invite_create, Gateway::InviteCreatePayload + + # Called when an invite is deleted. + # + # [API docs for this event](https://discordapp.com/developers/docs/topics/gateway#invite-delete) + event invite_delete, Gateway::InviteDeletePayload + + # 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://discord.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 all reactions of a single emoji are removed at once from a message. + event message_reaction_remove_emoji, Gateway::MessageReactionRemoveEmojiPayload + + # 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://discord.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://discord.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, or other actions. + # + # [API docs for this event](https://discord.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://discord.com/developers/docs/topics/gateway#presence-update) + event presence_update, Gateway::PresenceUpdatePayload + + # Sent when a Stage instance is created. + # + # [API docs for this event](https://discord.com/developers/docs/topics/gateway#stage-instance-create) + event stage_instance_create, StageInstance + + # Sent when a Stage instance is updated. + # + # [API docs for this event](https://discord.com/developers/docs/topics/gateway#stage-instance-update) + event stage_instance_update, StageInstance + + # Sent when a Stage instance is deleted. + # + # [API docs for this event](https://discord.com/developers/docs/topics/gateway#stage-instance-delete) + event stage_instance_delete, StageInstance + + # Called when somebody starts typing in a channel the bot has access to. + # + # [API docs for this event](https://discord.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://discord.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://discord.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://discord.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://discord.com/developers/docs/topics/gateway#webhooks-update) + event webhooks_update, Gateway::WebhooksUpdatePayload + + # Sent when a thread is created, relevant to the current user, or when the current user is added to a thread. + # + # [API docs for this event](https://discord.com/developers/docs/topics/gateway#thread-create) + event thread_create, Channel + + # Sent when a thread is updated. To keep track of the last_message_id changes, you must listen for Message Create events. + # + # [API docs for this event](https://discord.com/developers/docs/topics/gateway#thread-update) + event thread_update, Channel + + # Sent when a thread relevant to the current user is deleted. + # + # [API docs for this method](https://discord.com/developers/docs/topics/gateway#thread-delete) + event thread_delete, Channel + + # Sent when the current user gains access to a channel. + # + # [API docs for this event](https://discord.com/developers/docs/topics/gateway#thread-list-sync) + event thread_list_sync, Gateway::ThreadListSyncPayload + + # Sent when the thread member object for the current user is updated. + # This event is documented for completeness, but unlikely to be used by most bots. + # (See #thread_members_update instead) + # + # [API docs for this event](https://discord.com/developers/docs/topics/gateway#thread-member-update) + event thread_member_update, ThreadMember + + # Sent when anyone is added to or removed from a thread. + # If the current user does not have the GUILD_MEMBERS Gateway Intent, then this event will only be sent if the current user was added to or removed from the thread. + # + # [API docs for this event](https://discord.com/developers/docs/topics/gateway#thread-members-update) + event thread_members_update, Gateway::ThreadMembersUpdatePayload + end + + module Gateway + alias ShardKey = {shard_id: Int32, num_shards: Int32} + + class Session + getter session_id + property sequence + property resume_url + + def initialize(@session_id : String, @resume_url : 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/lib/discordcr/src/discordcr/dca.cr b/lib/discordcr/src/discordcr/dca.cr new file mode 100644 index 0000000..5ac58f4 --- /dev/null +++ b/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 + include JSON::Serializable + + property dca : DCA + property opus : Opus + property info : Info? + property origin : Origin? + property extra : JSON::Any + end + + struct DCA + include JSON::Serializable + + property version : Int32 + property tool : Tool + end + + struct Tool + include JSON::Serializable + + property name : String + property version : String + property url : String? + property author : String? + end + + struct Opus + include JSON::Serializable + + property mode : String + property sample_rate : Int32 + property frame_size : Int32 + property abr : Int32? + property vbr : Bool + property channels : Int32 + end + + struct Info + include JSON::Serializable + + property title : String? + property artist : String? + property album : String? + property genre : String? + property comments : String? + property cover : String? + end + + struct Origin + include JSON::Serializable + + property source : String? + property abr : Int32? + property channels : Int32? + property encoding : String? + property url : String? + end + end +end diff --git a/lib/discordcr/src/discordcr/errors.cr b/lib/discordcr/src/discordcr/errors.cr new file mode 100644 index 0000000..e2b3627 --- /dev/null +++ b/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 + include JSON::Serializable + + property code : Int32 + property message : String + end + + # This exception is raised in `REST#request` when a request fails with an + # API error response that has a code and a descriptive message. + class CodeException < StatusException + getter error : APIError + + def initialize(@response : HTTP::Client::Response, @error : APIError) + end + + # The API error code that was returned by Discord, for example 20001 or + # 50016. + def error_code : Int32 + @error.code + end + + # The API error message that was returned by Discord, for example "Bots + # cannot use this endpoint" or "Provided too few or too many messages to + # delete. Must provide at least 2 and fewer than 100 messages to delete.". + def error_message : String + @error.message + end + + def message + "#{@response.status_code} #{@response.status_message}: Code #{@error.code} - #{@error.message}" + end + + def to_s(io) + io << @response.status_code << " " << @response.status_message << ": Code " << @error.code << " - " << @error.message + end + end +end diff --git a/lib/discordcr/src/discordcr/mappings/channel.cr b/lib/discordcr/src/discordcr/mappings/channel.cr new file mode 100644 index 0000000..dd18e6a --- /dev/null +++ b/lib/discordcr/src/discordcr/mappings/channel.cr @@ -0,0 +1,429 @@ +require "./converters" + +module Discord + enum MessageType : UInt8 + Default = 0 + RecipientAdd = 1 + RecipientRemove = 2 + Call = 3 + ChannelNameChange = 4 + ChannelIconChange = 5 + ChannelPinnedMessage = 6 + GuildMemberJoin = 7 + UserPremiumGuildSubscription = 8 + UserPremiumGuildSubscriptionTier1 = 9 + UserPremiumGuildSubscriptionTier2 = 10 + UserPremiumGuildSubscriptionTier3 = 11 + ChannelFollowAdd = 12 + GuildDiscoveryDisqualified = 14 + GuildDiscoveryRequalified = 15 + GuildDiscoveryGracePeriodInitialWarning = 16 + GuildDiscoveryGracePeriodFinalWarning = 17 + ThreadCreated = 18 + Reply = 19 + ApplicationCommand = 20 + ThreadStarterMessage = 21 + GuildInviteReminder = 22 + + def self.new(pull : JSON::PullParser) + MessageType.new(pull.read_int.to_u8) + end + end + + @[Flags] + enum MessageFlags : UInt32 + Crossposted = 1 << 0 + IsCrosspost = 1 << 1 + SuppressEmbeds = 1 << 2 + SourceMessageDeleted = 1 << 3 + Urgent = 1 << 4 + HasThread = 1 << 5 + Ephemeral = 1 << 6 + Loading = 1 << 7 + FailedToMentionSomeRolesInThread = 1 << 8 + + def self.new(pull : JSON::PullParser) + MessageFlags.new(pull.read_int.to_u32) + end + end + + enum AutoArchiveDuration : UInt16 + Hour = 60 + Day = 1440 + ThreeDays = 4320 + Week = 10080 + + def self.new(pull : JSON::PullParser) + AutoArchiveDuration.new(pull.read_int.to_u16) + end + + def to_json(json : JSON::Builder) + json.number(value) + end + end + + class Message + include JSON::Serializable + + property type : MessageType + property content : String + property id : Snowflake + property channel_id : Snowflake + property guild_id : Snowflake? + property author : User + property member : PartialGuildMember? + @[JSON::Field(converter: Discord::TimestampConverter)] + property timestamp : Time + @[JSON::Field(converter: Discord::MaybeTimestampConverter)] + property edited_timestamp : Time? + property tts : Bool + property mention_everyone : Bool + property mentions : Array(User) + property mention_roles : Array(Snowflake) + property mention_channels : Array(Snowflake)? + property attachments : Array(Attachment) + property embeds : Array(Embed) + property pinned : Bool? + property reactions : Array(Reaction)? + property nonce : String | Int64? + property activity : Activity? + property application : OAuth2Application? + property webhook_id : Snowflake? + property flags : MessageFlags? + property thread : Channel? + property referenced_message : Message? + + def message_reference : MessageReference + MessageReference.new(@id, @channel_id, @guild_id) + end + end + + struct MessageReference + include JSON::Serializable + + property message_id : Snowflake? + property channel_id : Snowflake? + property guild_id : Snowflake? + property fail_if_not_exists : Bool? + + def initialize(@message_id = nil, @channel_id = nil, + @guild_id = nil, @fail_if_not_exists = nil) + end + end + + struct AllowedMentions + include JSON::Serializable + + property parse : Array(String)? + property roles : Array(Snowflake)? + property users : Array(Snowflake)? + property replied_user : Bool + + def initialize(@parse : Array(String)? = nil, @roles : Array(Snowflake)? = nil, + @users : Array(Snowflake)? = nil, @replied_user : Bool = false) + end + end + + enum ActivityType : UInt8 + Join = 1 + Spectate = 2 + Listen = 3 + JoinRequest = 5 + + def self.new(pull : JSON::PullParser) + ActivityType.new(pull.read_int.to_u8) + end + end + + struct Activity + include JSON::Serializable + + property type : ActivityType + property party_id : String? + end + + enum ChannelType : UInt8 + GuildText = 0 + DM = 1 + GuildVoice = 2 + GroupDM = 3 + GuildCategory = 4 + GuildNews = 5 + GuildStore = 6 + GuildNewsThread = 10 + GuildPublicThread = 11 + GuildPrivateThread = 12 + GuildStageVoice = 13 + + def self.new(pull : JSON::PullParser) + ChannelType.new(pull.read_int.to_u8) + end + + def to_json(json : JSON::Builder) + json.number(value) + end + end + + enum VideoQualityMode : UInt8 + Auto = 1 + Full = 2 + + def self.new(pull : JSON::PullParser) + VideoQualityMode.new(pull.read_int.to_u8) + end + + def to_json(json : JSON::Builder) + json.number(value) + end + end + + struct Channel + include JSON::Serializable + + property id : Snowflake + property type : ChannelType + property guild_id : Snowflake? + property name : String? + property permission_overwrites : Array(Overwrite)? + property topic : String? + property last_message_id : Snowflake? + property bitrate : UInt32? + property user_limit : UInt32? + property recipients : Array(User)? + property nsfw : Bool? + property icon : String? + property owner_id : Snowflake? + property application_id : Snowflake? + property position : Int32? + property parent_id : Snowflake? + property rate_limit_per_user : Int32? + @[JSON::Field(converter: Discord::MaybeTimestampConverter)] + property last_pin_timestamp : Time? + property rtc_region : String? + property video_quality_mode : VideoQualityMode? + property thread_metadata : ThreadMetaData? + property message_count : UInt32? + property member_count : UInt32? + property member : ThreadMember? + property default_auto_archive_duration : AutoArchiveDuration? + property last_message_id : Snowflake? + + # :nodoc: + def initialize(private_channel : PrivateChannel) + @id = private_channel.id + @type = private_channel.type + @recipients = private_channel.recipients + @last_message_id = private_channel.last_message_id + end + + # Produces a string to mention this channel in a message + def mention + "<##{id}>" + end + end + + struct ThreadMetaData + include JSON::Serializable + property archived : Bool + property auto_archive_duration : AutoArchiveDuration + property archive_timestamp : Time + property locked : Bool + property invitable : Bool? + end + + enum StagePrivacyLevel : UInt8 + PUBLIC = 1 + GUILD_ONLY = 2 + + def self.new(pull : JSON::PullParser) + StagePrivacyLevel.new(pull.read_int.to_u8) + end + + def to_json(json : JSON::Builder) + json.number(value) + end + end + + struct StageInstance + include JSON::Serializable + + getter id : Snowflake + getter guild_id : Snowflake + getter channel_id : Snowflake + getter topic : String + getter privacy_level : StagePrivacyLevel + getter discoverable_disabled : Bool + end + + struct PrivateChannel + include JSON::Serializable + + property id : Snowflake + property type : ChannelType + property recipients : Array(User) + property last_message_id : Snowflake? + end + + struct Overwrite + include JSON::Serializable + + property id : Snowflake + property type : Int8 + property allow : Permissions + property deny : Permissions + end + + struct Reaction + include JSON::Serializable + + property emoji : ReactionEmoji + property count : UInt32 + property me : Bool + end + + struct ReactionEmoji + include JSON::Serializable + + property id : Snowflake? + property name : String + end + + struct Embed + include JSON::Serializable + + property title : String? + property type : String + property description : String? + property url : String? + @[JSON::Field(converter: Discord::MaybeTimestampConverter)] + property timestamp : Time? + @[JSON::Field(key: "color")] + property colour : UInt32? + property footer : EmbedFooter? + property image : EmbedImage? + property thumbnail : EmbedThumbnail? + property video : EmbedVideo? + property provider : EmbedProvider? + property author : EmbedAuthor? + property fields : Array(EmbedField)? + + def initialize(@title : String? = nil, @type : String = "rich", + @description : String? = nil, @url : String? = nil, + @timestamp : Time? = nil, @colour : UInt32? = nil, + @footer : EmbedFooter? = nil, @image : EmbedImage? = nil, + @thumbnail : EmbedThumbnail? = nil, @author : EmbedAuthor? = nil, + @fields : Array(EmbedField)? = nil) + end + + {% unless flag?(:correct_english) %} + def color + colour + end + {% end %} + end + + struct EmbedThumbnail + include JSON::Serializable + + property url : String + property proxy_url : String? + property height : UInt32? + property width : UInt32? + + def initialize(@url : String) + end + end + + struct EmbedVideo + include JSON::Serializable + + property url : String + property height : UInt32 + property width : UInt32 + end + + struct EmbedImage + include JSON::Serializable + + property url : String + property proxy_url : String? + property height : UInt32? + property width : UInt32? + + def initialize(@url : String) + end + end + + struct EmbedProvider + include JSON::Serializable + + property name : String + property url : String? + end + + struct EmbedAuthor + include JSON::Serializable + + property name : String? + property url : String? + property icon_url : String? + property proxy_icon_url : String? + + def initialize( + @name : String? = nil, + @url : String? = nil, + @icon_url : String? = nil + ) + end + end + + struct EmbedFooter + include JSON::Serializable + + property text : String? + property icon_url : String? + property proxy_icon_url : String? + + def initialize( + @text : String? = nil, + @icon_url : String? = nil + ) + end + end + + struct EmbedField + include JSON::Serializable + + property name : String + property value : String + property inline : Bool + + def initialize( + @name : String, + @value : String, + @inline : Bool = false + ) + end + end + + struct Attachment + include JSON::Serializable + + property id : Snowflake + property filename : String + property size : UInt32 + property url : String + property proxy_url : String + property height : UInt32? + property width : UInt32? + end + + struct ThreadMember + include JSON::Serializable + + property id : Snowflake? + property user_id : Snowflake? + property join_timestamp : Time + property flags : UInt32 + end +end diff --git a/lib/discordcr/src/discordcr/mappings/converters.cr b/lib/discordcr/src/discordcr/mappings/converters.cr new file mode 100644 index 0000000..52a58bb --- /dev/null +++ b/lib/discordcr/src/discordcr/mappings/converters.cr @@ -0,0 +1,51 @@ +require "json" +require "time/format" + +module Discord + # :nodoc: + module TimestampConverter + def self.from_json(parser : JSON::PullParser) + time_str = parser.read_string + + begin + Time::Format.new("%FT%T.%6N%:z").parse(time_str) + rescue Time::Format::Error + Time::Format.new("%FT%T%:z").parse(time_str) + end + end + + def self.to_json(value : Time, builder : JSON::Builder) + Time::Format.new("%FT%T.%6N%:z").to_json(value, builder) + end + end + + # :nodoc: + module MaybeTimestampConverter + def self.from_json(parser : JSON::PullParser) + if parser.kind.null? + parser.read_null + return nil + end + TimestampConverter.from_json(parser) + end + + def self.to_json(value : Time?, builder : JSON::Builder) + if value + TimestampConverter.to_json(value, builder) + else + builder.null + end + end + end + + # :nodoc: + module TimeSpanMillisecondsConverter + def self.from_json(parser : JSON::PullParser) + parser.read_int.milliseconds + end + + def self.to_json(value : Time::Span, builder : JSON::Builder) + builder.scalar(value.milliseconds) + end + end +end diff --git a/lib/discordcr/src/discordcr/mappings/enums.cr b/lib/discordcr/src/discordcr/mappings/enums.cr new file mode 100644 index 0000000..0d2a3fd --- /dev/null +++ b/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/lib/discordcr/src/discordcr/mappings/gateway.cr b/lib/discordcr/src/discordcr/mappings/gateway.cr new file mode 100644 index 0000000..356c85e --- /dev/null +++ b/lib/discordcr/src/discordcr/mappings/gateway.cr @@ -0,0 +1,480 @@ +require "./converters" +require "./user" +require "./channel" +require "./guild" + +module Discord + module Gateway + struct ReadyPayload + include JSON::Serializable + + property v : UInt8 + property user : User + property private_channels : Array(PrivateChannel) + property guilds : Array(UnavailableGuild) + property session_id : String + property resume_gateway_url : String + end + + struct ResumedPayload + include JSON::Serializable + + property _trace : Array(String) + end + + struct IdentifyPacket + include JSON::Serializable + + property op : Int32 + property d : IdentifyPayload + + def initialize(token, properties, large_threshold, compress, shard, intents) + @op = Discord::Client::OP_IDENTIFY + @d = IdentifyPayload.new(token, properties, large_threshold, compress, shard, intents) + end + end + + struct IdentifyPayload + include JSON::Serializable + + property token : String + property properties : IdentifyProperties + property compress : Bool + property large_threshold : Int32 + property shard : Tuple(Int32, Int32)? + + @[JSON::Field(converter: Enum::ValueConverter)] + property intents : Intents? + + def initialize(@token, @properties, @compress, @large_threshold, @shard, @intents) + end + end + + struct IdentifyProperties + include JSON::Serializable + + @[JSON::Field(key: "$os")] + property os : String + @[JSON::Field(key: "$browser")] + property browser : String + @[JSON::Field(key: "$device")] + property device : String + @[JSON::Field(key: "$referrer")] + property referrer : String + @[JSON::Field(key: "$referring_domain")] + property referring_domain : String + + def initialize(@os, @browser, @device, @referrer, @referring_domain) + end + end + + @[Flags] + enum Intents + Guilds = 1 << 0 + GuildMembers = 1 << 1 + GuildBans = 1 << 2 + GuildEmojis = 1 << 3 + GuildIntegrations = 1 << 4 + GuildWebhooks = 1 << 5 + GuildInvites = 1 << 6 + GuildVoiceStates = 1 << 7 + GuildPresences = 1 << 8 + GuildMessages = 1 << 9 + GuildMessageReactions = 1 << 10 + GuildMessageTyping = 1 << 11 + DirectMessages = 1 << 12 + DirectMessageReactions = 1 << 13 + DirectMessageTyping = 1 << 14 + + # Generates an Unprivileged intents constant, removing GuildMembers and GuildPresences. + {% begin %} + Unprivileged = {{ @type.constants.reject { |e| ["All", "None", "GuildMembers", "GuildPresences"].includes?(e.stringify) }.join("|").id }} + {% end %} + end + + struct ResumePacket + include JSON::Serializable + + property op : Int32 + property d : ResumePayload + + def initialize(token, session_id, seq) + @op = Discord::Client::OP_RESUME + @d = ResumePayload.new(token, session_id, seq) + end + end + + # :nodoc: + struct ResumePayload + include JSON::Serializable + + property token : String + property session_id : String + property seq : Int64 + + def initialize(@token, @session_id, @seq) + end + end + + struct StatusUpdatePacket + include JSON::Serializable + + property op : Int32 + property d : StatusUpdatePayload + + def initialize(status, game, afk, since) + @op = Discord::Client::OP_STATUS_UPDATE + @d = StatusUpdatePayload.new(status, game, afk, since) + end + end + + # :nodoc: + struct StatusUpdatePayload + include JSON::Serializable + + @[JSON::Field(emit_null: true)] + property status : String? + @[JSON::Field(emit_null: true)] + property game : GamePlaying? + property afk : Bool + @[JSON::Field(emit_null: true)] + property since : Int64? + + def initialize(@status, @game, @afk, @since) + end + end + + struct VoiceStateUpdatePacket + include JSON::Serializable + + property op : Int32 + property d : VoiceStateUpdatePayload + + def initialize(guild_id, channel_id, self_mute, self_deaf) + @op = Discord::Client::OP_VOICE_STATE_UPDATE + @d = VoiceStateUpdatePayload.new(guild_id, channel_id, self_mute, self_deaf) + end + end + + # :nodoc: + struct VoiceStateUpdatePayload + include JSON::Serializable + + property guild_id : UInt64 + @[JSON::Field(emit_null: true)] + property channel_id : UInt64? + property self_mute : Bool + property self_deaf : Bool + + def initialize(@guild_id, @channel_id, @self_mute, @self_deaf) + end + end + + struct RequestGuildMembersPacket + include JSON::Serializable + + property op : Int32 + property d : RequestGuildMembersPayload + + def initialize(guild_id, query, limit) + @op = Discord::Client::OP_REQUEST_GUILD_MEMBERS + @d = RequestGuildMembersPayload.new(guild_id, query, limit) + end + end + + # :nodoc: + struct RequestGuildMembersPayload + include JSON::Serializable + + property guild_id : UInt64 + property query : String + property limit : Int32 + + def initialize(@guild_id, @query, @limit) + end + end + + struct HelloPayload + include JSON::Serializable + + property heartbeat_interval : UInt32 + property _trace : Array(String) + end + + # This one is special from simply Guild since it also has fields for members + # and presences. + struct GuildCreatePayload + include JSON::Serializable + + property id : Snowflake + property name : String + property icon : String? + property splash : String? + property owner_id : Snowflake + property region : String + property afk_channel_id : Snowflake? + property afk_timeout : Int32? + property verification_level : UInt8 + property premium_tier : UInt8 + property premium_subscription_count : UInt8? + property roles : Array(Role) + @[JSON::Field(key: "emojis")] + property emoji : Array(Emoji) + property features : Array(String) + property large : Bool + property voice_states : Array(VoiceState) + property unavailable : Bool? + property member_count : Int32 + property members : Array(GuildMember) + property channels : Array(Channel) + property presences : Array(Presence) + property widget_channel_id : Snowflake? + property default_message_notifications : UInt8 + property explicit_content_filter : UInt8 + property system_channel_id : Snowflake? + property stage_instances : Array(StageInstance) + property threads : Array(Channel) + + {% unless flag?(:correct_english) %} + def emojis + emoji + end + {% end %} + end + + struct GuildDeletePayload + include JSON::Serializable + + property id : Snowflake + property unavailable : Bool? + end + + struct GuildBanPayload + include JSON::Serializable + + property user : User + property guild_id : Snowflake + end + + struct GuildEmojiUpdatePayload + include JSON::Serializable + + property guild_id : Snowflake + @[JSON::Field(key: "emojis")] + property emoji : Array(Emoji) + + {% unless flag?(:correct_english) %} + def emojis + emoji + end + {% end %} + end + + struct GuildIntegrationsUpdatePayload + include JSON::Serializable + + property guild_id : Snowflake + end + + struct GuildMemberAddPayload + include JSON::Serializable + + property user : User + property nick : String? + property roles : Array(Snowflake) + @[JSON::Field(converter: Discord::MaybeTimestampConverter)] + property joined_at : Time? + @[JSON::Field(converter: Discord::MaybeTimestampConverter)] + property premium_since : Time? + property deaf : Bool + property mute : Bool + property guild_id : Snowflake + end + + struct GuildMemberUpdatePayload + include JSON::Serializable + + property user : User + property roles : Array(Snowflake) + property nick : String? + property guild_id : Snowflake + end + + struct GuildMemberRemovePayload + include JSON::Serializable + + property user : User + property guild_id : Snowflake + end + + struct GuildMembersChunkPayload + include JSON::Serializable + + property guild_id : Snowflake + property members : Array(GuildMember) + end + + struct GuildRolePayload + include JSON::Serializable + + property guild_id : Snowflake + property role : Role + end + + struct GuildRoleDeletePayload + include JSON::Serializable + + property guild_id : Snowflake + property role_id : Snowflake + end + + struct InviteCreatePayload + include JSON::Serializable + + property channel_id : Snowflake + property code : String + @[JSON::Field(converter: Discord::MaybeTimestampConverter)] + property created_at : Time? + property guild_id : Snowflake? + property inviter : User? + property max_age : Int32 + property max_uses : Int32 + property temporary : Bool + property uses : Int32 + end + + struct InviteDeletePayload + include JSON::Serializable + + property channel_id : Snowflake + property guild_id : Snowflake? + property code : String + end + + struct MessageReactionPayload + include JSON::Serializable + + property user_id : Snowflake + property channel_id : Snowflake + property message_id : Snowflake + property guild_id : Snowflake? + property emoji : ReactionEmoji + end + + struct MessageReactionRemoveAllPayload + include JSON::Serializable + + property channel_id : Snowflake + property message_id : Snowflake + property guild_id : Snowflake? + end + + struct MessageReactionRemoveEmojiPayload + include JSON::Serializable + + property channel_id : Snowflake + property guild_id : Snowflake + property message_id : Snowflake + property emoji : ReactionEmoji + end + + struct MessageUpdatePayload + include JSON::Serializable + + property type : UInt8? + property content : String? + property id : Snowflake + property channel_id : Snowflake + property guild_id : Snowflake? + property author : User? + @[JSON::Field(converter: Discord::MaybeTimestampConverter)] + property timestamp : Time? + property tts : Bool? + property mention_everyone : Bool? + property mentions : Array(User)? + property mention_roles : Array(Snowflake)? + property attachments : Array(Attachment)? + property embeds : Array(Embed)? + property pinned : Bool? + end + + struct MessageDeletePayload + include JSON::Serializable + + property id : Snowflake + property channel_id : Snowflake + property guild_id : Snowflake? + end + + struct MessageDeleteBulkPayload + include JSON::Serializable + + property ids : Array(Snowflake) + property channel_id : Snowflake + property guild_id : Snowflake? + end + + struct PresenceUpdatePayload + include JSON::Serializable + + property user : PartialUser + property game : GamePlaying? + property guild_id : Snowflake + property status : String + property activities : Array(GamePlaying) + end + + struct TypingStartPayload + include JSON::Serializable + + property channel_id : Snowflake + property user_id : Snowflake + property guild_id : Snowflake? + property member : GuildMember? + @[JSON::Field(converter: Time::EpochConverter)] + property timestamp : Time + end + + struct VoiceServerUpdatePayload + include JSON::Serializable + + property token : String + property guild_id : Snowflake + property endpoint : String + end + + struct WebhooksUpdatePayload + include JSON::Serializable + + property guild_id : Snowflake + property channel_id : Snowflake + end + + struct ChannelPinsUpdatePayload + include JSON::Serializable + + @[JSON::Field(converter: Discord::MaybeTimestampConverter)] + property last_pin_timestamp : Time? + property channel_id : Snowflake + end + + struct ThreadMembersUpdatePayload + include JSON::Serializable + + property id : Snowflake + property guild_id : Snowflake + property member_count : UInt32 + property added_members : Array(ThreadMember)? + property removed_member_ids : Array(Snowflake)? + end + + struct ThreadListSyncPayload + include JSON::Serializable + + property guild_id : Snowflake + property channel_ids : Array(Snowflake)? + property threads : Array(Channel) + property members : Array(ThreadMember) + end + end +end diff --git a/lib/discordcr/src/discordcr/mappings/guild.cr b/lib/discordcr/src/discordcr/mappings/guild.cr new file mode 100644 index 0000000..e9e9a31 --- /dev/null +++ b/lib/discordcr/src/discordcr/mappings/guild.cr @@ -0,0 +1,342 @@ +require "./converters" +require "./voice" + +module Discord + struct Guild + include JSON::Serializable + + property id : Snowflake + property name : String + property icon : String? + property splash : String? + property owner_id : Snowflake + property region : String + property afk_channel_id : Snowflake? + property afk_timeout : Int32? + # Removed in v8 + # property embed_enabled : Bool? + # property embed_channel_id : Snowflake? + property verification_level : UInt8 + property premium_tier : UInt8 + property premium_subscription_count : UInt8? + property roles : Array(Role) + @[JSON::Field(key: "emojis")] + property emoji : Array(Emoji) + property features : Array(String) + property widget_enabled : Bool? + property widget_channel_id : Snowflake? + property default_message_notifications : UInt8 + property explicit_content_filter : UInt8 + property system_channel_id : Snowflake? + + # :nodoc: + def initialize(payload : Gateway::GuildCreatePayload) + @id = payload.id + @name = payload.name + @icon = payload.icon + @splash = payload.splash + @owner_id = payload.owner_id + @region = payload.region + @afk_channel_id = payload.afk_channel_id + @afk_timeout = payload.afk_timeout + @verification_level = payload.verification_level + @premium_tier = payload.premium_tier + @roles = payload.roles + @emoji = payload.emoji + @features = payload.features + @widget_channel_id = payload.widget_channel_id + @default_message_notifications = payload.default_message_notifications + @explicit_content_filter = payload.explicit_content_filter + @system_channel_id = payload.system_channel_id + end + + {% unless flag?(:correct_english) %} + def emojis + emoji + end + {% end %} + + # Produces a CDN URL to this guild's icon in the given `format` and `size`, + # or `nil` if no icon is set. + def icon_url(format : CDN::GuildIconFormat = CDN::GuildIconFormat::WebP, + size : Int32 = 128) + if icon = @icon + CDN.guild_icon(id, icon, format, size) + end + end + + # Produces a CDN URL to this guild's splash in the given `format` and `size`, + # or `nil` if no splash is set. + def splash_url(format : CDN::GuildSplashFormat = CDN::GuildSplashFormat::WebP, + size : Int32 = 128) + if splash = @splash + CDN.guild_splash(id, splash, format, size) + end + end + end + + struct UnavailableGuild + include JSON::Serializable + + property id : Snowflake + property unavailable : Bool + end + + struct GuildEmbed + include JSON::Serializable + + property enabled : Bool + property channel_id : Snowflake? + end + + struct GuildMember + include JSON::Serializable + + property user : User + property nick : String? + property roles : Array(Snowflake)? + @[JSON::Field(converter: Discord::MaybeTimestampConverter)] + property joined_at : Time? + @[JSON::Field(converter: Discord::MaybeTimestampConverter)] + property premium_since : Time? + property deaf : Bool? + property mute : Bool? + property communication_disabled_until : Time? + + # :nodoc: + def initialize(user : User, partial_member : PartialGuildMember) + @user = user + @roles = partial_member.roles + @nick = partial_member.nick + @joined_at = partial_member.joined_at + @premium_since = partial_member.premium_since + @mute = partial_member.mute + @deaf = partial_member.deaf + @communication_disabled_until = partial_member.communication_disabled_until + end + + # :nodoc: + def initialize(payload : Gateway::GuildMemberAddPayload | GuildMember, roles : Array(Snowflake), nick : String?) + initialize(payload) + @nick = nick + @roles = roles + end + + # :nodoc: + def initialize(payload : Gateway::GuildMemberAddPayload | GuildMember) + @user = payload.user + @nick = payload.nick + @roles = payload.roles + @joined_at = payload.joined_at + @premium_since = payload.premium_since + @deaf = payload.deaf + @mute = payload.mute + end + + # :nodoc: + def initialize(payload : Gateway::PresenceUpdatePayload) + @user = User.new(payload.user) + # Presence updates have no joined_at or deaf/mute, thanks Discord + end + + # Produces a string to mention this member in a message + def mention + if nick + "<@!#{user.id}>" + else + "<@#{user.id}>" + end + end + end + + struct PartialGuildMember + include JSON::Serializable + + property nick : String? + property roles : Array(Snowflake) + @[JSON::Field(converter: Discord::TimestampConverter)] + property joined_at : Time + @[JSON::Field(converter: Discord::MaybeTimestampConverter)] + property premium_since : Time? + property deaf : Bool + property mute : Bool + property communication_disabled_until : Time? + end + + struct Integration + include JSON::Serializable + + property id : Snowflake + property name : String + property type : String + property enabled : Bool + property syncing : Bool + property role_id : Snowflake + @[JSON::Field(key: "expire_behavior")] + property expire_behaviour : UInt8 + property expire_grace_period : Int32 + property user : User + property account : IntegrationAccount + @[JSON::Field(converter: Time::EpochConverter)] + property synced_at : Time + + {% unless flag?(:correct_english) %} + def expire_behavior + expire_behaviour + end + {% end %} + end + + struct IntegrationAccount + include JSON::Serializable + + property id : String + property name : String + end + + struct Emoji + include JSON::Serializable + + property id : Snowflake? + property name : String + property roles : Array(Snowflake)? + property require_colons : Bool? + property managed : Bool? + property animated : Bool? + + # Produces a CDN URL to this emoji's image in the given `size`. Will return + # a PNG, or GIF if the emoji is animated. + def image_url(size : Int32 = 128) + if animated + image_url(:gif, size) + else + image_url(:png, size) + end + end + + # Produces a CDN URL to this emoji's image in the given `format` and `size` + # or `nil` if the emoji has no id. + def image_url(format : CDN::CustomEmojiFormat, size : Int32 = 128) + if emoji_id = id + CDN.custom_emoji(emoji_id, format, size) + end + end + + # Produces a string to mention this emoji in a message + def mention + if animated + "" + else + "<:#{name}:#{id}>" + end + end + end + + struct Role + include JSON::Serializable + + property id : Snowflake + property name : String + property permissions : Permissions + @[JSON::Field(key: "color")] + property colour : UInt32 + property hoist : Bool + property position : Int32 + property managed : Bool + property mentionable : Bool + + @[JSON::Field(converter: Discord::RoleTags)] + property tags : RoleTags? + + {% unless flag?(:correct_english) %} + def color + colour + end + {% end %} + + # Produces a string to mention this role in a message + def mention + "<@&#{id}>" + end + end + + struct RoleTags + include JSON::Serializable + + property bot_id : Snowflake? + property integration_id : Snowflake? + property premium_subscriber : Bool = false + + def initialize(@bot_id : Snowflake?, @integration_id : Snowflake?, + @premium_subscriber : Bool?) + end + + # This struct requires a special parsing routine because Discord + # decided to send dumb values for it. + # This can be removed whenever premium_subscriber doesnt return only null. + def self.from_json(pull : JSON::PullParser) + bot_id = nil + integration_id = nil + premium_subscriber = false + + pull.read_object do |key| + case key + when "bot_id" then bot_id = Snowflake.new(pull) + when "integration_id" then integration_id = Snowflake.new(pull) + when "premium_subscriber" + premium_subscriber = true + pull.skip + else + pull.skip + end + end + + RoleTags.new(bot_id, integration_id, premium_subscriber) + end + end + + struct GuildBan + include JSON::Serializable + + property user : User + property reason : String? + end + + struct GamePlaying + include JSON::Serializable + + enum Type : UInt8 + Playing = 0 + Streaming = 1 + Listening = 2 + Watching = 3 + Custom = 4 + Competing = 5 + end + + property name : String? + @[JSON::Field(converter: Enum::ValueConverter(Discord::GamePlaying::Type))] + property type : Type? + property url : String? + property state : String? + property emoji : Emoji? + + def initialize( + @name = nil, + @type : Type? = nil, + @url = nil, + @state = nil, + @emoji = nil + ) + end + end + + struct Presence + include JSON::Serializable + + property user : PartialUser + property game : GamePlaying? + property status : String + property activities : Array(GamePlaying) + end +end diff --git a/lib/discordcr/src/discordcr/mappings/invite.cr b/lib/discordcr/src/discordcr/mappings/invite.cr new file mode 100644 index 0000000..3f9eaf0 --- /dev/null +++ b/lib/discordcr/src/discordcr/mappings/invite.cr @@ -0,0 +1,44 @@ +require "./converters" +require "./user" + +module Discord + struct Invite + include JSON::Serializable + + property code : String + property guild : InviteGuild + property channel : InviteChannel + end + + struct InviteMetadata + include JSON::Serializable + + property code : String + property guild : InviteGuild + property channel : InviteChannel + property inviter : User + property users : UInt32 + property max_uses : UInt32 + property max_age : UInt32 + property temporary : Bool + @[JSON::Field(converter: Discord::TimestampConverter)] + property created_at : Time + property revoked : Bool + end + + struct InviteGuild + include JSON::Serializable + + property id : Snowflake + property name : String + property splash_hash : String? + end + + struct InviteChannel + include JSON::Serializable + + property id : Snowflake + property name : String + property type : UInt8 + end +end diff --git a/lib/discordcr/src/discordcr/mappings/oauth2.cr b/lib/discordcr/src/discordcr/mappings/oauth2.cr new file mode 100644 index 0000000..945a073 --- /dev/null +++ b/lib/discordcr/src/discordcr/mappings/oauth2.cr @@ -0,0 +1,62 @@ +require "./converters" +require "./user" + +module Discord + # An OAuth2 application, as registered with Discord, that can hold + # information about a `Client`'s associated bot user account and owner, + # among other OAuth2 properties. + struct OAuth2Application + include JSON::Serializable + + property id : Snowflake + property name : String + property icon : String? + property description : String? + property rpc_origins : Array(String)? + property bot_public : Bool + property bot_require_code_grant : Bool + property owner : User + property summary : String + property verify_key : String + property team : Team? + property guild_id : Snowflake? + property primary_sku_id : String? + property slug : String? + property cover_image : String? + + # Produces a CDN URL for this application's icon in the given `format` and `size` + def icon_url(format : CDN::ApplicationIconFormat = CDN::ApplicationIconFormat::WebP, + size : Int32 = 128) + if icon = @icon + CDN.application_icon(id, icon, format, size) + end + end + end + + struct Team + include JSON::Serializable + + property icon : String? + property id : Snowflake + property members : Array(TeamMember) + property owner_user_id : Snowflake + end + + struct TeamMember + include JSON::Serializable + + property membership_state : TeamMembershipState + property permissions : Array(String) + property team_id : Snowflake + property user : User + end + + enum TeamMembershipState : UInt8 + Invited = 1 + Accepted = 2 + + def self.new(pull : JSON::PullParser) + TeamMembershipState.new(pull.read_int.to_u8) + end + end +end diff --git a/lib/discordcr/src/discordcr/mappings/permissions.cr b/lib/discordcr/src/discordcr/mappings/permissions.cr new file mode 100644 index 0000000..cd6e83c --- /dev/null +++ b/lib/discordcr/src/discordcr/mappings/permissions.cr @@ -0,0 +1,51 @@ +module Discord + @[Flags] + enum Permissions : UInt64 + CreateInstantInvite = 1 + KickMembers = 1 << 1 + BanMembers = 1 << 2 + Administrator = 1 << 3 + ManageChannels = 1 << 4 + ManageGuild = 1 << 5 + AddReactions = 1 << 6 + ViewAuditLog = 1 << 7 + PrioritySpeaker = 1 << 8 + Stream = 1 << 9 + ReadMessages = 1 << 10 + SendMessages = 1 << 11 + SendTTSMessages = 1 << 12 + ManageMessages = 1 << 13 + EmbedLinks = 1 << 14 + AttachFiles = 1 << 15 + ReadMessageHistory = 1 << 16 + MentionEveryone = 1 << 17 + UseExternalEmojis = 1 << 18 + Connect = 1 << 20 + Speak = 1 << 21 + MuteMembers = 1 << 22 + DeafenMembers = 1 << 23 + MoveMembers = 1 << 24 + UseVAD = 1 << 25 + ChangeNickname = 1 << 26 + ManageNicknames = 1 << 27 + ManageRoles = 1 << 28 + ManageWebhooks = 1 << 29 + ManageEmojis = 1 << 30 + UseApplicationCommands = 1 << 31 + RequestToSpeak = 1 << 32 + ManageThreads = 1 << 34 + UsePrivateThreads = 1 << 36 + UseExternalStickers = 1 << 37 + SendMessagesInThreads = 1 << 38 + UseEmbeddedActivities = 1 << 39 + ModerateMembers = 1 << 40 + + def self.new(pull : JSON::PullParser) + Permissions.new(pull.read_string.to_u64) + end + + def to_json(json : JSON::Builder) + json.string(value) + end + end +end diff --git a/lib/discordcr/src/discordcr/mappings/rest.cr b/lib/discordcr/src/discordcr/mappings/rest.cr new file mode 100644 index 0000000..a65cc6a --- /dev/null +++ b/lib/discordcr/src/discordcr/mappings/rest.cr @@ -0,0 +1,98 @@ +require "./converters" + +module Discord + module REST + # A response to the Get Gateway REST API call. + struct GatewayResponse + include JSON::Serializable + + property url : String + end + + # A response to the Get Gateway Bot REST API call. + struct GatewayBotResponse + include JSON::Serializable + + property url : String + property shards : Int32 + property session_start_limit : SessionStartLimit + end + + # Session start limit details included in the Get Gateway Bot REST API call. + struct SessionStartLimit + include JSON::Serializable + + property total : Int32 + property remaining : Int32 + @[JSON::Field(converter: Discord::TimeSpanMillisecondsConverter)] + property reset_after : Time::Span + end + + # A response to the Get Guild Prune Count REST API call. + struct PruneCountResponse + include JSON::Serializable + + property pruned : UInt32 + end + + # A response to the Get Guild Vanity URL REST API call. + struct GuildVanityURLResponse + include JSON::Serializable + + property code : String + end + + # A request payload to rearrange channels in a `Guild` by a REST API call. + struct ModifyChannelPositionPayload + @id : Snowflake + + def initialize(id : UInt64 | Snowflake, @position : Int32, + @parent_id : UInt64 | Snowflake | ChannelParent = ChannelParent::Unchanged, + @lock_permissions : Bool? = nil) + id = Snowflake.new(id) unless id.is_a?(Snowflake) + @id = id + end + + def to_json(builder : JSON::Builder) + builder.object do + builder.field("id") { @id.to_json(builder) } + + builder.field("position", @position) + + case parent = @parent_id + when UInt64, Snowflake + parent.to_json(builder) + when ChannelParent::None + builder.field("parent_id", nil) + when ChannelParent::Unchanged + # no field + end + + builder.field("lock_permissions", @lock_permissions) unless @lock_permissions.nil? + end + end + end + + # A request payload to rearrange roles in a `Guild` by a REST API call. + struct ModifyRolePositionPayload + include JSON::Serializable + + property id : Snowflake + property position : Int32 + + def initialize(id : UInt64 | Snowflake, @position : Int32) + id = Snowflake.new(id) unless id.is_a?(Snowflake) + @id = id + end + end + + # Response payload to a thread list request + struct ThreadsPayload + include JSON::Serializable + + property threads : Array(Channel) + property members : Array(ThreadMember) + property has_more : Bool? + end + end +end diff --git a/lib/discordcr/src/discordcr/mappings/user.cr b/lib/discordcr/src/discordcr/mappings/user.cr new file mode 100644 index 0000000..cd62f36 --- /dev/null +++ b/lib/discordcr/src/discordcr/mappings/user.cr @@ -0,0 +1,113 @@ +require "./converters" + +module Discord + struct User + include JSON::Serializable + + property username : String + property id : Snowflake + property discriminator : String + property avatar : String? + property email : String? + property bot : Bool? + property system : Bool? + property mfa_enabled : Bool? + property verified : Bool? + property member : PartialGuildMember? + property flags : UserFlags? + + # :nodoc: + def initialize(partial : PartialUser) + @username = partial.username.not_nil! + @id = partial.id + @discriminator = partial.discriminator.not_nil! + @avatar = partial.avatar + @email = partial.email + @bot = partial.bot + end + + # Produces a CDN URL to this user's avatar in the given `size`. + # If the user has an avatar a WebP will be returned, or a GIF + # if the avatar is animated. If the user has no avatar, a default + # avatar URL is returned. + def avatar_url(size : Int32 = 128) + if avatar = @avatar + CDN.user_avatar(id, avatar, size) + else + CDN.default_user_avatar(discriminator) + end + end + + # Produces a CDN URL to this user's avatar, in the given `format` and + # `size`. If the user has no avatar, a default avatar URL is returned. + def avatar_url(format : CDN::UserAvatarFormat, size : Int32 = 128) + if avatar = @avatar + CDN.user_avatar(id, avatar, format, size) + else + CDN.default_user_avatar(discriminator) + end + end + + # Produces a string to mention this user in a message + def mention + "<@#{id}>" + end + end + + @[Flags] + enum UserFlags : UInt32 + DiscordEmployee = 1 << 0 + PartneredServerOwner = 1 << 1 + HypeSquadEvents = 1 << 2 + BugHunterLevel1 = 1 << 3 + HouseBravery = 1 << 6 + HouseBrilliance = 1 << 7 + HouseBalance = 1 << 8 + EarlySupporter = 1 << 9 + TeamUser = 1 << 10 + System = 1 << 12 + BugHunterLevel2 = 1 << 14 + VerifiedBot = 1 << 16 + EarlyVerifiedBotDeveloper = 1 << 17 + CertifiedModerator = 1 << 18 + BotHttpInteractions = 1 << 19 + + def self.new(pull : JSON::PullParser) + UserFlags.new(pull.read_int.to_u32) + end + end + + struct PartialUser + include JSON::Serializable + + property username : String? + property id : Snowflake + property discriminator : String? + property avatar : String? + property email : String? + property bot : Bool? + + def full? : Bool + !@username.nil? && !@discriminator.nil? && !@avatar.nil? + end + end + + struct UserGuild + include JSON::Serializable + + property id : Snowflake + property name : String + property icon : String? + property owner : Bool + property permissions : Permissions + end + + struct Connection + include JSON::Serializable + + property id : Snowflake + property name : String + property type : String + property revoked : Bool + end +end diff --git a/lib/discordcr/src/discordcr/mappings/voice.cr b/lib/discordcr/src/discordcr/mappings/voice.cr new file mode 100644 index 0000000..f6f8fe2 --- /dev/null +++ b/lib/discordcr/src/discordcr/mappings/voice.cr @@ -0,0 +1,31 @@ +require "./converters" + +module Discord + struct VoiceState + include JSON::Serializable + + property guild_id : Snowflake? + property channel_id : Snowflake? + property user_id : Snowflake + property member : GuildMember? + property session_id : String + property deaf : Bool + property mute : Bool + property self_deaf : Bool + property self_mute : Bool + property suppress : Bool + + @[JSON::Field(converter: Discord::MaybeTimestampConverter)] + property request_to_speak_timestamp : Time? + end + + struct VoiceRegion + include JSON::Serializable + + property id : String + property name : String + property custom : Bool + property deprecated : Bool + property optimal : Bool + end +end diff --git a/lib/discordcr/src/discordcr/mappings/vws.cr b/lib/discordcr/src/discordcr/mappings/vws.cr new file mode 100644 index 0000000..3f50420 --- /dev/null +++ b/lib/discordcr/src/discordcr/mappings/vws.cr @@ -0,0 +1,107 @@ +require "./converters" + +module Discord + # :nodoc: + module VWS + struct IdentifyPacket + include JSON::Serializable + + property op : Int32 + property d : IdentifyPayload + + def initialize(server_id, user_id, session_id, token) + @op = Discord::VoiceClient::OP_IDENTIFY + @d = IdentifyPayload.new(server_id, user_id, session_id, token) + end + end + + struct IdentifyPayload + include JSON::Serializable + + property server_id : UInt64 + property user_id : UInt64 + property session_id : String + property token : String + + def initialize(@server_id, @user_id, @session_id, @token) + end + end + + struct SelectProtocolPacket + include JSON::Serializable + + property op : Int32 + property d : SelectProtocolPayload + + def initialize(protocol, data) + @op = Discord::VoiceClient::OP_SELECT_PROTOCOL + @d = SelectProtocolPayload.new(protocol, data) + end + end + + struct SelectProtocolPayload + include JSON::Serializable + + property protocol : String + property data : ProtocolData + + def initialize(@protocol, @data) + end + end + + struct ProtocolData + include JSON::Serializable + + property address : String + property port : UInt16 + property mode : String + + def initialize(@address, @port, @mode) + end + end + + struct ReadyPayload + include JSON::Serializable + + property ssrc : Int32 + property port : Int32 + property modes : Array(String) + property ip : String + end + + struct SessionDescriptionPayload + include JSON::Serializable + + property secret_key : Array(UInt8) + property mode : String + end + + struct SpeakingPacket + include JSON::Serializable + + property op : Int32 + property d : SpeakingPayload + + def initialize(speaking, delay) + @op = Discord::VoiceClient::OP_SPEAKING + @d = SpeakingPayload.new(speaking, delay) + end + end + + struct SpeakingPayload + include JSON::Serializable + + property speaking : Bool + property delay : Int32 + + def initialize(@speaking, @delay) + end + end + + struct HelloPayload + include JSON::Serializable + + property heartbeat_interval : Float32 + end + end +end diff --git a/lib/discordcr/src/discordcr/mappings/webhook.cr b/lib/discordcr/src/discordcr/mappings/webhook.cr new file mode 100644 index 0000000..7afa937 --- /dev/null +++ b/lib/discordcr/src/discordcr/mappings/webhook.cr @@ -0,0 +1,16 @@ +require "./converters" +require "./user" + +module Discord + struct Webhook + include JSON::Serializable + + property id : Snowflake + property guild_id : Snowflake? + property channel_id : Snowflake + property user : User? + property name : String + property avatar : String? + property token : String + end +end diff --git a/lib/discordcr/src/discordcr/mention.cr b/lib/discordcr/src/discordcr/mention.cr new file mode 100644 index 0000000..1df9f56 --- /dev/null +++ b/lib/discordcr/src/discordcr/mention.cr @@ -0,0 +1,135 @@ +module Discord::Mention + record User, id : UInt64, start : Int32, size : Int32 + + record Role, id : UInt64, start : Int32, size : Int32 + + record Channel, id : UInt64, start : Int32, size : Int32 + + record Emoji, animated : Bool, name : String, id : UInt64, start : Int32, size : Int32 + + record Everyone, start : Int32 do + def size + 9 + end + end + + record Here, start : Int32 do + def size + 5 + end + end + + alias MentionType = User | Role | Channel | Emoji | Everyone | Here + + # Returns an array of mentions found in a string + def self.parse(string : String) + Parser.new(string).parse + end + + # Parses a string for mentions, yielding for each mention found + def self.parse(string : String, &block : MentionType ->) + Parser.new(string).parse(&block) + end + + # :nodoc: + class Parser + def initialize(@string : String) + @reader = Char::Reader.new string + end + + delegate has_next?, pos, current_char, next_char, peek_next_char, to: @reader + + def parse(&block : MentionType ->) + while has_next? + start = pos + animated = false + + case current_char + when '<' + case next_char + when '@' + case peek_next_char + when '&' + next_char # Skip role mention indicator + + if next_char.ascii_number? + snowflake = scan_snowflake(pos) + yield Role.new(snowflake, start, pos - start + 1) if has_next? && current_char == '>' + end + when .ascii_number?, '!' + next_char # Skip mention indicator + next_char if current_char == '!' # Skip optional nickname indicator + + if current_char.ascii_number? + snowflake = scan_snowflake(pos) + yield User.new(snowflake, start, pos - start + 1) if current_char == '>' + end + else + # Continue parsing. + end + when '#' + next_char # Skip channel mention indicator + + if peek_next_char.ascii_number? + snowflake = scan_snowflake(pos) + yield Channel.new(snowflake, start, pos - start + 1) if current_char == '>' + end + when ':', 'a' + if current_char == 'a' + next unless peek_next_char == ':' + animated = true + next_char + end + next_char + + name = scan_word(pos) + if current_char == ':' && peek_next_char.ascii_number? + next_char + snowflake = scan_snowflake(pos) + yield Emoji.new(animated, name, snowflake, start, pos - start + 1) if current_char == '>' + end + else + # Continue parsing. + end + when '@' + word = scan_word(pos) + case word + when "@everyone" + yield Everyone.new(start) + when "@here" + yield Here.new(start) + else + # Continue parsing. + end + else + next_char + end + end + end + + def parse + results = [] of MentionType + parse { |mention| results << mention } + results + end + + private def scan_snowflake(start) + while next_char.ascii_number? + # Nothing to do + end + @string[start..pos - 1].to_u64 + end + + private def scan_word(start) + while has_next? + case next_char + when .ascii_letter?, .ascii_number? + # Nothing to do + else + break + end + end + @string[start..pos - 1] + end + end +end diff --git a/lib/discordcr/src/discordcr/paginator.cr b/lib/discordcr/src/discordcr/paginator.cr new file mode 100644 index 0000000..898e149 --- /dev/null +++ b/lib/discordcr/src/discordcr/paginator.cr @@ -0,0 +1,39 @@ +module Discord + class Paginator(T) + include ::Enumerable(T) + + enum Direction + Up + Down + end + + def initialize(@limit : Int32?, @direction : Direction, + &@block : Array(T)? -> Array(T)) + @count = 0 + end + + def each + last_page = nil + loop do + page = @block.call(last_page) + return if page.empty? + + if @direction.up? + page.reverse_each do |item| + yield(item) + @count += 1 + @limit.try { |l| return if @count >= l } + end + else + page.each do |item| + yield(item) + @count += 1 + @limit.try { |l| return if @count >= l } + end + end + + last_page = page + end + end + end +end diff --git a/lib/discordcr/src/discordcr/rest.cr b/lib/discordcr/src/discordcr/rest.cr new file mode 100644 index 0000000..f14c539 --- /dev/null +++ b/lib/discordcr/src/discordcr/rest.cr @@ -0,0 +1,2259 @@ +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/discordcr/discordcr, #{Discord::VERSION})" + API_BASE = "https://discord.com/api/v9" + + Log = Discord::Log.for("rest") + + 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 : Snowflake | UInt64 | Nil, 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 + headers["X-RateLimit-Precision"] = "millisecond" + + request_done = false + rate_limit_key = {route_key: route_key, major_parameter: major_parameter.try(&.to_u64)} + + 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 { } + + Log.info { "[HTTP OUT] #{method} #{path} (#{body.try &.size || 0} bytes)" } + Log.debug { "[HTTP OUT] BODY: #{body}" } + + response = HTTP::Client.exec(method: method, url: API_BASE + path, headers: headers, body: body, tls: SSL_CONTEXT) + + Log.info { "[HTTP IN] #{response.status_code} #{response.status_message} (#{response.body.size} bytes)" } + Log.debug { "[HTTP IN] BODY: #{response.body}" } + + if response.status_code == 429 || response.headers["X-RateLimit-Remaining"]? == "0" + retry_after_value = response.headers["X-RateLimit-Reset-After"]? || response.headers["Retry-After"]? + retry_after = retry_after_value.not_nil!.to_f + + if response.headers["X-RateLimit-Global"]? + Log.warn { "Global rate limit exceeded! Pausing all requests for #{retry_after}" } + global_mutex.synchronize { sleep retry_after } + else + Log.warn { "Pausing requests for #{rate_limit_key[:route_key]} in #{rate_limit_key[:major_parameter]} for #{retry_after}" } + 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 : Snowflake | UInt64 | Nil, 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://discord.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://discord.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://discord.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://discord.com/developers/docs/resources/channel#get-channel) + def get_channel(channel_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/channel#modify-channel) + def modify_channel(channel_id : UInt64 | Snowflake, name : String? = nil, position : UInt32? = nil, + topic : String? = nil, bitrate : UInt32? = nil, user_limit : UInt32? = nil, + nsfw : Bool? = nil, rate_limit_per_user : Int32? = nil, + default_auto_archive_duration : AutoArchiveDuration? = nil, archived : Bool? = nil, + locked : Bool? = nil, invitable : Bool? = nil, video_quality_mode : VideoQualityMode? = nil, + rtc_region : String? = nil) + json = encode_tuple( + name: name, + position: position, + topic: topic, + bitrate: bitrate, + user_limit: user_limit, + nsfw: nsfw, + rate_limit_per_user: rate_limit_per_user, + default_auto_archive_duration: default_auto_archive_duration, + archived: archived, + locked: locked, + invitable: invitable, + video_quality_mode: video_quality_mode, + rtc_region: rtc_region, + ) + + 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, or "Manage Threads" if the channel is a thread. + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#deleteclose-channel) + def delete_channel(channel_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/channel#get-channel-messages) + def get_channel_messages(channel_id : UInt64 | Snowflake, limit : Int32 = 50, before : UInt64 | Snowflake | Nil = nil, after : UInt64 | Snowflake | Nil = nil, around : UInt64 | Snowflake | Nil = 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 + + # Returns a `Paginator` over a channel's message history. Requires the + # "Read Message History" permission. See `get_channel_messages`. + # + # Will yield a channels message history in the given `direction` starting at + # `start_id` until `limit` number of messages are observed, or the channel has + # no further history. Setting `limit` to `nil` will have the paginator continue + # to make requests until all messages are fetched in the given `direction`. + def page_channel_messages(channel_id : UInt64 | Snowflake, start_id : UInt64 | Snowflake = 0_u64, + limit : Int32? = 100, direction : Paginator::Direction = Paginator::Direction::Down, + page_size : Int32 = 100) + Paginator(Message).new(limit, direction ^ Paginator::Direction::Down) do |last_page| + if direction.up? + next_id = last_page.try &.last.id || start_id + get_channel_messages(channel_id, page_size, before: next_id) + else + next_id = last_page.try &.first.id || start_id + get_channel_messages(channel_id, page_size, after: next_id) + end + end + end + + # Gets a single message from the channel's history. Requires the "Read + # Message History" permission. + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#get-channel-message) + def get_channel_message(channel_id : UInt64 | Snowflake, message_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/channel#create-message) + # + # The `embeds` parameter can be used to display up to 10 rich embeds to a message, + # which allows for displaying certain kinds of data in a more structured way. + # + # The `embed` field can be used to display only one rich embed, but this is deprecated + # and will be removed in a later version. Use `embeds` instead. + # + # 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.utc, + # 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.", embeds: [embed]) + # ``` + # + # For more details on the format of the `embed` object, look at the + # [relevant documentation](https://discord.com/developers/docs/resources/channel#embed-object). + def create_message(channel_id : UInt64 | Snowflake, content : String, embed : Embed? = nil, tts : Bool = false, + nonce : Int64 | String? = nil, allowed_mentions : AllowedMentions? = nil, message_reference : MessageReference? = nil, embeds : Array(Embed)? = nil) + embed_value = embeds + if embed_value.nil? && embed + embed_value = [embed] + end + json = encode_tuple( + content: content, + embeds: embed_value, + tts: tts, + nonce: nonce, + allowed_mentions: allowed_mentions, + message_reference: message_reference + ) + + response = request( + :channels_cid_messages, + channel_id, + "POST", + "/channels/#{channel_id}/messages", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + Message.from_json(response.body) + end + + # Start Thread from existing message + # When called on a GUILD_TEXT channel, creates a GUILD_PUBLIC_THREAD. + # When called on a GUILD_NEWS channel, creates a GUILD_NEWS_THREAD. + # The id of the created thread will be the same as the id of the message, and as such a message can only have a single thread created from it. + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#start-thread-with-message) + def start_thread(channel_id : UInt64 | Snowflake, message_id : UInt64 | Snowflake, name : String, + auto_archive_duration : AutoArchiveDuration, reason : String? = nil) + json = encode_tuple( + name: name, + auto_archive_duration: auto_archive_duration + ) + + headers = HTTP::Headers{ + "Content-Type" => "application/json", + } + headers["X-Audit-Log-Reason"] = reason if reason + + response = request( + :channels_cid_threads, + channel_id, + "POST", + "/channels/#{channel_id}/messages/#{message_id}/threads", + headers, + json + ) + + Channel.from_json(response.body) + end + + # Start Thread without Message + # Creates a new thread that is not connected to an existing message. + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#start-thread-without-message) + def start_thread(channel_id : UInt64 | Snowflake, name : String, auto_archive_duration : AutoArchiveDuration, + type : ChannelType? = ChannelType::GuildPublicThread, invitable : Bool? = nil, reason : String? = nil) + json = encode_tuple( + name: name, + auto_archive_duration: auto_archive_duration, + type: type, + invitable: invitable + ) + + headers = HTTP::Headers{ + "Content-Type" => "application/json", + } + headers["X-Audit-Log-Reason"] = reason if reason + + response = request( + :channels_cid_threads, + channel_id, + "POST", + "/channels/#{channel_id}/threads", + headers, + json + ) + + Channel.from_json(response.body) + end + + # Join Thread + # Adds the current user to a thread. Also requires the thread is not archived. + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#join-thread) + def join_thread(channel_id : UInt64 | Snowflake) + request( + :channels_cid_thread_members, + channel_id, + "PUT", + "/channels/#{channel_id}/thread-members/@me", + HTTP::Headers.new, + nil + ) + end + + # Add Thread Member + # Adds another member to a thread. Requires the ability to send messages in the thread. + # Also requires the thread is not archived. + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#add-thread-member) + def add_thread_member(channel_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake) + request( + :channels_cid_thread_members, + channel_id, + "PUT", + "/channels/#{channel_id}/thread-members/#{user_id}", + HTTP::Headers.new, + nil + ) + end + + # Leave Thread + # Removes the current user from a thread. Also requires the thread is not archived. + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#leave-thread) + def leave_thread(channel_id : UInt64 | Snowflake) + request( + :channels_cid_thread_members, + channel_id, + "DELETE", + "/channels/#{channel_id}/thread-members/@me", + HTTP::Headers.new, + nil + ) + end + + # Remove Thread Member + # Removes another member from a thread. Requires the MANAGE_THREADS permission, or the creator of the thread if it is a GUILD_PRIVATE_THREAD. + # Also requires the thread is not archived. + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#remove-thread-member) + def remove_thread_member(channel_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake) + request( + :channels_cid_thread_members, + channel_id, + "DELETE", + "/channels/#{channel_id}/thread-members/#{user_id}", + HTTP::Headers.new, + nil + ) + end + + # List Thread Members + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#list-thread-members) + def list_thread_members(channel_id : UInt64 | Snowflake) + response = request( + :channels_cid_thread_members, + channel_id, + "GET", + "/channels/#{channel_id}/thread-members", + HTTP::Headers.new, + nil + ) + + Array(ThreadMember).from_json(response.body) + end + + # List Active Threads + # + # [API docs for this method](https://discord.com/developers/docs/resources/guild#list-active-threads) + def list_active_threads(guild_id : UInt64 | Snowflake) + response = request( + :guilds_gid_threads, + guild_id, + "GET", + "/guilds/#{guild_id}/threads/active", + HTTP::Headers.new, + nil + ) + + ThreadsPayload.from_json(response.body) + end + + # List Public Archived Threads + # Returns archived threads in the channel that are public. + # When called on a GUILD_TEXT channel, returns threads of type GUILD_PUBLIC_THREAD. When called on a GUILD_NEWS channel returns threads of type GUILD_NEWS_THREAD. + # Threads are ordered by archive_timestamp, in descending order. Requires the READ_MESSAGE_HISTORY permission. + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#list-public-archived-threads) + def list_public_archived_threads(channel_id : UInt64 | Snowflake, before : Time? = nil, limit : Int32? = nil) + path = "/channels/#{channel_id}/threads/archived/public" + path += "&before=#{before}" if before + path += "&limit=#{limit}" if limit + + response = request( + :channels_cid_threads, + channel_id, + "GET", + path, + HTTP::Headers.new, + nil + ) + + ThreadsPayload.from_json(response.body) + end + + # List Private Archived Threads + # Returns archived threads in the channel that are of type GUILD_PRIVATE_THREAD. + # Threads are ordered by archive_timestamp, in descending order. + # Requires both the READ_MESSAGE_HISTORY and MANAGE_THREADS permissions. + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#list-private-archived-threads) + def list_private_archived_threads(channel_id : UInt64 | Snowflake, before : Time? = nil, limit : Int32? = nil) + path = "/channels/#{channel_id}/threads/archived/private" + path += "&before=#{before}" if before + path += "&limit=#{limit}" if limit + + response = request( + :channels_cid_threads, + channel_id, + "GET", + path, + HTTP::Headers.new, + nil + ) + + ThreadsPayload.from_json(response.body) + end + + # List Joined Private Archived Threads + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#list-joined-private-archived-threads) + def list_joined_private_threads(channel_id : UInt64 | Snowflake, before : Time? = nil, limit : Int32? = nil) + path = "/channels/#{channel_id}/users/@me/threads/archived/private" + path += "&before=#{before}" if before + path += "&limit=#{limit}" if limit + + response = request( + :channels_cid_threads, + channel_id, + "GET", + path, + HTTP::Headers.new, + nil + ) + + ThreadsPayload.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. + # Requires the "Read Message History" permission and additionally + # the "Add Reactions" permission if no one has reacted with this emoji yet. + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#create-reaction) + def create_reaction(channel_id : UInt64 | Snowflake, message_id : UInt64 | Snowflake, emoji : String) + response = request( + :channels_cid_messages_mid_reactions_emoji_me, + channel_id, + "PUT", + "/channels/#{channel_id}/messages/#{message_id}/reactions/#{URI.encode(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://discord.com/developers/docs/resources/channel#delete-own-reaction) + def delete_own_reaction(channel_id : UInt64 | Snowflake, message_id : UInt64 | Snowflake, emoji : String) + request( + :channels_cid_messages_mid_reactions_emoji_me, + channel_id, + "DELETE", + "/channels/#{channel_id}/messages/#{message_id}/reactions/#{URI.encode(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://discord.com/developers/docs/resources/channel#delete-user-reaction) + def delete_user_reaction(channel_id : UInt64 | Snowflake, message_id : UInt64 | Snowflake, emoji : String, user_id : UInt64 | Snowflake) + request( + :channels_cid_messages_mid_reactions_emoji_uid, + channel_id, + "DELETE", + "/channels/#{channel_id}/messages/#{message_id}/reactions/#{URI.encode(emoji)}/#{user_id}", + HTTP::Headers.new, + nil + ) + end + + # Returns all users that have reacted with a specific emoji. + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#get-reactions) + def get_reactions(channel_id : UInt64 | Snowflake, message_id : UInt64 | Snowflake, emoji : String) + response = request( + :channels_cid_messages_mid_reactions_emoji_me, + channel_id, + "GET", + "/channels/#{channel_id}/messages/#{message_id}/reactions/#{URI.encode(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://discord.com/developers/docs/resources/channel#delete-all-reactions) + def delete_all_reactions(channel_id : UInt64 | Snowflake, message_id : UInt64 | Snowflake) + request( + :channels_cid_messages_mid_reactions, + channel_id, + "DELETE", + "/channels/#{channel_id}/messages/#{message_id}/reactions", + HTTP::Headers.new, + nil + ) + end + + # Removes all reactions for a given emoji from a message. Requires the "Manage Messages" + # permission. + # + # [API Docs for this method]() + def delete_reaction(channel_id : UInt64 | Snowflake, message_id : UInt64 | Snowflake, emoji : String) + request( + :channels_cid_messages_mid_reactions_emoji, + channel_id, + "DELETE", + "/channels/#{channel_id}/messages/#{message_id}/reactions/#{URI.encode(emoji)}", + 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://discord.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 | Snowflake, content : String?, file : IO, filename : String? = nil, + embed : Embed? = nil, allowed_mentions : AllowedMentions? = nil, spoiler : Bool = false) + io = IO::Memory.new + + unless filename + if file.is_a? File + filename = File.basename(file.path) + else + filename = "" + end + end + + if spoiler && !filename.starts_with?("SPOILER_") + filename = "SPOILER_" + filename + end + + builder = HTTP::FormData::Builder.new(io) + builder.file("file", file, HTTP::FormData::FileMetadata.new(filename: filename)) + if content || embed + json = encode_tuple( + content: content, + embed: embed, + allowed_mentions: allowed_mentions + ) + builder.field("payload_json", json) + end + 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://discord.com/developers/docs/resources/channel#edit-message) + def edit_message(channel_id : UInt64 | Snowflake, message_id : UInt64 | Snowflake, content : String, + embed : Embed? = nil, allowed_mentions : AllowedMentions? = nil) + json = encode_tuple( + content: content, + embed: embed, + allowed_mentions: allowed_mentions + ) + + response = request( + :channels_cid_messages_mid, + channel_id, + "PATCH", + "/channels/#{channel_id}/messages/#{message_id}", + HTTP::Headers{"Content-Type" => "application/json"}, + 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://discord.com/developers/docs/resources/channel#delete-message) + def delete_message(channel_id : UInt64 | Snowflake, message_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/channel#bulk-delete-messages) + def bulk_delete_messages(channel_id : UInt64 | Snowflake, message_ids : Array(UInt64 | Snowflake)) + 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 Roles" permission. + # + # [API docs for this method](https://discord.com/developers/docs/resources/channel#edit-channel-permissions) + def edit_channel_permissions(channel_id : UInt64 | Snowflake, overwrite_id : UInt64 | Snowflake, + 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://discord.com/developers/docs/resources/channel#get-channel-invites) + def get_channel_invites(channel_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/channel#create-channel-invite) + def create_channel_invite(channel_id : UInt64 | Snowflake, 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://discord.com/developers/docs/resources/channel#delete-channel-permission) + def delete_channel_permission(channel_id : UInt64 | Snowflake, overwrite_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/channel#trigger-typing-indicator) + def trigger_typing_indicator(channel_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/channel#get-pinned-messages) + def get_pinned_messages(channel_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/channel#add-pinned-channel-message) + def add_pinned_channel_message(channel_id : UInt64 | Snowflake, message_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/channel#delete-pinned-channel-message) + def delete_pinned_channel_message(channel_id : UInt64 | Snowflake, message_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/guild#get-guild) + def get_guild(guild_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/guild#modify-guild) + def modify_guild(guild_id : UInt64 | Snowflake, name : String? = nil, region : String? = nil, + verification_level : UInt8? = nil, afk_channel_id : UInt64 | Snowflake | Nil = nil, + afk_timeout : Int32? = nil, icon : String? = nil, owner_id : UInt64 | Snowflake | Nil = nil, + splash : String? = nil, reason : 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 + ) + + headers = HTTP::Headers{ + "Content-Type" => "application/json", + } + headers["X-Audit-Log-Reason"] = reason if reason + + response = request( + :guilds_gid, + guild_id, + "PATCH", + "/guilds/#{guild_id}", + headers, + json + ) + + Guild.from_json(response.body) + end + + # Deletes a guild. Requires the bot to be the server owner. + # + # [API docs for this method](https://discord.com/developers/docs/resources/guild#delete-guild) + def delete_guild(guild_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/emoji#list-guild-emojis) + def list_guild_emojis(guild_id : UInt64 | Snowflake) + response = request( + :guilds_gid_emojis, + guild_id, + "GET", + "/guilds/#{guild_id}/emojis", + HTTP::Headers.new, + nil + ) + + Array(Emoji).from_json(response.body) + end + + # Gets a specific emoji by guild ID and emoji ID. + # + # [API docs for this method](https://discord.com/developers/docs/resources/emoji#get-guild-emoji) + def get_guild_emoji(guild_id : UInt64 | Snowflake, emoji_id : UInt64 | Snowflake) + response = request( + :guilds_gid_emojis_eid, + guild_id, + "GET", + "/guilds/#{guild_id}/emojis/#{emoji_id}", + HTTP::Headers.new, + nil + ) + + Emoji.from_json(response.body) + end + + # Modifies a guild emoji. Requires the "Manage Emojis" permission. + # + # [API docs for this method](https://discord.com/developers/docs/resources/emoji#modify-guild-emoji) + def modify_guild_emoji(guild_id : UInt64 | Snowflake, emoji_id : UInt64 | Snowflake, name : String) + response = request( + :guilds_gid_emojis_eid, + guild_id, + "PATCH", + "/guilds/#{guild_id}/emojis/#{emoji_id}", + HTTP::Headers{"Content-Type" => "application/json"}, + {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://discord.com/developers/docs/resources/emoji#create-guild-emoji) + def create_guild_emoji(guild_id : UInt64 | Snowflake, name : String, image : String) + json = encode_tuple( + name: name, + image: image, + ) + + response = request( + :guilds_gid_emojis, + guild_id, + "POST", + "/guilds/#{guild_id}/emojis", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + Emoji.from_json(response.body) + end + + # Deletes a guild emoji. Requires the "Manage Emojis" permission. + # + # [API docs for this method](https://discord.com/developers/docs/resources/emoji#delete-guild-emoji) + def delete_guild_emoji(guild_id : UInt64 | Snowflake, emoji_id : UInt64 | Snowflake) + request( + :guilds_gid_emojis_eid, + guild_id, + "DELETE", + "/guilds/#{guild_id}/emojis/#{emoji_id}", + HTTP::Headers.new, + nil + ) + end + + # Gets a list of channels in a guild. + # + # [API docs for this method](https://discord.com/developers/docs/resources/guild#get-guild-channels) + def get_guild_channels(guild_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/guild#create-guild-channel) + def create_guild_channel(guild_id : UInt64 | Snowflake, name : String, type : ChannelType, topic : String? = nil, + bitrate : UInt32? = nil, user_limit : UInt32? = nil, rate_limit_per_user : Int32? = nil, + position : UInt32? = nil, parent_id : UInt64? | Snowflake? = nil, nsfw : Bool? = nil, + reason : String? = nil) + json = encode_tuple( + name: name, + type: type, + topic: topic, + bitrate: bitrate, + user_limit: user_limit, + rate_limit_per_user: rate_limit_per_user, + position: position, + parent_id: parent_id, + nsfw: nsfw + ) + + headers = HTTP::Headers{ + "Content-Type" => "application/json", + } + headers["X-Audit-Log-Reason"] = reason if reason + + response = request( + :guilds_gid_channels, + guild_id, + "POST", + "/guilds/#{guild_id}/channels", + headers, + json + ) + + Channel.from_json(response.body) + end + + # Creates a new Stage instance associated to a Stage channel. + # + # [API docs for this method](https://discord.com/developers/docs/resources/stage-instance#create-stage-instance) + def create_stage_instance(channel_id : UInt64 | Snowflake, topic : String, privacy_level : StagePrivacyLevel = StagePrivacyLevel::GUILD_ONLY) + json = encode_tuple( + channel_id: channel_id, + topic: topic, + privacy_level: privacy_level + ) + + response = request( + :stage_instances_cid, + channel_id, + "POST", + "/stage-instances", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + StageInstance.from_json(response.body) + end + + # Gets the Stage instance associated with the passed Stage channel if it exists. + # + # [API docs for this method](https://discord.com/developers/docs/resources/stage-instance#get-stage-instance) + def get_stage_instance(channel_id : UInt64 | Snowflake) + response = request( + :stage_instances_cid, + channel_id, + "GET", + "/stage-instances/#{channel_id}", + HTTP::Headers.new, + nil + ) + + StageInstance.from_json(response.body) + end + + # Updates fields of an existing Stage instance. + # + # [API docs for this method](https://discord.com/developers/docs/resources/stage-instance#update-stage-instance) + def update_stage_instance(channel_id : UInt64 | Snowflake, topic : String? = nil, privacy_level : StagePrivacyLevel? = nil) + json = encode_tuple( + topic: topic, + privacy_level: privacy_level + ) + + response = request( + :stage_instances_cid, + channel_id, + "PATCH", + "/stage-instances/#{channel_id}", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + + StageInstance.from_json(response.body) + end + + # Deletes the Stage instance associated with the passed Stage channel. + # + # [API docs for this method](https://discord.com/developers/docs/resources/stage-instance#delete-stage-instance) + def delete_stage_instance(channel_id : UInt64 | Snowflake) + request( + :stage_instances_cid, + channel_id, + "DELETE", + "/stage-instances/#{channel_id}", + HTTP::Headers.new, + nil + ) + 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 | Snowflake) + 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 | Snowflake, code : String) + request( + :guilds_gid_vanityurl, + guild_id, + "PATCH", + "/guilds/#{guild_id}/vanity-url", + HTTP::Headers{"Content-Type" => "application/json"}, + {code: code}.to_json + ) + end + + # Updates the current user's, or passed user's voice state. + # For use with Stage Channels only. + # The user that is being updated must be inside of the stage channel. + # Requires "MUTE_MEMBERS" to (un)suppress other members, you can always suppress yourself. + # Requires "REQUEST_TO_SPEAK" to request to speak. + # + # [API docs for this method](https://discord.com/developers/docs/resources/guild#update-current-user-voice-state) + def modify_voice_state(guild_id : UInt64 | Snowflake, + channel_id : UInt64 | Snowflake, + user_id : UInt64 | Snowflake | Nil = nil, + suppress : Bool? = nil, + request_to_speak_timestamp : Time? = nil) + json = encode_tuple( + channel_id: channel_id, + suppress: suppress, + request_to_speak_timestamp: request_to_speak_timestamp, + ) + + request( + :guilds_gid_voicestate, + guild_id, + "PATCH", + "/guilds/#{guild_id}/voice-states/#{user_id || "@me"}", + HTTP::Headers{"Content-Type" => "application/json"}, + json + ) + end + + # Modifies the position of channels within a guild. Requires the + # "Manage Channels" permission. + # + # [API docs for this method](https://discord.com/developers/docs/resources/guild#modify-guild-channel-positions) + def modify_guild_channel_positions(guild_id : UInt64 | Snowflake, + 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://discord.com/developers/docs/resources/guild#get-guild-member) + def get_guild_member(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/guild#list-guild-members) + def list_guild_members(guild_id : UInt64 | Snowflake, limit : Int32 = 1000, after : UInt64 | Snowflake = 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 + + # Returns a `Paginator` over the given guilds members. + # + # Will yield members starting at `start_id` until `limit` number of members + # guilds are observed, or the user has no further guilds. Setting `limit` + # to `nil` will have the paginator continue to make requests until all members + # are fetched. + def page_guild_members(guild_id : UInt64 | Snowflake, start_id : UInt64 | Snowflake = 0_u64, + limit : Int32? = 1000, page_size : Int32 = 1000) + Paginator(GuildMember).new(limit, Paginator::Direction::Down) do |last_page| + next_id = last_page.try &.last.user.id || start_id + list_guild_members(guild_id, page_size, next_id) + end + 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://discord.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 + ) + + if response.status_code == 201 + GuildMember.from_json(response.body) + else + nil + end + 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. + # + # To remove a member's nickname, you can send an empty string for the `nick` argument. + # To set or remove a timeout, you must have the `MANAGE_MEMBERS` permission. + # The `communication_disabled_until` argument takes a time up to 28 days in the future, or nil to remove. + # + # [API docs for this method](https://discord.com/developers/docs/resources/guild#modify-guild-member) + def modify_guild_member(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake, nick : String? = nil, + roles : Array(UInt64 | Snowflake)? = nil, mute : Bool? = nil, deaf : Bool? = nil, + channel_id : UInt64 | Snowflake | Nil = nil, communication_disabled_until : Time? = nil, + reason : String? = nil) + json = encode_tuple( + nick: nick, + roles: roles, + mute: mute, + deaf: deaf, + channel_id: channel_id, + communication_disabled_until: communication_disabled_until + ) + + headers = HTTP::Headers{ + "Content-Type" => "application/json", + } + headers["X-Audit-Log-Reason"] = reason if reason + + request( + :guilds_gid_members_uid, + guild_id, + "PATCH", + "/guilds/#{guild_id}/members/#{user_id}", + headers, + 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://discord.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://discord.com/developers/docs/resources/guild#remove-guild-member) + def remove_guild_member(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake, reason : String? = nil) + headers = HTTP::Headers.new + headers["X-Audit-Log-Reason"] = reason if reason + + request( + :guilds_gid_members_uid, + guild_id, + "DELETE", + "/guilds/#{guild_id}/members/#{user_id}", + headers, + nil + ) + end + + # Adds a role to a member. Requires the "Manage Roles" permission. + # + # [API docs for this method](https://discord.com/developers/docs/resources/guild#add-guild-member-role) + def add_guild_member_role(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake, role_id : UInt64 | Snowflake) + 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://discord.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://discord.com/developers/docs/resources/guild#get-guild-bans) + def get_guild_bans(guild_id : UInt64 | Snowflake) + response = request( + :guilds_gid_bans, + guild_id, + "GET", + "/guilds/#{guild_id}/bans", + HTTP::Headers.new, + nil + ) + + Array(GuildBan).from_json(response.body) + end + + # Returns information about a banned user in a guild. Requires the "Ban Members" + # permission. + # + # [API docs for this method](https://discord.com/developers/docs/resources/guild#get-guild-ban) + def get_guild_ban(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake, reason : String? = nil) + headers = HTTP::Headers.new + headers["X-Audit-Log-Reason"] = reason if reason + + response = request( + :guilds_gid_bans_uid, + guild_id, + "GET", + "/guilds/#{guild_id}/bans/#{user_id}", + headers, + nil + ) + + GuildBan.from_json(response.body) + end + + # Bans a member from the guild. Requires the "Ban Members" permission. + # + # [API docs for this method](https://discord.com/developers/docs/resources/guild#create-guild-ban) + def create_guild_ban(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake, + delete_message_days : Int32? = nil, reason : String? = nil) + json = encode_tuple( + delete_message_days: delete_message_days, + reason: reason, + ) + + request( + :guilds_gid_bans_uid, + guild_id, + "PUT", + "/guilds/#{guild_id}/bans/#{user_id}", + HTTP::Headers{"Content-Type" => "application/json"}, + json, + ) + end + + # Unbans a member from the guild. Requires the "Ban Members" permission. + # + # [API docs for this method](https://discord.com/developers/docs/resources/guild#remove-guild-ban) + def remove_guild_ban(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake, reason : String? = nil) + headers = HTTP::Headers.new + headers["X-Audit-Log-Reason"] = reason if reason + + request( + :guilds_gid_bans_uid, + guild_id, + "DELETE", + "/guilds/#{guild_id}/bans/#{user_id}", + headers, + nil + ) + end + + # Get a list of roles on the guild. + # + # [API docs for this method](https://discord.com/developers/docs/resources/guild#get-guild-roles) + def get_guild_roles(guild_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/guild#create-guild-role) + def create_guild_role(guild_id : UInt64 | Snowflake, name : String? = nil, + permissions : Permissions? = nil, colour : UInt32 = 0_u32, + hoist : Bool = false, mentionable : Bool = false, reason : String? = nil) + json = encode_tuple( + name: name, + permissions: permissions, + color: colour, + hoist: hoist, + mentionable: mentionable + ) + + headers = HTTP::Headers{ + "Content-Type" => "application/json", + } + headers["X-Audit-Log-Reason"] = reason if reason + + response = request( + :get_guild_roles, + guild_id, + "POST", + "/guilds/#{guild_id}/roles", + headers, + 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://discord.com/developers/docs/resources/guild#modify-guild-role) + def modify_guild_role(guild_id : UInt64 | Snowflake, role_id : UInt64 | Snowflake, name : String? = nil, + permissions : Permissions? = nil, colour : UInt32? = nil, + position : Int32? = nil, hoist : Bool? = nil, reason : String? = nil) + json = encode_tuple( + name: name, + permissions: permissions, + color: colour, + position: position, + hoist: hoist + ) + + headers = HTTP::Headers{ + "Content-Type" => "application/json", + } + headers["X-Audit-Log-Reason"] = reason if reason + + response = request( + :guilds_gid_roles_rid, + guild_id, + "PATCH", + "/guilds/#{guild_id}/roles/#{role_id}", + headers, + json + ) + + Role.from_json(response.body) + end + + # Changes the position of roles. Requires the "Manage Roles" permission + # and you cannot raise roles above the bot's highest role. + # + # [API docs for this method](https://discord.com/developers/docs/resources/guild#modify-guild-role-positions) + def modify_guild_role_positions(guild_id : UInt64 | Snowflake, + positions : Array(ModifyRolePositionPayload)) + response = request( + :guilds_gid_roles, + guild_id, + "PATCH", + "/guilds/#{guild_id}/roles", + HTTP::Headers{"Content-Type" => "application/json"}, + positions.to_json + ) + + Array(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://discord.com/developers/docs/resources/guild#delete-guild-role) + def delete_guild_role(guild_id : UInt64 | Snowflake, role_id : UInt64 | Snowflake, reason : String? = nil) + headers = HTTP::Headers.new + headers["X-Audit-Log-Reason"] = reason if reason + + request( + :guilds_gid_roles_rid, + guild_id, + "DELETE", + "/guilds/#{guild_id}/roles/#{role_id}", + headers, + nil + ) + 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://discord.com/developers/docs/resources/guild#get-guild-prune-count) + def get_guild_prune_count(guild_id : UInt64 | Snowflake, days : UInt32) + response = request( + :guilds_gid_prune, + guild_id, + "GET", + "/guilds/#{guild_id}/prune?days=#{days}", + HTTP::Headers.new, + nil + ) + + PruneCountResponse.from_json(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://discord.com/developers/docs/resources/guild#begin-guild-prune) + def begin_guild_prune(guild_id : UInt64 | Snowflake, days : UInt32, reason : String? = nil) + headers = HTTP::Headers{ + "Content-Type" => "application/json", + } + headers["X-Audit-Log-Reason"] = reason if reason + + response = request( + :guilds_gid_prune, + guild_id, + "POST", + "/guilds/#{guild_id}/prune?days=#{days}", + headers, + nil + ) + + PruneCountResponse.from_json(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://discord.com/developers/docs/resources/guild#get-guild-voice-regions) + def get_guild_voice_regions(guild_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/guild#get-guild-integrations) + def get_guild_integrations(guild_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/guild#create-guild-integration) + def create_guild_integration(guild_id : UInt64 | Snowflake, type : String, id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/guild#modify-guild-integration) + def modify_guild_integration(guild_id : UInt64 | Snowflake, integration_id : UInt64 | Snowflake, + 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://discord.com/developers/docs/resources/guild#delete-guild-integration) + def delete_guild_integration(guild_id : UInt64 | Snowflake, integration_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/guild#sync-guild-integration) + def sync_guild_integration(guild_id : UInt64 | Snowflake, integration_id : UInt64 | Snowflake) + request( + :guilds_gid_integrations_iid_sync, + guild_id, + "POST", + "/guilds/#{guild_id}/integrations/#{integration_id}/sync", + HTTP::Headers{"Content-Type" => "application/json"}, + nil + ) + end + + # Gets embed data for a guild. Requires the "Manage Guild" permission. + # + # [API docs for this method](https://discord.com/developers/docs/resources/guild#get-guild-embed) + def get_guild_embed(guild_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/guild#modify-guild-embed) + def modify_guild_embed(guild_id : UInt64 | Snowflake, enabled : Bool, + channel_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/user#get-user) + def get_user(user_id : UInt64 | Snowflake) + 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://discord.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://discord.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://discord.com/developers/docs/resources/user#get-current-user-guilds) + def get_current_user_guilds(limit : Int32 = 100, before : UInt64 | Snowflake = 0_u64, after : UInt64 | Snowflake = 0_u64) + params = URI::Params.build do |form| + form.add "limit", limit.to_s + + if before > 0_u64 + form.add "before", before.to_s + end + + if after > 0_u64 + 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 + + # Returns a `Paginator` over the current users guilds. + # + # Will yield guilds in the given `direction` starting at `start_id` until + # `limit` number of guilds are observed, or the user has no further guilds. + # Setting `limit` to `nil` will have the paginator continue to make requests + # until all guilds are fetched in the given `direction`. + def page_current_user_guilds(start_id : UInt64 | Snowflake = 0_u64, limit : Int32? = 100, + direction : Paginator::Direction = Paginator::Direction::Down, + page_size : Int32 = 100) + Paginator(UserGuild).new(limit, direction) do |last_page| + if direction.up? + next_id = last_page.try &.first.id || start_id + get_current_user_guilds(page_size, before: next_id) + else + next_id = last_page.try &.last.id || start_id + get_current_user_guilds(page_size, after: next_id) + end + end + end + + # Makes the bot leave a guild. + # + # [API docs for this method](https://discord.com/developers/docs/resources/user#leave-guild) + def leave_guild(guild_id : UInt64 | Snowflake) + 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://discord.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://discord.com/developers/docs/resources/user#create-dm) + def create_dm(recipient_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/user#get-user-connections) + def get_user_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://discord.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://discord.com/developers/docs/resources/invite#delete-invite) + def delete_invite(code : String, reason : String? = nil) + headers = HTTP::Headers.new + headers["X-Audit-Log-Reason"] = reason if reason + + response = request( + :invites_code, + nil, + "DELETE", + "/invites/#{code}", + headers, + 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://discord.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://discord.com/developers/docs/resources/webhook#get-webhook). + def get_webhook(webhook_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/webhook#get-webhook-with-token). + def get_webhook(webhook_id : UInt64 | Snowflake, 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://discord.com/developers/docs/resources/webhook#get-guild-webhooks). + def get_guild_webhooks(guild_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/webhook#create-webhook). + def create_channel_webhook(channel_id : UInt64 | Snowflake, 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://discord.com/developers/docs/resources/webhook#get-channel-webhooks). + def get_channel_webhooks(channel_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/webhook#modify-webhook). + def modify_webhook(webhook_id : UInt64 | Snowflake, name : String? = nil, avatar : String? = nil, + channel_id : UInt64 | Snowflake | Nil = 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://discord.com/developers/docs/resources/webhook#modify-webhook-with-token). + def modify_webhook_with_token(webhook_id : UInt64 | Snowflake, 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://discord.com/developers/docs/resources/webhook#delete-webhook) + def delete_webhook(webhook_id : UInt64 | Snowflake) + 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://discord.com/developers/docs/resources/webhook#delete-webhook-with-token) + def delete_webhook(webhook_id : UInt64 | Snowflake, 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://discord.com/developers/docs/resources/webhook#execute-webhook) + def execute_webhook(webhook_id : UInt64 | Snowflake, token : String, content : String? = nil, + file : IO? = nil, filename : String? = nil, embeds : Array(Embed)? = nil, + tts : Bool? = nil, avatar_url : String? = nil, + username : String? = nil, allowed_mentions : AllowedMentions? = nil, + wait : Bool? = false, thread_id : UInt64 | Snowflake? = nil) + json = encode_tuple( + content: content, + embeds: embeds, + tts: tts, + avatar_url: avatar_url, + username: username, + allowed_mentions: allowed_mentions + ) + + params = URI::Params.build do |form| + form.add "wait", wait.to_s if wait + form.add "thread_id", thread_id.to_s if thread_id + end + + body, content_type = nil, nil + if file + 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.file("file", file, HTTP::FormData::FileMetadata.new(filename: filename)) + builder.field("payload_json", json) + builder.finish + + body = io.to_s + content_type = builder.content_type + else + body = json + content_type = "application/json" + end + + response = request( + :webhooks_wid, + webhook_id, + "POST", + "/webhooks/#{webhook_id}/#{token}?#{params}", + HTTP::Headers{"Content-Type" => content_type}, + body + ) + + # Expecting response + Message.from_json(response.body) if wait + end + end +end diff --git a/lib/discordcr/src/discordcr/snowflake.cr b/lib/discordcr/src/discordcr/snowflake.cr new file mode 100644 index 0000000..821b9a1 --- /dev/null +++ b/lib/discordcr/src/discordcr/snowflake.cr @@ -0,0 +1,65 @@ +module Discord + DISCORD_EPOCH = 1420070400000_u64 + + # Struct representing a Discord ID + struct Snowflake + include Comparable(Snowflake) + include Comparable(UInt64) + + getter value : UInt64 + + def self.new(string : String) + new(string.to_u64) + end + + def self.new(parser : JSON::PullParser) + string = parser.read_string + new(string.to_u64) + end + + # Creates a `Snowflake` embedded with the given timestamp + def self.new(time : Time) + ms = time.to_unix_ms.to_u64 + value = (ms - DISCORD_EPOCH) << 22 + new(value) + end + + def initialize(@value : UInt64) + end + + # Compatibility with UInt64 API + def to_u64 + @value + end + + def to_s(io : IO) + io << @value + end + + # The time at which this snowflake was created + def creation_time + ms = (value >> 22) + DISCORD_EPOCH + Time.unix_ms(ms) + end + + def to_json(builder : JSON::Builder) + builder.scalar value.to_s + end + + def <=>(other : Snowflake) + value <=> other.value + end + + def <=>(int : UInt64) + value <=> int + end + end +end + +struct UInt64 + include Comparable(Discord::Snowflake) + + def <=>(snowflake : Discord::Snowflake) + self <=> snowflake.value + end +end diff --git a/lib/discordcr/src/discordcr/sodium.cr b/lib/discordcr/src/discordcr/sodium.cr new file mode 100644 index 0000000..ee33795 --- /dev/null +++ b/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/lib/discordcr/src/discordcr/version.cr b/lib/discordcr/src/discordcr/version.cr new file mode 100644 index 0000000..42bbed2 --- /dev/null +++ b/lib/discordcr/src/discordcr/version.cr @@ -0,0 +1,3 @@ +module Discord + VERSION = "0.4.0" +end diff --git a/lib/discordcr/src/discordcr/voice.cr b/lib/discordcr/src/discordcr/voice.cr new file mode 100644 index 0000000..0bb8d8d --- /dev/null +++ b/lib/discordcr/src/discordcr/voice.cr @@ -0,0 +1,334 @@ +require "uri" + +require "./mappings/gateway" +require "./mappings/vws" +require "./websocket" +require "./sodium" + +module Discord + class VoiceClient + UDP_PROTOCOL = "udp" + + Log = Discord::Log.for("voice") + + # Supported encryption modes. Sorted by preference + ENCRYPTION_MODES = {"xsalsa20_poly1305_lite", "xsalsa20_poly1305_suffix", "xsalsa20_poly1305"} + + OP_IDENTIFY = 0 + OP_SELECT_PROTOCOL = 1 + OP_READY = 2 + OP_HEARTBEAT = 3 + OP_SESSION_DESCRIPTION = 4 + OP_SPEAKING = 5 + OP_HELLO = 8 + + @udp : VoiceUDP + + @sequence : UInt16 = 0_u16 + @time : UInt32 = 0_u32 + + @endpoint : String + @server_id : UInt64 + @user_id : UInt64 + @session_id : String + @token : String + + @heartbeat_interval : Float32? + @send_heartbeats = false + + # Creates a new voice client. The *payload* should be a payload received + # from Discord as part of a VOICE_SERVER_UPDATE dispatch, received after + # sending a voice state update (gateway op 4) packet. The *session* should + # be the session currently in use by the gateway client on which the + # aforementioned dispatch was received, and the *user_id* should be the + # user ID of the account on which the voice client is created. (It is + # received as part of the gateway READY dispatch, for example) + def initialize(payload : Discord::Gateway::VoiceServerUpdatePayload, + session : Discord::Gateway::Session, user_id : UInt64 | Snowflake) + initialize(payload.endpoint, payload.token, session.session_id, payload.guild_id, user_id) + end + + # :nodoc: + def initialize(@endpoint, @token, @session_id, guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake) + @user_id = user_id.to_u64 + host, port = @endpoint.split(':') + + @server_id = guild_id.to_u64 + + @websocket = Discord::WebSocket.new( + host: host, + path: "/?v=4", + port: port.to_i, + tls: true + ) + + @websocket.on_message(&->on_message(Discord::WebSocket::Packet)) + @websocket.on_close(&->on_close(HTTP::WebSocket::CloseCode, String)) + + @udp = VoiceUDP.new + end + + # Initiates the connection process and blocks forever afterwards. + def run + @send_heartbeats = true + spawn { heartbeat_loop } + @websocket.run + end + + # Closes the VWS connection, in effect disconnecting from voice. + def close + @send_heartbeats = false + @websocket.close + end + + # Sets the handler that should be run once the voice client has connected + # successfully. + def on_ready(&@ready_handler : ->) + end + + # Sends a packet to indicate to Discord whether or not we are speaking + # right now + def send_speaking(speaking : Bool, delay : Int32 = 0) + packet = VWS::SpeakingPacket.new(speaking, delay) + @websocket.send(packet.to_json) + end + + # Plays a single opus packet + def play_opus(buf : Bytes) + increment_packet_metadata + @udp.send_audio(buf, @sequence, @time) + end + + # Increment sequence and time + private def increment_packet_metadata + @sequence &+= 1 + @time &+= 960 + end + + private def heartbeat_loop + while @send_heartbeats + if @heartbeat_interval + @websocket.send({op: 3, d: Time.utc.to_unix_ms}.to_json) + sleep @heartbeat_interval.not_nil!.milliseconds + else + sleep 1 + end + end + end + + private def on_message(packet : Discord::WebSocket::Packet) + Log.debug { "VWS packet received: #{packet} #{packet.data.to_s}" } + + case packet.opcode + when OP_READY + payload = VWS::ReadyPayload.from_json(packet.data) + handle_ready(payload) + when OP_SESSION_DESCRIPTION + payload = VWS::SessionDescriptionPayload.from_json(packet.data) + handle_session_description(payload) + when OP_HELLO + payload = VWS::HelloPayload.from_json(packet.data) + handle_hello(payload) + else + # TODO: Debug log unknown opcodes? + end + end + + private def on_close(code : HTTP::WebSocket::CloseCode, message : String) + @send_heartbeats = false + reason = message.empty? ? "(none)" : message + Log.warn { "VWS closed with code: #{code}, reason: #{reason}" } + end + + private def handle_ready(payload : VWS::ReadyPayload) + if selected_crypto = ENCRYPTION_MODES.find { |preferred| payload.modes.includes?(preferred) } + udp_connect(payload.ip, payload.port.to_u32, payload.ssrc.to_u32, selected_crypto) + else + raise "No supported crypto modes found in #{payload.modes}" + end + end + + private def udp_connect(ip, port, ssrc, encryption_mode) + @udp.connect(ip, port, ssrc) + @udp.send_discovery + ip, port = @udp.receive_discovery_reply + send_select_protocol(UDP_PROTOCOL, ip, port, encryption_mode) + end + + private def send_identify(server_id, user_id, session_id, token) + packet = VWS::IdentifyPacket.new(server_id, user_id, session_id, token) + @websocket.send(packet.to_json) + end + + private def send_select_protocol(protocol, address, port, mode) + data = VWS::ProtocolData.new(address, port, mode) + packet = VWS::SelectProtocolPacket.new(protocol, data) + @websocket.send(packet.to_json) + end + + private def handle_session_description(payload : VWS::SessionDescriptionPayload) + @udp.secret_key = Bytes.new(payload.secret_key.to_unsafe, payload.secret_key.size) + @udp.mode = payload.mode + + # Once the secret key has been received, we are ready to send audio data. + # Notify the user of this + spawn { @ready_handler.try(&.call) } + end + + private def handle_hello(payload : VWS::HelloPayload) + @heartbeat_interval = payload.heartbeat_interval + send_identify(@server_id, @user_id, @session_id, @token) + end + end + + # Client for Discord's voice UDP protocol, on which the actual audio data is + # sent. There should be no reason to manually use this class: use + # `VoiceClient` instead which uses this class internally. + class VoiceUDP + @secret_key : Bytes? + @mode : String? + @lite_nonce : UInt32 = 0 + + property secret_key + property mode + getter socket + + def initialize + @socket = UDPSocket.new + end + + def connect(endpoint : String, port : UInt32, ssrc : UInt32) + @ssrc = ssrc + @socket.connect(endpoint, port) + end + + # Sends a discovery packet to Discord, telling them that we want to know our + # IP so we can select the protocol on the VWS + def send_discovery + data = Bytes.new(74) + IO::ByteFormat::BigEndian.encode(1_u16, data[0, 2]) # Mark as request + IO::ByteFormat::BigEndian.encode(70_u16, data[2, 2]) # Message size + IO::ByteFormat::BigEndian.encode(@ssrc.not_nil!, data[4, 4]) + @socket.write(data) + end + + # Awaits a response to the discovery request and returns our local IP and + # port once the response is received + def receive_discovery_reply : {String, UInt16} + buf = Bytes.new(74) + @socket.receive(buf) + + # The first 8 bytes are utility and the SSRC again, we don't care about that + data = buf[8, buf.size - 8] + ip = String.new(data[0, 64]).delete("\0") + port = IO::ByteFormat::BigEndian.decode(UInt16, data[64, 2]) + + {ip, port} + end + + # Sends 20 ms of opus audio data to Discord, with the specified sequence and + # time (used on the receiving client to synchronise packets) + def send_audio(buf, sequence, time) + header = create_header(sequence, time) + nonce = create_nonce(header) + buf = encrypt_audio(nonce, buf) + + new_buf = if @mode == "xsalsa20_poly1305" + Bytes.new(header.size + buf.size) + else + Bytes.new(header.size + buf.size + nonce.size) + end + + header.copy_to(new_buf) + buf.copy_to(new_buf + header.size) + + nonce.copy_to(new_buf + header.size + buf.size) unless @mode == "xsalsa20_poly1305" + + @socket.write(new_buf) + end + + # :nodoc: + def create_header(sequence : UInt16, time : UInt32) : Bytes + bytes = Bytes.new(12) + + # Write the magic bytes required by Discord + bytes[0] = 0x80_u8 + bytes[1] = 0x78_u8 + + IO::ByteFormat::BigEndian.encode(sequence, bytes[2, 2]) + IO::ByteFormat::BigEndian.encode(time, bytes[4, 4]) + IO::ByteFormat::BigEndian.encode(@ssrc.not_nil!, bytes[8, 4]) + + bytes + end + + private def create_nonce(header : Bytes) + nonce = nil + case @mode + when "xsalsa20_poly1305" + nonce = Bytes.new(header.size) + header.copy_to(nonce) + when "xsalsa20_poly1305_suffix" + nonce = Random::Secure.random_bytes(24) + when "xsalsa20_poly1305_lite" + nonce = Bytes.new(4) + IO::ByteFormat::BigEndian.encode(@lite_nonce, nonce) + + @lite_nonce &+= 1 + else + raise "Cannot create a nonce for unsupported audio mode #{@mode.inspect}" + end + nonce + end + + private def encrypt_audio(nonce : Bytes, buf : Bytes) : Bytes + raise "No secret key was set!" unless @secret_key + + sodium_nonce = Bytes.new(24, 0_u8) + nonce.copy_to(sodium_nonce) + + # Sodium constants + zero_bytes = Sodium.crypto_secretbox_xsalsa20poly1305_zerobytes + box_zero_bytes = Sodium.crypto_secretbox_xsalsa20poly1305_boxzerobytes + + # Prepend the buf with zero_bytes zero bytes + message = Bytes.new(buf.size + zero_bytes, 0_u8) + buf.copy_to(message + zero_bytes) + + # Create a buffer for the ciphertext + c = Bytes.new(message.size) + + # Encrypt + Sodium.crypto_secretbox_xsalsa20poly1305(c, message, message.bytesize, sodium_nonce, @secret_key.not_nil!) + + # The resulting ciphertext buffer has box_zero_bytes zero bytes prepended; + # we don't want them in the result, so move the slice forward by that many + # bytes + c + box_zero_bytes + end + end + + # Utility function that runs the given block and measures the time it takes, + # then sleeps the given time minus that time. This is useful for voice code + # because (in most cases) voice data should be sent to Discord at a rate of + # one frame every 20 ms, and if the processing and sending takes a certain + # amount of time, then noticeable choppiness can be heard. + def self.timed_run(total_time : Time::Span) + delta = Time.measure { yield } + + sleep_time = {total_time - delta, Time::Span.zero}.max + sleep sleep_time + end + + # Runs the given block every *time_span*. This method takes into account the + # execution time for the block to keep the intervals accurate. + # + # Note that if the block takes longer to execute than the given *time_span*, + # there will be no delay: the next iteration follows immediately, with no + # attempt to get in sync. + def self.every(time_span : Time::Span) + loop do + timed_run(time_span) { yield } + end + end +end diff --git a/lib/discordcr/src/discordcr/websocket.cr b/lib/discordcr/src/discordcr/websocket.cr new file mode 100644 index 0000000..4f4e914 --- /dev/null +++ b/lib/discordcr/src/discordcr/websocket.cr @@ -0,0 +1,135 @@ +require "http" +require "compress/zlib" + +module Discord + # Internal wrapper around HTTP::WebSocket to decode the Discord-specific + # payload format used in the gateway and VWS. + class WebSocket + Log = Discord::Log.for("ws") + + # :nodoc: + struct Packet + include JSON::Serializable + + module DataConverter + def self.from_json(parser) + data = IO::Memory.new + JSON.build(data) do |builder| + parser.read_raw(builder) + end + data.rewind + end + + def self.to_json(value, builder) + builder.raw(value.to_s) + end + end + + @[JSON::Field(key: "op")] + getter opcode : Int64 + + @[JSON::Field(key: "s")] + getter sequence : Int64? + + @[JSON::Field(key: "d", converter: Discord::WebSocket::Packet::DataConverter)] + getter data : IO::Memory + + @[JSON::Field(key: "t")] + getter event_type : String? + + def initialize(@opcode : Int64, @sequence : Int64?, @data : IO::Memory, @event_type : String?) + end + + def inspect(io : IO) + io << "Discord::WebSocket::Packet(@opcode=" + opcode.inspect(io) + io << " @sequence=" + sequence.inspect(io) + io << " @data=" + data.to_s.inspect(io) + io << " @event_type=" + event_type.inspect(io) + io << ')' + end + end + + ZLIB_SUFFIX = Bytes[0x0, 0x0, 0xFF, 0xFF] + + @zlib_reader : Compress::Zlib::Reader? + @buffer : Bytes + + def initialize(@host : String, @path : String, @port : Int32, @tls : Bool, + @zlib_buffer_size : Int32 = 10 * 1024 * 1024) + Log.info { "Connecting to #{@host}:#{@port}#{@path}" } + @websocket = HTTP::WebSocket.new( + host: @host, + path: @path, + port: @port, + tls: @tls + ) + + # Buffer for zlib-stream + @buffer_memory = Bytes.empty + @buffer = @buffer_memory[0, 0] + @zlib_io = IO::Memory.new + @zlib_reader = nil + end + + def on_compressed(&handler : Packet ->) + @websocket.on_binary do |binary| + io = IO::Memory.new(binary) + Compress::Zlib::Reader.open(io) do |reader| + payload = Packet.from_json(reader) + Log.debug { "[WS IN] (compressed, #{binary.size} bytes) #{payload.to_json}" } + handler.call(payload) + end + end + end + + def on_compressed_stream(&handler : Packet ->) + @buffer_memory = Bytes.new(@zlib_buffer_size) + @websocket.on_binary do |binary| + @zlib_io.write binary + next if binary.size < 4 || binary[binary.size - 4, 4] != ZLIB_SUFFIX + @zlib_io.rewind + + zlib_reader = (@zlib_reader ||= Compress::Zlib::Reader.new(@zlib_io)) + + read_size = zlib_reader.read(@buffer_memory) + @buffer = @buffer_memory[0, read_size] + + payload = Packet.from_json(IO::Memory.new(@buffer)) + Log.debug { "[WS IN] (compressed, #{binary.size} bytes) #{payload.to_json}" } + handler.call(payload) + + @zlib_io.clear + end + end + + def on_message(&handler : Packet ->) + @websocket.on_message do |message| + Log.debug { "[WS IN] #{message}" } + payload = Packet.from_json(message) + handler.call(payload) + end + end + + def on_close(&handler : HTTP::WebSocket::CloseCode, String ->) + @websocket.on_close(&handler) + end + + def run + @websocket.run + end + + def close(code : HTTP::WebSocket::CloseCode | Int? = nil, message = nil) + Log.info { "Closing with code: #{code} #{message || "(no message)"}" } + @websocket.close(code, message) + end + + def send(message) + Log.debug { "[WS OUT] #{message}" } + @websocket.send(message) + end + end +end diff --git a/main.cr b/main.cr new file mode 100644 index 0000000..9d927fb --- /dev/null +++ b/main.cr @@ -0,0 +1,184 @@ +require "discordcr" +require "json" +require "uuid" +require "./responses" +require "./reactions" + +token = ENV["DISCORD_BOT_TOKEN"]? || raise "Missing DISCORD_BOT_TOKEN env variable" + +puts("Bot #{token}") + +client = Discord::Client.new( + token: "Bot #{token}", + client_id: 1475668463363293398_u64 +) + +DICT_FILE = "./dictionary.json" +USER_LIMIT = 100 + +def load_dict + if File.exists?(DICT_FILE) + JSON.parse(File.read(DICT_FILE)).as_h.transform_values do |defs| + defs.as_a.map do |d| + { + "id" => d["id"].as_s, + "definition" => d["definition"].as_s, + "author" => d["author"].as_s, + "upvotes" => d["upvotes"].as_i, + "downvotes" => d["downvotes"].as_i + } + end + end + else + {} of String => Array(Hash(String, String|Int32)) + end +end + +def save_dict(dict) + File.write(DICT_FILE, dict.to_json) +end + +dictionary = load_dict + +def user_def_count(dict, username) + dict.values.flatten.count { |d| d["author"] == username } +end + +client.on_message_create do |payload| + content = payload.content + args = content.split(" ", 3) + command = args[0]? + user = payload.author.username + + if payload.author.bot + next + end + + case command + when "!add" + word = args[1]? + definition = args[2]? + + if word.nil? || definition.nil? + client.create_message(payload.channel_id, "usage: !add ") + next + end + + if user_def_count(dictionary, user) >= USER_LIMIT + client.create_message(payload.channel_id, "#{user}, you reached the limit of #{USER_LIMIT} definitions.") + next + end + + dictionary[word] ||= [] of Hash(String, String | Int32) + dictionary[word] << { + "id" => UUID.random.to_s, + "definition" => definition, + "author" => user, + "upvotes" => 0, + "downvotes" => 0 + } + + save_dict(dictionary) + client.create_message(payload.channel_id, "added definition for **#{word}** by #{user}.") + + when "!define" + word = args[1]? + if word.nil? + client.create_message(payload.channel_id, "usage: !define ") + next + end + + defs = dictionary[word]? + if defs + text = defs.map_with_index do |d, i| + "#{i+1}. #{d["definition"]} (by #{d["author"]}) 👍#{d["upvotes"]} 👎#{d["downvotes"]}" + end.join("\n") + client.create_message(payload.channel_id, "**#{word}**:\n#{text}") + else + client.create_message(payload.channel_id, "no definition for **#{word}**.") + end + + when "!list" + word = args[1]? + if word.nil? + client.create_message(payload.channel_id, "usage: !list ") + next + end + + defs = dictionary[word]? + if defs + text = defs.map_with_index do |d, i| + "#{i+1}. #{d["definition"]} (by #{d["author"]}) 👍#{d["upvotes"]} 👎#{d["downvotes"]}" + end.join("\n") + client.create_message(payload.channel_id, "**#{word}** definitions:\n#{text}") + else + client.create_message(payload.channel_id, "no definitions found.") + end + + when "!upvote", "!downvote" + word = args[1]? + index = args[2]? ? args[2].to_i : nil + + if word.nil? || index.nil? + client.create_message(payload.channel_id, "usage: #{command} ") + next + end + + defs = dictionary[word]? + if defs && index > 0 && index <= defs.size + target = defs[index - 1] + if command == "!upvote" + target["upvotes"] = target["upvotes"].to_i + 1 + else + target["downvotes"] = target["downvotes"].to_i + 1 + end + save_dict(dictionary) + client.create_message(payload.channel_id, "updated votes for definition #{index} of **#{word}**.") + else + client.create_message(payload.channel_id, "invalid word or definition number.") + end + + when "!random" + if dictionary.empty? + client.create_message(payload.channel_id, "nichectionary is empty.") + next + end + + word = dictionary.keys.sample + defs = dictionary[word] + def_choice = defs.sample + client.create_message(payload.channel_id, + "**#{word}**:\n#{def_choice["definition"]} (by #{def_choice["author"]}) 👍#{def_choice["upvotes"]} 👎#{def_choice["downvotes"]}" + ) + + when "!search" + term = args[1]? + if term.nil? + client.create_message(payload.channel_id, "usage: !search ") + next + end + + matches = dictionary.select { |w, _| w.includes?(term) } + if matches.empty? + client.create_message(payload.channel_id, "no words matched `#{term}`.") + next + end + + text = matches.keys.join(", ") + client.create_message(payload.channel_id, "words matching `#{term}`: #{text}") + end + + Reactions.check_message(content).each do |emoji| + client.create_reaction(payload.channel_id, payload.id, emoji) + end + + preset_reply = Responses.check_message(content) + if preset_reply + client.trigger_typing_indicator(payload.channel_id) + sleep 500.milliseconds + client.create_message(payload.channel_id, preset_reply, message_reference: payload.message_reference()) + next + end +end + +client.run diff --git a/reactions.cr b/reactions.cr new file mode 100644 index 0000000..3609cf7 --- /dev/null +++ b/reactions.cr @@ -0,0 +1,20 @@ +module Reactions + TRIGGERS = { + "void" => ["👀"], + "cursed" => ["😳"], + "based" => ["🗿"], + } + + def self.check_message(content : String) : Array(String) + lower = content.downcase + emojis = [] of String + + TRIGGERS.each do |keyword, reactions| + if lower.matches?(/\b#{Regex.escape(keyword)}\b/) + emojis.concat(reactions) + end + end + + emojis + end +end diff --git a/responses.cr b/responses.cr new file mode 100644 index 0000000..d4dc950 --- /dev/null +++ b/responses.cr @@ -0,0 +1,28 @@ +module Responses + PRESETS = { + "wachin" => ["wachin vos", "k onda wachinovich", "qn te juna a vos wachin"], + "mamita" => ["\"mamita\" Villacoño :round_pushpin:"], + "niche" => ["very niche indeed :eyes:"], + "parezco musulmana" => ["TIA K DICES :woman_facepalming:"], + "eh?" => ["jajaja"], + "jaja" => ["se jijea el jijonoide", "se jijeaba", "el jijonazo"], + "rust" => ["ruzzzzzzzt"], + "we" => ["we? eres mexa? :face_vomiting:", "we dice el pelotudo", "#freemencho"], + "tranki" => ["piola sin berretin"], + "67" => ["<:67:1478470308678865066>"], + "smokedope" => ["WE LOVE SMOKEDOPE"], + "pinto" => ["y si, pintó"] + } + + PROBABILITY = 0.55 + + def self.check_message(message : String) + PRESETS.each do |keyword, responses| + if message.downcase.includes?(keyword) + prob = rand + return responses.sample if prob < PROBABILITY + end + end + nil + end +end diff --git a/shard.lock b/shard.lock new file mode 100644 index 0000000..6044fed --- /dev/null +++ b/shard.lock @@ -0,0 +1,6 @@ +version: 2.0 +shards: + discordcr: + git: https://github.com/shardlab/discordcr.git + version: 0.4.0+git.commit.0e03deb8ffa247814f2fec4e197cba7a62534f85 + diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..f8a1b0a --- /dev/null +++ b/shard.yml @@ -0,0 +1,24 @@ +name: cr +version: 0.1.0 + +dependencies: + discordcr: + github: shardlab/discordcr + branch: master + +# authors: +# - name + +# description: | +# Short description of cr + +# dependencies: +# pg: +# github: will/crystal-pg +# version: "~> 0.5" + +# development_dependencies: +# webmock: +# github: manastech/webmock.cr + +# license: MIT