├── .rspec ├── spec ├── json_examples │ ├── test.json │ ├── user.json │ ├── role.json │ ├── emoji │ │ ├── dispatch_remove.json │ │ ├── dispatch.json │ │ ├── dispatch_update.json │ │ ├── dispatch_event.json │ │ ├── dispatch_add.json │ │ └── emoji_server.json │ ├── emoji.json │ ├── webhook.json │ ├── webhook │ │ ├── update_avatar.json │ │ ├── update_channel.json │ │ └── update_name.json │ ├── text_channel.json │ └── message.json ├── data_spec.rb ├── mock │ └── api_mock.rb ├── helpers_spec.rb ├── logger_spec.rb ├── errors_spec.rb ├── data │ ├── role_spec.rb │ └── emoji_spec.rb ├── paginator_spec.rb ├── api_mock_spec.rb ├── sodium_spec.rb ├── api │ └── channel_spec.rb ├── main_spec.rb ├── overwrite_spec.rb ├── rate_limiter_spec.rb ├── permissions_spec.rb └── spec_helper.rb ├── .yardopts ├── examples ├── data │ ├── music.dca │ └── music.mp3 ├── pm_send.rb ├── ping_with_respond_time.rb ├── eval.rb ├── shutdown.rb ├── ping.rb ├── commands.rb ├── voice_send.rb ├── prefix_proc.rb ├── awaits.rb └── slash_commands.rb ├── bin ├── setup └── console ├── .overcommit.yml ├── lib ├── discordrb │ ├── version.rb │ ├── webhooks │ │ ├── version.rb │ │ ├── builder.rb │ │ └── client.rb │ ├── light.rb │ ├── webhooks.rb │ ├── commands │ │ └── events.rb │ ├── events │ │ ├── lifetime.rb │ │ ├── raw.rb │ │ ├── voice_server_update.rb │ │ ├── await.rb │ │ ├── bans.rb │ │ ├── webhooks.rb │ │ ├── typing.rb │ │ ├── roles.rb │ │ ├── members.rb │ │ ├── voice_state_update.rb │ │ ├── invites.rb │ │ ├── presence.rb │ │ ├── reactions.rb │ │ ├── generic.rb │ │ └── guilds.rb │ ├── data │ │ ├── recipient.rb │ │ ├── reaction.rb │ │ ├── voice_region.rb │ │ ├── voice_state.rb │ │ ├── application.rb │ │ ├── attachment.rb │ │ ├── emoji.rb │ │ ├── profile.rb │ │ ├── overwrite.rb │ │ ├── integration.rb │ │ └── invite.rb │ ├── api │ │ ├── invite.rb │ │ ├── interaction.rb │ │ ├── user.rb │ │ └── webhook.rb │ ├── id_object.rb │ ├── data.rb │ ├── allowed_mentions.rb │ ├── colour_rgb.rb │ ├── light │ │ ├── data.rb │ │ ├── light_bot.rb │ │ └── integrations.rb │ ├── paginator.rb │ ├── await.rb │ ├── websocket.rb │ ├── voice │ │ ├── sodium.rb │ │ └── encoder.rb │ └── logger.rb └── discordrb.rb ├── .gitignore ├── Gemfile ├── Rakefile ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── pull_request_template.md ├── discordrb-webhooks.gemspec ├── LICENSE.txt ├── .rubocop.yml ├── discordrb.gemspec └── .circleci └── config.yml /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /spec/json_examples/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": true 3 | } -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup markdown --hide-tag todo --fail-on-warning 2 | -------------------------------------------------------------------------------- /examples/data/music.dca: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/discordrb/main/examples/data/music.dca -------------------------------------------------------------------------------- /examples/data/music.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrevorHinesley/discordrb/main/examples/data/music.mp3 -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /.overcommit.yml: -------------------------------------------------------------------------------- 1 | PreCommit: 2 | RuboCop: 3 | enabled: true 4 | on_warn: fail # Treat all warnings as failures 5 | 6 | AuthorName: 7 | enabled: false 8 | -------------------------------------------------------------------------------- /spec/json_examples/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "username": "meewꙩ ⃤", 3 | "discriminator": "9811", 4 | "id": "66237334693085184", 5 | "avatar": "c173843003c037f261a478b5d2a5ec99" 6 | } -------------------------------------------------------------------------------- /lib/discordrb/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Discordrb and all its functionality, in this case only the version. 4 | module Discordrb 5 | # The current version of discordrb. 6 | VERSION = '3.4.2' 7 | end 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /profiles/ 10 | /tmp/ 11 | /.idea/ 12 | .DS_Store 13 | *.gem 14 | **.sw* 15 | 16 | /vendor/bundle 17 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in discordrb.gemspec 6 | gemspec name: 'discordrb' 7 | gemspec name: 'discordrb-webhooks', development_group: 'webhooks' 8 | -------------------------------------------------------------------------------- /spec/json_examples/role.json: -------------------------------------------------------------------------------- 1 | { 2 | "hoist": false, 3 | "name": "crystal", 4 | "mentionable": false, 5 | "color": 0, 6 | "position": 39, 7 | "id": "240172879361212416", 8 | "managed": false, 9 | "permissions": 103809088 10 | } -------------------------------------------------------------------------------- /lib/discordrb/webhooks/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Webhook support for discordrb 4 | module Discordrb 5 | module Webhooks 6 | # The current version of discordrb-webhooks. 7 | VERSION = '3.4.2' 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/json_examples/emoji/dispatch_remove.json: -------------------------------------------------------------------------------- 1 | { 2 | "guild_id": "1", 3 | "emojis": [ 4 | { 5 | "roles": [], 6 | "require_colons": true, 7 | "name": "emoji_name_1", 8 | "managed": false, 9 | "id": "10" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /lib/discordrb/light.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/light/light_bot' 4 | 5 | # This module contains classes to allow connections to bots without a connection to the gateway socket, i.e. bots 6 | # that only use the REST part of the API. 7 | module Discordrb::Light 8 | end 9 | -------------------------------------------------------------------------------- /spec/data_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | require 'mock/api_mock' 5 | 6 | using APIMock 7 | 8 | require 'data/channel_spec' 9 | require 'data/emoji_spec' 10 | require 'data/message_spec' 11 | require 'data/role_spec' 12 | require 'data/webhook_spec' 13 | -------------------------------------------------------------------------------- /lib/discordrb/webhooks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/webhooks/version' 4 | require 'discordrb/webhooks/embeds' 5 | require 'discordrb/webhooks/client' 6 | require 'discordrb/webhooks/builder' 7 | 8 | module Discordrb 9 | # Webhook client 10 | module Webhooks 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/discordrb/commands/events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/message' 4 | 5 | module Discordrb::Commands 6 | # Extension of MessageEvent for commands that contains the command called and makes the bot readable 7 | class CommandEvent < Discordrb::Events::MessageEvent 8 | attr_reader :bot 9 | attr_accessor :command 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/json_examples/emoji.json: -------------------------------------------------------------------------------- 1 | { 2 | "managed": false, 3 | "name": "rubytaco", 4 | "roles": ["403178127519383552"], 5 | "user": { 6 | "username": "z64", 7 | "discriminator": "1337", 8 | "id": "120571255635181568", 9 | "avatar": "937d2fd2957106c08d2cb4da738780c6" 10 | }, 11 | "require_colons": true, 12 | "animated": false, 13 | "id": "315242245274075157" 14 | } 15 | -------------------------------------------------------------------------------- /spec/json_examples/emoji/dispatch.json: -------------------------------------------------------------------------------- 1 | { 2 | "guild_id": "1", 3 | "emojis": [ 4 | { 5 | "roles": [], 6 | "require_colons": true, 7 | "name": "emoji_name_1", 8 | "managed": false, 9 | "id": "10" 10 | }, 11 | { 12 | "roles": [], 13 | "require_colons": true, 14 | "name": "emoji_name_2", 15 | "managed": false, 16 | "id": "11" 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /spec/json_examples/emoji/dispatch_update.json: -------------------------------------------------------------------------------- 1 | { 2 | "guild_id": "1", 3 | "emojis": [ 4 | { 5 | "roles": [], 6 | "require_colons": true, 7 | "name": "emoji_name_1", 8 | "managed": false, 9 | "id": "10" 10 | }, 11 | { 12 | "roles": [], 13 | "require_colons": true, 14 | "name": "new_emoji_name", 15 | "managed": false, 16 | "id": "11" 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /spec/json_examples/emoji/dispatch_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "guild_id": "1", 3 | "emojis": [ 4 | { 5 | "roles": [], 6 | "require_colons": true, 7 | "name": "changed_emoji_name", 8 | "managed": false, 9 | "id": "10" 10 | }, 11 | { 12 | "roles": [], 13 | "require_colons": true, 14 | "name": "emoji_name_3", 15 | "managed": false, 16 | "id": "12" 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'discordrb' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start 16 | -------------------------------------------------------------------------------- /spec/json_examples/webhook.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Captain Hook", 3 | "channel_id": "269512751339143168", 4 | "token": "fake_webhook_token", 5 | "avatar": "5d238f5d6b7a3f95cbf4404593998fe7", 6 | "guild_id": "269512733039394816", 7 | "id": "279055026209554442", 8 | "user": { 9 | "username": "meewꙩ ⃤", 10 | "discriminator": "9811", 11 | "id": "66237334693085184", 12 | "avatar": "c173843003c037f261a478b5d2a5ec99" 13 | } 14 | } -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_helper' 4 | 5 | namespace :main do 6 | Bundler::GemHelper.install_tasks(name: 'discordrb') 7 | end 8 | 9 | namespace :webhooks do 10 | Bundler::GemHelper.install_tasks(name: 'discordrb-webhooks') 11 | end 12 | 13 | task build: %i[main:build webhooks:build] 14 | task release: %i[main:release webhooks:release] 15 | 16 | # Make "build" the default task 17 | task default: :build 18 | -------------------------------------------------------------------------------- /spec/json_examples/webhook/update_avatar.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Captain Hook", 3 | "channel_id": "269512751339143168", 4 | "token": "fake_webhook_token", 5 | "avatar": "5d238f5d6b7a3f95cbf4404593998fe7", 6 | "guild_id": "269512733039394816", 7 | "id": "279055026209554442", 8 | "user": { 9 | "username": "meewꙩ ⃤", 10 | "discriminator": "9811", 11 | "id": "66237334693085184", 12 | "avatar": "c173843003c037f261a478b5d2a5ec99" 13 | } 14 | } -------------------------------------------------------------------------------- /spec/json_examples/webhook/update_channel.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Captain Hook", 3 | "channel_id": "269512733039394816", 4 | "token": "fake_webhook_token", 5 | "avatar": "5d238f5d6b7a3f95cbf4404593998fe7", 6 | "guild_id": "269512733039394816", 7 | "id": "279055026209554442", 8 | "user": { 9 | "username": "meewꙩ ⃤", 10 | "discriminator": "9811", 11 | "id": "66237334693085184", 12 | "avatar": "c173843003c037f261a478b5d2a5ec99" 13 | } 14 | } -------------------------------------------------------------------------------- /spec/json_examples/webhook/update_name.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Captain Hook", 3 | "channel_id": "269512751339143168", 4 | "token": "fake_webhook_token", 5 | "avatar": "5d238f5d6b7a3f95cbf4404593998fe7", 6 | "guild_id": "269512733039394816", 7 | "id": "279055026209554442", 8 | "user": { 9 | "username": "meewꙩ ⃤", 10 | "discriminator": "9811", 11 | "id": "66237334693085184", 12 | "avatar": "c173843003c037f261a478b5d2a5ec99" 13 | } 14 | } -------------------------------------------------------------------------------- /spec/json_examples/emoji/dispatch_add.json: -------------------------------------------------------------------------------- 1 | { 2 | "guild_id": "1", 3 | "emojis": [ 4 | { 5 | "roles": [], 6 | "require_colons": true, 7 | "name": "emoji_name_1", 8 | "managed": false, 9 | "id": "10" 10 | }, 11 | { 12 | "roles": [], 13 | "require_colons": true, 14 | "name": "emoji_name_2", 15 | "managed": false, 16 | "id": "11" 17 | }, 18 | { 19 | "roles": [], 20 | "require_colons": true, 21 | "name": "emoji_name_3", 22 | "managed": false, 23 | "id": "12" 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /examples/pm_send.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This bot shows off PM functionality by sending a PM every time the bot is mentioned. 4 | 5 | require 'discordrb' 6 | 7 | bot = Discordrb::Bot.new token: 'B0T.T0KEN.here' 8 | 9 | # The `mention` event is called if the bot is *directly mentioned*, i.e. not using a role mention or @everyone/@here. 10 | bot.mention do |event| 11 | # The `pm` method is used to send a private message (also called a DM or direct message) to the user who sent the 12 | # initial message. 13 | event.user.pm('You have mentioned me!') 14 | end 15 | 16 | bot.run 17 | -------------------------------------------------------------------------------- /spec/json_examples/text_channel.json: -------------------------------------------------------------------------------- 1 | { 2 | "guild_id": "269512733039394816", 3 | "name": "text_channel", 4 | "permission_overwrites": [ 5 | { 6 | "deny": 0, 7 | "type": "member", 8 | "id": "66237334693085184", 9 | "allow": 131072 10 | }, 11 | { 12 | "deny": 131072, 13 | "type": "role", 14 | "id": "269512733039394816", 15 | "allow": 0 16 | } 17 | ], 18 | "topic": "This is the topic of the text channel.", 19 | "position": 1, 20 | "last_message_id": null, 21 | "type": 0, 22 | "id": "269512751339143168", 23 | "nsfw": false, 24 | "rate_limit_per_user": 0 25 | } 26 | -------------------------------------------------------------------------------- /spec/json_examples/emoji/emoji_server.json: -------------------------------------------------------------------------------- 1 | { 2 | "verification_level": 0, 3 | "explicit_content_filter": 0, 4 | "default_message_notifications": 0, 5 | "embed_enabled": false, 6 | "embed_channel_id": null, 7 | "widget_enabled": false, 8 | "widget_channel_id": null, 9 | "features": [], 10 | "id": "1", 11 | "emojis": [ 12 | { 13 | "roles": [], 14 | "require_colons": true, 15 | "name": "emoji_name_1", 16 | "managed": false, 17 | "id": "10" 18 | }, 19 | { 20 | "roles": [], 21 | "require_colons": true, 22 | "name": "emoji_name_2", 23 | "managed": false, 24 | "id": "11" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /spec/json_examples/message.json: -------------------------------------------------------------------------------- 1 | { 2 | "attachments": [], 3 | "tts": false, 4 | "embeds": [], 5 | "timestamp": "2017-09-21T19:53:15.684000+00:00", 6 | "mention_everyone": false, 7 | "id": "360513832470315009", 8 | "pinned": false, 9 | "edited_timestamp": null, 10 | "author": { 11 | "username": "Ghosty", 12 | "discriminator": "8204", 13 | "id": "97437477723193344", 14 | "avatar": "c41f34685cd25cb4c2401f56fbdaedbc" 15 | }, 16 | "mention_roles": [], 17 | "content": "i have not been able to use my computer machine due to curriculum-assigned domal tasks", 18 | "channel_id": "83281822225530880", 19 | "mentions": [], 20 | "type": 0 21 | } -------------------------------------------------------------------------------- /spec/mock/api_mock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Mock for Discordrb::API that allows setting arbitrary results and checking previous requests 4 | require 'json' 5 | 6 | module APIMock 7 | refine Discordrb::API.singleton_class do 8 | attr_reader :last_method 9 | attr_reader :last_url 10 | attr_reader :last_body 11 | attr_reader :last_headers 12 | 13 | attr_writer :next_response 14 | 15 | def raw_request(type, attributes) 16 | @last_method = type 17 | @last_url = attributes.first 18 | @last_body = if attributes[1] 19 | attributes[1].is_a?(Hash) ? nil : JSON.parse(attributes[1]) 20 | end 21 | @last_headers = attributes.last 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /examples/ping_with_respond_time.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This example is nearly the same as the normal ping example, but rather than simply responding with "Pong!", it also 4 | # responds with the time it took to send the message. 5 | 6 | require 'discordrb' 7 | 8 | bot = Discordrb::Bot.new token: 'B0T.T0KEN.here' 9 | 10 | bot.message(content: 'Ping!') do |event| 11 | # The `respond` method returns a `Message` object, which is stored in a variable `m`. The `edit` method is then called 12 | # to edit the message with the time difference between when the event was received and after the message was sent. 13 | m = event.respond('Pong!') 14 | m.edit "Pong! Time taken: #{Time.now - event.timestamp} seconds." 15 | end 16 | 17 | bot.run 18 | -------------------------------------------------------------------------------- /examples/eval.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Eval bots are useful for developers because they give you a way to execute code directly from your Discord channel, 4 | # e.g. to quickly check something, demonstrate something to other, or something else entirely. Special care must be 5 | # taken since anyone with access to the command can execute arbitrary code on your system which may potentially be 6 | # malicious. 7 | 8 | require 'discordrb' 9 | 10 | bot = Discordrb::Commands::CommandBot.new token: 'B0T.T0KEN.here', prefix: '!' 11 | 12 | bot.command(:eval, help_available: false) do |event, *code| 13 | break unless event.user.id == 66237334693085184 # Replace number with your ID 14 | 15 | begin 16 | eval code.join(' ') 17 | rescue StandardError 18 | 'An error occurred 😞' 19 | end 20 | end 21 | 22 | bot.run 23 | -------------------------------------------------------------------------------- /spec/helpers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe '#load_data_file' do 4 | it 'should load the test data correctly' do 5 | data = load_data_file(:test) 6 | expect(data['success']).to eq(true) 7 | end 8 | end 9 | 10 | describe '#fixture' do 11 | fixture :data, [:test] 12 | 13 | it 'should load the test data correctly' do 14 | expect(data['success']).to eq(true) 15 | end 16 | end 17 | 18 | describe '#fixture_property' do 19 | fixture :data, [:test] 20 | fixture_property :data_success, :data, ['success'] 21 | fixture_property :data_success_str, :data, ['success'], :to_s 22 | 23 | it 'should define the test property correctly' do 24 | expect(data_success).to eq(true) 25 | end 26 | 27 | it 'should filter data correctly' do 28 | expect(data_success_str).to eq('true') 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guidelines 2 | 3 | Any contributions are very much appreciated! This project is relatively relaxed when it comes to guidelines, however 4 | there are still some things that would be nice to have considered. 5 | 6 | For bug reports, please try to include a code sample if at all appropriate for 7 | the issue, so we can reproduce it on our own machines. 8 | 9 | For PRs, please make sure that you tested your code before every push; it's a little annoying to keep having to get back 10 | to a PR because of small avoidable oversights. (Huge bonus points if you're adding specs for your code! This project 11 | has very few specs in places where it should have more so every added spec is very much appreciated.) 12 | 13 | If you have any questions at all, don't be afraid to ask in the [discordrb channel on Discord](https://discord.gg/cyK3Hjm). 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Request a new feature, or change an existing one 4 | 5 | --- 6 | 7 | 26 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | 16 | 17 | --- 18 | 19 | 28 | 29 | ## Added 30 | 31 | ## Changed 32 | 33 | ## Deprecated 34 | 35 | ## Removed 36 | 37 | ## Fixed 38 | -------------------------------------------------------------------------------- /lib/discordrb/events/lifetime.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/generic' 4 | 5 | module Discordrb::Events 6 | # Common superclass for all lifetime events 7 | class LifetimeEvent < Event 8 | # @!visibility private 9 | def initialize(bot) 10 | @bot = bot 11 | end 12 | end 13 | 14 | # @see Discordrb::EventContainer#ready 15 | class ReadyEvent < LifetimeEvent; end 16 | 17 | # Event handler for {ReadyEvent} 18 | class ReadyEventHandler < TrueEventHandler; end 19 | 20 | # @see Discordrb::EventContainer#disconnected 21 | class DisconnectEvent < LifetimeEvent; end 22 | 23 | # Event handler for {DisconnectEvent} 24 | class DisconnectEventHandler < TrueEventHandler; end 25 | 26 | # @see Discordrb::EventContainer#heartbeat 27 | class HeartbeatEvent < LifetimeEvent; end 28 | 29 | # Event handler for {HeartbeatEvent} 30 | class HeartbeatEventHandler < TrueEventHandler; end 31 | end 32 | -------------------------------------------------------------------------------- /examples/shutdown.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This bot doesn't do anything except for letting a specifically authorised user shutdown the bot on command. 4 | 5 | require 'discordrb' 6 | 7 | bot = Discordrb::Commands::CommandBot.new token: 'B0T.T0KEN.here', prefix: '!' 8 | 9 | # Here we can see the `help_available` property used, which can determine whether a command shows up in the default 10 | # generated `help` command. It is true by default but it can be set to false to hide internal commands that only 11 | # specific people can use. 12 | bot.command(:exit, help_available: false) do |event| 13 | # This is a check that only allows a user with a specific ID to execute this command. Otherwise, everyone would be 14 | # able to shut your bot down whenever they wanted. 15 | break unless event.user.id == 66237334693085184 # Replace number with your ID 16 | 17 | bot.send_message(event.channel.id, 'Bot is shutting down') 18 | exit 19 | end 20 | 21 | bot.run 22 | -------------------------------------------------------------------------------- /discordrb-webhooks.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'discordrb/webhooks/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'discordrb-webhooks' 9 | spec.version = Discordrb::Webhooks::VERSION 10 | spec.authors = %w[meew0 swarley] 11 | spec.email = [''] 12 | 13 | spec.summary = 'Webhook client for discordrb' 14 | spec.description = "A client for Discord's webhooks to fit alongside [discordrb](https://rubygems.org/gems/discordrb)." 15 | spec.homepage = 'https://github.com/shardlab/discordrb' 16 | spec.license = 'MIT' 17 | 18 | spec.files = `git ls-files -z lib/discordrb/webhooks/`.split("\x0") + ['lib/discordrb/webhooks.rb'] 19 | spec.bindir = 'exe' 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ['lib'] 22 | 23 | spec.add_dependency 'rest-client', '>= 2.0.0' 24 | 25 | spec.required_ruby_version = '>= 2.6' 26 | end 27 | -------------------------------------------------------------------------------- /lib/discordrb/data/recipient.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # Recipients are members on private channels - they exist for completeness purposes, but all 5 | # the attributes will be empty. 6 | class Recipient < DelegateClass(User) 7 | include MemberAttributes 8 | 9 | # @return [Channel] the private channel this recipient is the recipient of. 10 | attr_reader :channel 11 | 12 | # @!visibility private 13 | def initialize(user, channel, bot) 14 | @bot = bot 15 | @channel = channel 16 | raise ArgumentError, 'Tried to create a recipient for a public channel!' unless @channel.private? 17 | 18 | @user = user 19 | super @user 20 | 21 | # Member attributes 22 | @mute = @deaf = @self_mute = @self_deaf = false 23 | @voice_channel = nil 24 | @server = nil 25 | @roles = [] 26 | @joined_at = @channel.creation_time 27 | end 28 | 29 | # Overwriting inspect for debug purposes 30 | def inspect 31 | "" 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2020 meew0 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/discordrb/data/reaction.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # A reaction to a message. 5 | class Reaction 6 | # @return [Integer] the amount of users who have reacted with this reaction 7 | attr_reader :count 8 | 9 | # @return [true, false] whether the current bot or user used this reaction 10 | attr_reader :me 11 | alias_method :me?, :me 12 | 13 | # @return [Integer] the ID of the emoji, if it was custom 14 | attr_reader :id 15 | 16 | # @return [String] the name or unicode representation of the emoji 17 | attr_reader :name 18 | 19 | def initialize(data) 20 | @count = data['count'] 21 | @me = data['me'] 22 | @id = data['emoji']['id'].nil? ? nil : data['emoji']['id'].to_i 23 | @name = data['emoji']['name'] 24 | end 25 | 26 | # Converts this Reaction into a string that can be sent back to Discord in other reaction endpoints. 27 | # If ID is present, it will be rendered into the form of `name:id`. 28 | # @return [String] the name of this reaction, including the ID if it is a custom emoji 29 | def to_s 30 | id.nil? ? name : "#{name}:#{id}" 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug to help us improve the library 4 | 5 | --- 6 | 7 | # Summary 8 | 9 | 23 | 24 | --- 25 | 26 | ## Environment 27 | 28 | 32 | 33 | **Ruby version:** 34 | 35 | 36 | 37 | **Discordrb version:** 38 | 39 | 40 | -------------------------------------------------------------------------------- /lib/discordrb/api/invite.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # API calls for Invite object 4 | module Discordrb::API::Invite 5 | module_function 6 | 7 | # Resolve an invite 8 | # https://discord.com/developers/docs/resources/invite#get-invite 9 | def resolve(token, invite_code, counts = true) 10 | Discordrb::API.request( 11 | :invite_code, 12 | nil, 13 | :get, 14 | "#{Discordrb::API.api_base}/invites/#{invite_code}#{counts ? '?with_counts=true' : ''}", 15 | Authorization: token 16 | ) 17 | end 18 | 19 | # Delete an invite by code 20 | # https://discord.com/developers/docs/resources/invite#delete-invite 21 | def delete(token, code, reason = nil) 22 | Discordrb::API.request( 23 | :invites_code, 24 | nil, 25 | :delete, 26 | "#{Discordrb::API.api_base}/invites/#{code}", 27 | Authorization: token, 28 | 'X-Audit-Log-Reason': reason 29 | ) 30 | end 31 | 32 | # Join a server using an invite 33 | # https://discord.com/developers/docs/resources/invite#accept-invite 34 | def accept(token, invite_code) 35 | Discordrb::API.request( 36 | :invite_code, 37 | nil, 38 | :post, 39 | "#{Discordrb::API.api_base}/invites/#{invite_code}", 40 | nil, 41 | Authorization: token 42 | ) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/discordrb/events/raw.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/generic' 4 | 5 | # Event classes and handlers 6 | module Discordrb::Events 7 | # Event raised when any dispatch is received 8 | class RawEvent < Event 9 | # @return [Symbol] the type of this dispatch. 10 | attr_reader :type 11 | alias_method :t, :type 12 | 13 | # @return [Hash] the data of this dispatch. 14 | attr_reader :data 15 | alias_method :d, :data 16 | 17 | def initialize(type, data, bot) 18 | @type = type 19 | @data = data 20 | @bot = bot 21 | end 22 | end 23 | 24 | # Event handler for {RawEvent} 25 | class RawEventHandler < EventHandler 26 | def matches?(event) 27 | # Check for the proper event type 28 | return false unless event.is_a? RawEvent 29 | 30 | [ 31 | matches_all(@attributes[:type] || @attributes[:t], event.type) do |a, e| 32 | if a.is_a? Regexp 33 | a.match?(e) 34 | else 35 | e.to_s.casecmp(a.to_s).zero? 36 | end 37 | end 38 | ].reduce(true, &:&) 39 | end 40 | end 41 | 42 | # Event raised when an unknown dispatch is received 43 | class UnknownEvent < RawEvent; end 44 | 45 | # Event handler for {UnknownEvent} 46 | class UnknownEventHandler < RawEventHandler; end 47 | end 48 | -------------------------------------------------------------------------------- /spec/logger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | 5 | describe Discordrb::Logger do 6 | it 'should log messages' do 7 | stream = spy 8 | logger = Discordrb::Logger.new(false, [stream]) 9 | 10 | logger.error('Testing') 11 | 12 | expect(stream).to have_received(:puts).with(something_including('Testing')) 13 | end 14 | 15 | it 'should respect the log mode' do 16 | stream = spy 17 | logger = Discordrb::Logger.new(false, [stream]) 18 | logger.mode = :silent 19 | 20 | logger.error('Testing') 21 | 22 | expect(stream).to_not have_received(:puts) 23 | end 24 | 25 | context 'fancy mode' do 26 | it 'should log messages' do 27 | stream = spy 28 | logger = Discordrb::Logger.new(true, [stream]) 29 | 30 | logger.error('Testing') 31 | 32 | expect(stream).to have_received(:puts).with(something_including('Testing')) 33 | end 34 | end 35 | 36 | context 'redacted token' do 37 | it 'should redact the token from messages' do 38 | stream = spy 39 | logger = Discordrb::Logger.new(true, [stream]) 40 | logger.token = 'asdfg' 41 | 42 | logger.error('this message contains a token that should be redacted: asdfg') 43 | 44 | expect(stream).to have_received(:puts).with(something_not_including('asdfg')) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/discordrb/id_object.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # Mixin for objects that have IDs 5 | module IDObject 6 | # @return [Integer] the ID which uniquely identifies this object across Discord. 7 | attr_reader :id 8 | alias_method :resolve_id, :id 9 | alias_method :hash, :id 10 | 11 | # ID based comparison 12 | def ==(other) 13 | Discordrb.id_compare(@id, other) 14 | end 15 | 16 | alias_method :eql?, :== 17 | 18 | # Estimates the time this object was generated on based on the beginning of the ID. This is fairly accurate but 19 | # shouldn't be relied on as Discord might change its algorithm at any time 20 | # @return [Time] when this object was created at 21 | def creation_time 22 | # Milliseconds 23 | ms = (@id >> 22) + DISCORD_EPOCH 24 | Time.at(ms / 1000.0) 25 | end 26 | 27 | # Creates an artificial snowflake at the given point in time. Useful for comparing against. 28 | # @param time [Time] The time the snowflake should represent. 29 | # @return [Integer] a snowflake with the timestamp data as the given time 30 | def self.synthesise(time) 31 | ms = (time.to_f * 1000).to_i 32 | (ms - DISCORD_EPOCH) << 22 33 | end 34 | 35 | class << self 36 | alias_method :synthesize, :synthesise 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/errors_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | 5 | describe Discordrb::Errors do 6 | describe 'Code' do 7 | it 'should create a class without errors' do 8 | Discordrb::Errors.Code(10_000) 9 | end 10 | 11 | describe 'the created class' do 12 | it 'should contain the correct code' do 13 | classy = Discordrb::Errors.Code(10_001) 14 | expect(classy.code).to eq(10_001) 15 | end 16 | 17 | it 'should create an instance with the correct code' do 18 | classy = Discordrb::Errors.Code(10_002) 19 | error = classy.new 'random message' 20 | expect(error.code).to eq(10_002) 21 | expect(error.message).to eq 'random message' 22 | end 23 | end 24 | end 25 | 26 | describe 'error_class_for' do 27 | it 'should return the correct class for code 40001' do 28 | classy = Discordrb::Errors.error_class_for(40_001) 29 | expect(classy).to be(Discordrb::Errors::Unauthorized) 30 | end 31 | end 32 | 33 | describe Discordrb::Errors::Unauthorized do 34 | it 'should exist' do 35 | expect(Discordrb::Errors::Unauthorized).to be_a(Class) 36 | end 37 | 38 | it 'should have the correct code' do 39 | instance = Discordrb::Errors::Unauthorized.new('some message') 40 | expect(instance.code).to eq(40_001) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/data/role_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | 5 | describe Discordrb::Role do 6 | let(:server) { double('server', id: double) } 7 | let(:bot) { double('bot', token: double) } 8 | 9 | subject(:role) do 10 | described_class.new(role_data, bot, server) 11 | end 12 | 13 | fixture :role_data, %i[role] 14 | 15 | describe '#sort_above' do 16 | context 'when other is nil' do 17 | it 'sorts the role to position 1' do 18 | allow(server).to receive(:update_role_positions) 19 | allow(server).to receive(:roles).and_return [ 20 | double(id: 0, position: 0), 21 | double(id: 1, position: 1) 22 | ] 23 | 24 | new_position = role.sort_above 25 | expect(new_position).to eq 1 26 | end 27 | end 28 | 29 | context 'when other is given' do 30 | it 'sorts above other' do 31 | other = double(id: 1, position: 1, resolve_id: 1) 32 | allow(server).to receive(:update_role_positions) 33 | allow(server).to receive(:role).and_return other 34 | allow(server).to receive(:roles).and_return [ 35 | double(id: 0, position: 0), 36 | other, 37 | double(id: 2, position: 2) 38 | ] 39 | 40 | new_position = role.sort_above(other) 41 | expect(new_position).to eq 2 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/discordrb/data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/allowed_mentions' 4 | require 'discordrb/permissions' 5 | require 'discordrb/id_object' 6 | require 'discordrb/colour_rgb' 7 | require 'discordrb/errors' 8 | require 'discordrb/api' 9 | require 'discordrb/api/channel' 10 | require 'discordrb/api/server' 11 | require 'discordrb/api/invite' 12 | require 'discordrb/api/user' 13 | require 'discordrb/api/webhook' 14 | require 'discordrb/webhooks/embeds' 15 | require 'discordrb/paginator' 16 | require 'time' 17 | require 'base64' 18 | 19 | require 'discordrb/data/activity' 20 | require 'discordrb/data/application' 21 | require 'discordrb/data/user' 22 | require 'discordrb/data/voice_state' 23 | require 'discordrb/data/voice_region' 24 | require 'discordrb/data/member' 25 | require 'discordrb/data/recipient' 26 | require 'discordrb/data/profile' 27 | require 'discordrb/data/role' 28 | require 'discordrb/data/invite' 29 | require 'discordrb/data/overwrite' 30 | require 'discordrb/data/channel' 31 | require 'discordrb/data/embed' 32 | require 'discordrb/data/attachment' 33 | require 'discordrb/data/message' 34 | require 'discordrb/data/reaction' 35 | require 'discordrb/data/emoji' 36 | require 'discordrb/data/integration' 37 | require 'discordrb/data/server' 38 | require 'discordrb/data/webhook' 39 | require 'discordrb/data/audit_logs' 40 | require 'discordrb/data/interaction' 41 | require 'discordrb/data/component' 42 | -------------------------------------------------------------------------------- /spec/data/emoji_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | 5 | describe Discordrb::Emoji do 6 | let(:bot) { double('bot') } 7 | 8 | subject(:emoji) do 9 | server = double('server', role: double) 10 | 11 | described_class.new(emoji_data, bot, server) 12 | end 13 | 14 | fixture :emoji_data, %i[emoji] 15 | 16 | describe '#mention' do 17 | context 'with an animated emoji' do 18 | it 'serializes with animated flag' do 19 | allow(emoji).to receive(:animated).and_return(true) 20 | 21 | expect(emoji.mention).to eq '' 22 | end 23 | end 24 | 25 | context 'with a unicode emoji' do 26 | it 'serializes' do 27 | allow(emoji).to receive(:id).and_return(nil) 28 | 29 | expect(emoji.mention).to eq 'rubytaco' 30 | end 31 | end 32 | 33 | it 'serializes' do 34 | expect(emoji.mention).to eq '<:rubytaco:315242245274075157>' 35 | end 36 | end 37 | 38 | describe '#to_reaction' do 39 | it 'serializes to reaction format' do 40 | expect(emoji.to_reaction).to eq 'rubytaco:315242245274075157' 41 | end 42 | 43 | context 'when ID is nil' do 44 | it 'serializes to reaction format without custom emoji ID character' do 45 | allow(emoji).to receive(:id).and_return(nil) 46 | 47 | expect(emoji.to_reaction).to eq 'rubytaco' 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/discordrb/data/voice_region.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # Voice regions are the locations of servers that handle voice communication in Discord 5 | class VoiceRegion 6 | # @return [String] unique ID for the region 7 | attr_reader :id 8 | alias_method :to_s, :id 9 | 10 | # @return [String] name of the region 11 | attr_reader :name 12 | 13 | # @return [String] an example hostname for the region 14 | attr_reader :sample_hostname 15 | 16 | # @return [Integer] an example port for the region 17 | attr_reader :sample_port 18 | 19 | # @return [true, false] if this is a VIP-only server 20 | attr_reader :vip 21 | 22 | # @return [true, false] if this voice server is the closest to the client 23 | attr_reader :optimal 24 | 25 | # @return [true, false] whether this is a deprecated voice region (avoid switching to these) 26 | attr_reader :deprecated 27 | 28 | # @return [true, false] whether this is a custom voice region (used for events/etc) 29 | attr_reader :custom 30 | 31 | def initialize(data) 32 | @id = data['id'] 33 | 34 | @name = data['name'] 35 | 36 | @sample_hostname = data['sample_hostname'] 37 | @sample_port = data['sample_port'] 38 | 39 | @vip = data['vip'] 40 | @optimal = data['optimal'] 41 | @deprecated = data['deprecated'] 42 | @custom = data['custom'] 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/discordrb/events/voice_server_update.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/generic' 4 | require 'discordrb/data' 5 | 6 | module Discordrb::Events 7 | # Event raised when a server's voice server is updating. 8 | # Sent when initially connecting to voice and when a voice instance fails 9 | # over to a new server. 10 | # This event is exposed for use with library agnostic interfaces like telecom and 11 | # lavalink. 12 | class VoiceServerUpdateEvent < Event 13 | # @return [String] The voice connection token 14 | attr_reader :token 15 | 16 | # @return [Server] The server this update is for. 17 | attr_reader :server 18 | 19 | # @return [String] The voice server host. 20 | attr_reader :endpoint 21 | 22 | def initialize(data, bot) 23 | @bot = bot 24 | 25 | @token = data['token'] 26 | @endpoint = data['endpoint'] 27 | @server = bot.server(data['guild_id']) 28 | end 29 | end 30 | 31 | # Event handler for VoiceServerUpdateEvent 32 | class VoiceServerUpdateEventHandler < EventHandler 33 | def matches?(event) 34 | return false unless event.is_a? VoiceServerUpdateEvent 35 | 36 | [ 37 | matches_all(@attributes[:from], event.server) do |a, e| 38 | a == if a.is_a? String 39 | e.name 40 | else 41 | e 42 | end 43 | end 44 | ] 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/discordrb/events/await.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/generic' 4 | require 'discordrb/await' 5 | 6 | module Discordrb::Events 7 | # @see Bot#await 8 | class AwaitEvent < Event 9 | # The await that was triggered. 10 | # @return [Await] The await 11 | attr_reader :await 12 | 13 | # The event that triggered the await. 14 | # @return [Event] The event 15 | attr_reader :event 16 | 17 | # @!attribute [r] key 18 | # @return [Symbol] the await's key. 19 | # @see Await#key 20 | # @!attribute [r] type 21 | # @return [Class] the await's event class. 22 | # @see Await#type 23 | # @!attribute [r] attributes 24 | # @return [Hash] a hash of attributes defined on the await. 25 | # @see Await#attributes 26 | delegate :key, :type, :attributes, to: :await 27 | 28 | # For internal use only 29 | def initialize(await, event, bot) 30 | @await = await 31 | @event = event 32 | @bot = bot 33 | end 34 | end 35 | 36 | # Event handler for {AwaitEvent} 37 | class AwaitEventHandler < EventHandler 38 | def matches?(event) 39 | # Check for the proper event type 40 | return false unless event.is_a? AwaitEvent 41 | 42 | [ 43 | matches_all(@attributes[:key], event.key) { |a, e| a == e }, 44 | matches_all(@attributes[:type], event.type) { |a, e| a == e } 45 | ].reduce(true, &:&) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/discordrb/allowed_mentions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/id_object' 4 | 5 | module Discordrb 6 | # Builder class for `allowed_mentions` when sending messages. 7 | class AllowedMentions 8 | # @return [Array<"users", "roles", "everyone">, nil] 9 | attr_accessor :parse 10 | 11 | # @return [Array, nil] 12 | attr_accessor :users 13 | 14 | # @return [Array, nil] 15 | attr_accessor :roles 16 | 17 | # @param parse [Array<"users", "roles", "everyone">] Mention types that can be inferred from the message. 18 | # `users` and `roles` allow for all mentions of the respective type to ping. `everyone` allows usage of `@everyone` and `@here` 19 | # @param users [Array] Users or user IDs that can be pinged. Cannot be used in conjunction with `"users"` in `parse` 20 | # @param roles [Array] Roles or role IDs that can be pinged. Cannot be used in conjunction with `"roles"` in `parse` 21 | def initialize(parse: nil, users: nil, roles: nil) 22 | @parse = parse 23 | @users = users 24 | @roles = roles 25 | end 26 | 27 | # @!visibility private 28 | def to_hash 29 | { 30 | parse: @parse, 31 | users: @users&.map { |user| user.is_a?(IDObject) ? user.id : user }, 32 | roles: @roles&.map { |role| role.is_a?(IDObject) ? role.id : role } 33 | }.compact 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/discordrb/data/voice_state.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # A voice state represents the state of a member's connection to a voice channel. It includes data like the voice 5 | # channel the member is connected to and mute/deaf flags. 6 | class VoiceState 7 | # @return [Integer] the ID of the user whose voice state is represented by this object. 8 | attr_reader :user_id 9 | 10 | # @return [true, false] whether this voice state's member is muted server-wide. 11 | attr_reader :mute 12 | 13 | # @return [true, false] whether this voice state's member is deafened server-wide. 14 | attr_reader :deaf 15 | 16 | # @return [true, false] whether this voice state's member has muted themselves. 17 | attr_reader :self_mute 18 | 19 | # @return [true, false] whether this voice state's member has deafened themselves. 20 | attr_reader :self_deaf 21 | 22 | # @return [Channel] the voice channel this voice state's member is in. 23 | attr_reader :voice_channel 24 | 25 | # @!visibility private 26 | def initialize(user_id) 27 | @user_id = user_id 28 | end 29 | 30 | # Update this voice state with new data from Discord 31 | # @note For internal use only. 32 | # @!visibility private 33 | def update(channel, mute, deaf, self_mute, self_deaf) 34 | @voice_channel = channel 35 | @mute = mute 36 | @deaf = deaf 37 | @self_mute = self_mute 38 | @self_deaf = self_deaf 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/discordrb/data/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # OAuth Application information 5 | class Application 6 | include IDObject 7 | 8 | # @return [String] the application name 9 | attr_reader :name 10 | 11 | # @return [String] the application description 12 | attr_reader :description 13 | 14 | # @return [Array] the application's origins permitted to use RPC 15 | attr_reader :rpc_origins 16 | 17 | # @return [Integer] 18 | attr_reader :flags 19 | 20 | # Gets the user object of the owner. May be limited to username, discriminator, 21 | # ID, and avatar if the bot cannot reach the owner. 22 | # @return [User] the user object of the owner 23 | attr_reader :owner 24 | 25 | def initialize(data, bot) 26 | @bot = bot 27 | 28 | @name = data['name'] 29 | @id = data['id'].to_i 30 | @description = data['description'] 31 | @icon_id = data['icon'] 32 | @rpc_origins = data['rpc_origins'] 33 | @flags = data['flags'] 34 | @owner = @bot.ensure_user(data['owner']) 35 | end 36 | 37 | # Utility function to get a application's icon URL. 38 | # @return [String, nil] the URL of the icon image (nil if no image is set). 39 | def icon_url 40 | return nil if @icon_id.nil? 41 | 42 | API.app_icon_url(@id, @icon_id) 43 | end 44 | 45 | # The inspect method is overwritten to give more useful output 46 | def inspect 47 | "" 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/paginator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | 5 | describe Discordrb::Paginator do 6 | context 'direction down' do 7 | it 'requests all pages until empty' do 8 | data = [ 9 | [1, 2, 3], 10 | [4, 5], 11 | [], 12 | [6, 7] 13 | ] 14 | 15 | index = 0 16 | paginator = Discordrb::Paginator.new(nil, :down) do |last_page| 17 | expect(last_page).to eq data[index - 1] if last_page 18 | next_page = data[index] 19 | index += 1 20 | next_page 21 | end 22 | 23 | expect(paginator.to_a).to eq [1, 2, 3, 4, 5] 24 | end 25 | end 26 | 27 | context 'direction up' do 28 | it 'requests all pages until empty' do 29 | data = [ 30 | [6, 7], 31 | [4, 5], 32 | [], 33 | [1, 2, 3] 34 | ] 35 | 36 | index = 0 37 | paginator = Discordrb::Paginator.new(nil, :up) do |last_page| 38 | expect(last_page).to eq data[index - 1] if last_page 39 | next_page = data[index] 40 | index += 1 41 | next_page 42 | end 43 | 44 | expect(paginator.to_a).to eq [7, 6, 5, 4] 45 | end 46 | end 47 | 48 | it 'only returns up to limit items' do 49 | data = [ 50 | [1, 2, 3], 51 | [4, 5], 52 | [] 53 | ] 54 | 55 | index = 0 56 | paginator = Discordrb::Paginator.new(2, :down) do |_last_page| 57 | next_page = data[index] 58 | index += 1 59 | next_page 60 | end 61 | 62 | expect(paginator.to_a).to eq [1, 2] 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/api_mock_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | require 'mock/api_mock' 5 | using APIMock 6 | 7 | describe APIMock do 8 | it 'stores the used method' do 9 | Discordrb::API.raw_request(:get, []) 10 | 11 | expect(Discordrb::API.last_method).to eq :get 12 | end 13 | 14 | it 'stores the used URL' do 15 | url = 'https://example.com/test' 16 | Discordrb::API.raw_request(:get, [url]) 17 | 18 | expect(Discordrb::API.last_url).to eq url 19 | end 20 | 21 | it 'parses the stored body using JSON' do 22 | body = { test: 1 } 23 | Discordrb::API.raw_request(:post, ['https://example.com/test', body.to_json]) 24 | 25 | expect(Discordrb::API.last_body['test']).to eq 1 26 | end 27 | 28 | it "doesn't parse the body if there is none present" do 29 | Discordrb::API.raw_request(:post, ['https://example.com/test', nil]) 30 | 31 | expect(Discordrb::API.last_body).to be_nil 32 | end 33 | 34 | it 'parses headers if there is no body' do 35 | Discordrb::API.raw_request(:post, ['https://example.com/test', nil, { a: 1, b: 2 }]) 36 | 37 | expect(Discordrb::API.last_headers[:a]).to eq 1 38 | expect(Discordrb::API.last_headers[:b]).to eq 2 39 | end 40 | 41 | it 'parses body and headers if there is a body' do 42 | Discordrb::API.raw_request(:post, ['https://example.com/test', { test: 1 }.to_json, { a: 1, b: 2 }]) 43 | 44 | expect(Discordrb::API.last_body['test']).to eq 1 45 | expect(Discordrb::API.last_headers[:a]).to eq 1 46 | expect(Discordrb::API.last_headers[:b]).to eq 2 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/discordrb/colour_rgb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # A colour (red, green and blue values). Used for role colours. If you prefer the American spelling, the alias 5 | # {ColorRGB} is also available. 6 | class ColourRGB 7 | # @return [Integer] the red part of this colour (0-255). 8 | attr_reader :red 9 | 10 | # @return [Integer] the green part of this colour (0-255). 11 | attr_reader :green 12 | 13 | # @return [Integer] the blue part of this colour (0-255). 14 | attr_reader :blue 15 | 16 | # @return [Integer] the colour's RGB values combined into one integer. 17 | attr_reader :combined 18 | alias_method :to_i, :combined 19 | 20 | # Make a new colour from the combined value. 21 | # @param combined [String, Integer] The colour's RGB values combined into one integer or a hexadecimal string 22 | # @example Initialize a with a base 10 integer 23 | # ColourRGB.new(7506394) #=> ColourRGB 24 | # ColourRGB.new(0x7289da) #=> ColourRGB 25 | # @example Initialize a with a hexadecimal string 26 | # ColourRGB.new('7289da') #=> ColourRGB 27 | def initialize(combined) 28 | @combined = combined.is_a?(String) ? combined.to_i(16) : combined 29 | @red = (@combined >> 16) & 0xFF 30 | @green = (@combined >> 8) & 0xFF 31 | @blue = @combined & 0xFF 32 | end 33 | 34 | # @return [String] the colour as a hexadecimal. 35 | def hex 36 | @combined.to_s(16) 37 | end 38 | alias_method :hexadecimal, :hex 39 | end 40 | 41 | # Alias for the class {ColourRGB} 42 | ColorRGB = ColourRGB 43 | end 44 | -------------------------------------------------------------------------------- /spec/sodium_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/voice/sodium' 4 | 5 | describe Discordrb::Voice::SecretBox do 6 | def rand_bytes(size) 7 | bytes = Array.new(size) { rand(256) } 8 | bytes.pack('C*') 9 | end 10 | 11 | it 'encrypts round trip' do 12 | key = rand_bytes(Discordrb::Voice::SecretBox::KEY_LENGTH) 13 | nonce = rand_bytes(Discordrb::Voice::SecretBox::NONCE_BYTES) 14 | message = rand_bytes(20) 15 | 16 | secret_box = Discordrb::Voice::SecretBox.new(key) 17 | ct = secret_box.box(nonce, message) 18 | pt = secret_box.open(nonce, ct) 19 | expect(pt).to eq message 20 | end 21 | 22 | it 'raises on invalid key length' do 23 | key = rand_bytes(Discordrb::Voice::SecretBox::KEY_LENGTH - 1) 24 | expect { Discordrb::Voice::SecretBox.new(key) }.to raise_error(Discordrb::Voice::SecretBox::LengthError) 25 | end 26 | 27 | describe '#box' do 28 | it 'raises on invalid nonce length' do 29 | key = rand_bytes(Discordrb::Voice::SecretBox::KEY_LENGTH) 30 | nonce = rand_bytes(Discordrb::Voice::SecretBox::NONCE_BYTES - 1) 31 | expect { Discordrb::Voice::SecretBox.new(key).box(nonce, '') }.to raise_error(Discordrb::Voice::SecretBox::LengthError) 32 | end 33 | end 34 | 35 | describe '#open' do 36 | it 'raises on invalid nonce length' do 37 | key = rand_bytes(Discordrb::Voice::SecretBox::KEY_LENGTH) 38 | nonce = rand_bytes(Discordrb::Voice::SecretBox::NONCE_BYTES - 1) 39 | expect { Discordrb::Voice::SecretBox.new(key).open(nonce, '') }.to raise_error(Discordrb::Voice::SecretBox::LengthError) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-performance 3 | - rubocop-rake 4 | 5 | inherit_mode: 6 | merge: 7 | - AllowedNames 8 | 9 | AllCops: 10 | NewCops: enable 11 | TargetRubyVersion: 2.6 12 | 13 | # Disable line length checks 14 | Layout/LineLength: 15 | Enabled: false 16 | 17 | # TODO: Larger refactor 18 | Lint/MissingSuper: 19 | Enabled: false 20 | 21 | # Allow 'Pokémon-style' exception handling 22 | Lint/RescueException: 23 | Enabled: false 24 | 25 | # Disable all metrics. 26 | Metrics: 27 | Enabled: false 28 | 29 | # Allow some common and/or obvious short method params 30 | Naming/MethodParameterName: 31 | AllowedNames: 32 | - e 33 | 34 | # Ignore `eval` in the examples folder 35 | Security/Eval: 36 | Exclude: 37 | - examples/**/* 38 | 39 | # https://stackoverflow.com/q/4763121/ 40 | Style/Alias: 41 | Enabled: false 42 | 43 | # Had to disable this globally because it's being silently autocorrected even with local disable comments? 44 | Style/BisectedAttrAccessor: 45 | Enabled: false 46 | 47 | # Prefer compact module/class defs 48 | Style/ClassAndModuleChildren: 49 | Enabled: false 50 | 51 | # So RuboCop doesn't complain about application IDs 52 | Style/NumericLiterals: 53 | Exclude: 54 | - examples/**/* 55 | 56 | # TODO: Requires breaking changes 57 | Style/OptionalBooleanParameter: 58 | Enabled: false 59 | 60 | # Prefer explicit arguments in case global variables like `$;` or `$,` are changed 61 | Style/RedundantArgument: 62 | Enabled: false 63 | 64 | # Prefer |m, e| for the `reduce` block arguments 65 | Style/SingleLineBlockParams: 66 | Methods: 67 | - reduce: [m, e] 68 | - inject: [m, e] 69 | -------------------------------------------------------------------------------- /examples/ping.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This simple bot responds to every "Ping!" message with a "Pong!" 4 | 5 | require 'discordrb' 6 | 7 | # This statement creates a bot with the specified token and application ID. After this line, you can add events to the 8 | # created bot, and eventually run it. 9 | # 10 | # If you don't yet have a token to put in here, you will need to create a bot account here: 11 | # https://discord.com/developers/applications 12 | # If you're wondering about what redirect URIs and RPC origins, you can ignore those for now. If that doesn't satisfy 13 | # you, look here: https://github.com/discordrb/discordrb/wiki/Redirect-URIs-and-RPC-origins 14 | # After creating the bot, simply copy the token (*not* the OAuth2 secret) and put it into the 15 | # respective place. 16 | bot = Discordrb::Bot.new token: 'B0T.T0KEN.here' 17 | 18 | # Here we output the invite URL to the console so the bot account can be invited to the channel. This only has to be 19 | # done once, afterwards, you can remove this part if you want 20 | puts "This bot's invite URL is #{bot.invite_url}." 21 | puts 'Click on it to invite it to your server.' 22 | 23 | # This method call adds an event handler that will be called on any message that exactly contains the string "Ping!". 24 | # The code inside it will be executed, and a "Pong!" response will be sent to the channel. 25 | bot.message(content: 'Ping!') do |event| 26 | event.respond 'Pong!' 27 | end 28 | 29 | # This method call has to be put at the end of your script, it is what makes the bot actually connect to Discord. If you 30 | # leave it out (try it!) the script will simply stop and the bot will not appear online. 31 | bot.run 32 | -------------------------------------------------------------------------------- /lib/discordrb/events/bans.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/generic' 4 | 5 | module Discordrb::Events 6 | # Raised when a user is banned 7 | class UserBanEvent < Event 8 | # @return [User] the user that was banned 9 | attr_reader :user 10 | 11 | # @return [Server] the server from which the user was banned 12 | attr_reader :server 13 | 14 | # @!visibility private 15 | def initialize(data, bot) 16 | @user = bot.user(data['user']['id'].to_i) 17 | @server = bot.server(data['guild_id'].to_i) 18 | @bot = bot 19 | end 20 | end 21 | 22 | # Event handler for {UserBanEvent} 23 | class UserBanEventHandler < EventHandler 24 | def matches?(event) 25 | # Check for the proper event type 26 | return false unless event.is_a? UserBanEvent 27 | 28 | [ 29 | matches_all(@attributes[:user], event.user) do |a, e| 30 | case a 31 | when String 32 | a == e.name 33 | when Integer 34 | a == e.id 35 | when :bot 36 | e.current_bot? 37 | else 38 | a == e 39 | end 40 | end, 41 | matches_all(@attributes[:server], event.server) do |a, e| 42 | a == case a 43 | when String 44 | e.name 45 | when Integer 46 | e.id 47 | else 48 | e 49 | end 50 | end 51 | ].reduce(true, &:&) 52 | end 53 | end 54 | 55 | # Raised when a user is unbanned from a server 56 | class UserUnbanEvent < UserBanEvent; end 57 | 58 | # Event handler for {UserUnbanEvent} 59 | class UserUnbanEventHandler < UserBanEventHandler; end 60 | end 61 | -------------------------------------------------------------------------------- /lib/discordrb/data/attachment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # An attachment to a message 5 | class Attachment 6 | include IDObject 7 | 8 | # @return [Message] the message this attachment belongs to. 9 | attr_reader :message 10 | 11 | # @return [String] the CDN URL this attachment can be downloaded at. 12 | attr_reader :url 13 | 14 | # @return [String] the attachment's proxy URL - I'm not sure what exactly this does, but I think it has something to 15 | # do with CDNs. 16 | attr_reader :proxy_url 17 | 18 | # @return [String] the attachment's filename. 19 | attr_reader :filename 20 | 21 | # @return [Integer] the attachment's file size in bytes. 22 | attr_reader :size 23 | 24 | # @return [Integer, nil] the width of an image file, in pixels, or `nil` if the file is not an image. 25 | attr_reader :width 26 | 27 | # @return [Integer, nil] the height of an image file, in pixels, or `nil` if the file is not an image. 28 | attr_reader :height 29 | 30 | # @!visibility private 31 | def initialize(data, message, bot) 32 | @bot = bot 33 | @message = message 34 | 35 | @id = data['id'].to_i 36 | @url = data['url'] 37 | @proxy_url = data['proxy_url'] 38 | @filename = data['filename'] 39 | 40 | @size = data['size'] 41 | 42 | @width = data['width'] 43 | @height = data['height'] 44 | end 45 | 46 | # @return [true, false] whether this file is an image file. 47 | def image? 48 | !(@width.nil? || @height.nil?) 49 | end 50 | 51 | # @return [true, false] whether this file is tagged as a spoiler. 52 | def spoiler? 53 | @filename.start_with? 'SPOILER_' 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/discordrb/events/webhooks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/generic' 4 | require 'discordrb/data' 5 | 6 | module Discordrb::Events 7 | # Event raised when a webhook is updated 8 | class WebhookUpdateEvent < Event 9 | # @return [Server] the server where the webhook updated 10 | attr_reader :server 11 | 12 | # @return [Channel] the channel the webhook is associated to 13 | attr_reader :channel 14 | 15 | def initialize(data, bot) 16 | @bot = bot 17 | 18 | @server = bot.server(data['guild_id'].to_i) 19 | @channel = bot.channel(data['channel_id'].to_i) 20 | end 21 | end 22 | 23 | # Event handler for {WebhookUpdateEvent} 24 | class WebhookUpdateEventHandler < EventHandler 25 | def matches?(event) 26 | # Check for the proper event type 27 | return false unless event.is_a? WebhookUpdateEvent 28 | 29 | [ 30 | matches_all(@attributes[:server], event.server) do |a, e| 31 | a == case a 32 | when String 33 | e.name 34 | when Integer 35 | e.id 36 | else 37 | e 38 | end 39 | end, 40 | matches_all(@attributes[:channel], event.channel) do |a, e| 41 | case a 42 | when String 43 | # Make sure to remove the "#" from channel names in case it was specified 44 | a.delete('#') == e.name 45 | when Integer 46 | a == e.id 47 | else 48 | a == e 49 | end 50 | end, 51 | matches_all(@attributes[:webhook], event) do |a, e| 52 | a == case a 53 | when String 54 | e.name 55 | when Integer 56 | e.id 57 | else 58 | e 59 | end 60 | end 61 | ].reduce(true, &:&) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/discordrb/light/data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/data' 4 | 5 | module Discordrb::Light 6 | # Represents the bot account used for the light bot, but without any methods to change anything. 7 | class LightProfile 8 | include Discordrb::IDObject 9 | include Discordrb::UserAttributes 10 | 11 | # @!visibility private 12 | def initialize(data, bot) 13 | @bot = bot 14 | 15 | @username = data['username'] 16 | @id = data['id'].to_i 17 | @discriminator = data['discriminator'] 18 | @avatar_id = data['avatar'] 19 | 20 | @bot_account = false 21 | @bot_account = true if data['bot'] 22 | 23 | @verified = data['verified'] 24 | 25 | @email = data['email'] 26 | end 27 | end 28 | 29 | # A server that only has an icon, a name, and an ID associated with it, like for example an integration's server. 30 | class UltraLightServer 31 | include Discordrb::IDObject 32 | include Discordrb::ServerAttributes 33 | 34 | # @!visibility private 35 | def initialize(data, bot) 36 | @bot = bot 37 | 38 | @id = data['id'].to_i 39 | 40 | @name = data['name'] 41 | @icon_id = data['icon'] 42 | end 43 | end 44 | 45 | # Represents a light server which only has a fraction of the properties of any other server. 46 | class LightServer < UltraLightServer 47 | # @return [true, false] whether or not the LightBot this server belongs to is the owner of the server. 48 | attr_reader :bot_is_owner 49 | alias_method :bot_is_owner?, :bot_is_owner 50 | 51 | # @return [Discordrb::Permissions] the permissions the LightBot has on this server 52 | attr_reader :bot_permissions 53 | 54 | # @!visibility private 55 | def initialize(data, bot) 56 | super(data, bot) 57 | 58 | @bot_is_owner = data['owner'] 59 | @bot_permissions = Discordrb::Permissions.new(data['permissions']) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/discordrb/paginator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # Utility class for wrapping paginated endpoints. It is [Enumerable](https://ruby-doc.org/core-2.5.1/Enumerable.html), 5 | # similar to an `Array`, so most of the same methods can be used to filter the results of the request 6 | # that it wraps. If you simply want an array of all of the results, `#to_a` can be called. 7 | class Paginator 8 | include Enumerable 9 | 10 | # Creates a new {Paginator} 11 | # @param limit [Integer] the maximum number of items to request before stopping 12 | # @param direction [:up, :down] the order in which results are returned in 13 | # @yield [Array, nil] the last page of results, or nil if this is the first iteration. 14 | # This should be used to request the next page of results. 15 | # @yieldreturn [Array] the next page of results 16 | def initialize(limit, direction, &block) 17 | @count = 0 18 | @limit = limit 19 | @direction = direction 20 | @block = block 21 | end 22 | 23 | # Yields every item produced by the wrapped request, until it returns 24 | # no more results or the configured `limit` is reached. 25 | def each 26 | last_page = nil 27 | until limit_check 28 | page = @block.call(last_page) 29 | return if page.empty? 30 | 31 | enumerator = case @direction 32 | when :down 33 | page.each 34 | when :up 35 | page.reverse_each 36 | end 37 | 38 | enumerator.each do |item| 39 | yield item 40 | @count += 1 41 | break if limit_check 42 | end 43 | 44 | last_page = page 45 | end 46 | end 47 | 48 | private 49 | 50 | # Whether the paginator limit has been exceeded 51 | def limit_check 52 | return false if @limit.nil? 53 | 54 | @count >= @limit 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /discordrb.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'discordrb/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'discordrb' 9 | spec.version = Discordrb::VERSION 10 | spec.authors = %w[meew0 swarley] 11 | spec.email = [''] 12 | 13 | spec.summary = 'Discord API for Ruby' 14 | spec.description = 'A Ruby implementation of the Discord (https://discord.com) API.' 15 | spec.homepage = 'https://github.com/shardlab/discordrb' 16 | spec.license = 'MIT' 17 | 18 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|examples|lib/discordrb/webhooks)/}) } 19 | spec.bindir = 'exe' 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.metadata = { 22 | 'changelog_uri' => 'https://github.com/shardlab/discordrb/blob/master/CHANGELOG.md' 23 | } 24 | spec.require_paths = ['lib'] 25 | 26 | spec.add_dependency 'ffi', '>= 1.9.24' 27 | spec.add_dependency 'opus-ruby' 28 | spec.add_dependency 'rest-client', '>= 2.0.0' 29 | spec.add_dependency 'websocket-client-simple', '>= 0.3.0' 30 | 31 | spec.add_dependency 'discordrb-webhooks', '~> 3.4.2' 32 | 33 | spec.required_ruby_version = '>= 2.6' 34 | 35 | spec.add_development_dependency 'bundler', '>= 1.10', '< 3' 36 | spec.add_development_dependency 'rake', '~> 13.0' 37 | spec.add_development_dependency 'redcarpet', '~> 3.5.0' # YARD markdown formatting 38 | spec.add_development_dependency 'rspec', '~> 3.10.0' 39 | spec.add_development_dependency 'rspec-prof', '~> 0.0.7' 40 | spec.add_development_dependency 'rubocop', '~> 1.21.0' 41 | spec.add_development_dependency 'rubocop-performance', '~> 1.0' 42 | spec.add_development_dependency 'rubocop-rake', '~> 0.6.0' 43 | spec.add_development_dependency 'simplecov', '~> 0.21.0' 44 | spec.add_development_dependency 'yard', '~> 0.9.9' 45 | end 46 | -------------------------------------------------------------------------------- /lib/discordrb/api/interaction.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # API calls for interactions. 4 | module Discordrb::API::Interaction 5 | module_function 6 | 7 | # Respond to an interaction. 8 | # https://discord.com/developers/docs/interactions/slash-commands#create-interaction-response 9 | def create_interaction_response(interaction_token, interaction_id, type, content = nil, tts = nil, embeds = nil, allowed_mentions = nil, flags = nil, components = nil) 10 | data = { tts: tts, content: content, embeds: embeds, allowed_mentions: allowed_mentions, flags: flags, components: components }.compact 11 | 12 | Discordrb::API.request( 13 | :interactions_iid_token_callback, 14 | interaction_id, 15 | :post, 16 | "#{Discordrb::API.api_base}/interactions/#{interaction_id}/#{interaction_token}/callback", 17 | { type: type, data: data }.to_json, 18 | content_type: :json 19 | ) 20 | end 21 | 22 | # Get the original response to an interaction. 23 | # https://discord.com/developers/docs/interactions/slash-commands#get-original-interaction-response 24 | def get_original_interaction_response(interaction_token, application_id) 25 | Discordrb::API::Webhook.token_get_message(interaction_token, application_id, '@original') 26 | end 27 | 28 | # Edit the original response to an interaction. 29 | # https://discord.com/developers/docs/interactions/slash-commands#edit-original-interaction-response 30 | def edit_original_interaction_response(interaction_token, application_id, content = nil, embeds = nil, allowed_mentions = nil, components = nil) 31 | Discordrb::API::Webhook.token_edit_message(interaction_token, application_id, '@original', content, embeds, allowed_mentions, components) 32 | end 33 | 34 | # Delete the original response to an interaction. 35 | # https://discord.com/developers/docs/interactions/slash-commands#delete-original-interaction-response 36 | def delete_original_interaction_response(interaction_token, application_id) 37 | Discordrb::API::Webhook.token_delete_message(interaction_token, application_id, '@original') 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/discordrb/events/typing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/generic' 4 | 5 | module Discordrb::Events 6 | # Event raised when a user starts typing 7 | class TypingEvent < Event 8 | include Respondable 9 | 10 | # @return [Channel] the channel on which a user started typing. 11 | attr_reader :channel 12 | 13 | # @return [User, Member, Recipient] the user that started typing. 14 | attr_reader :user 15 | alias_method :member, :user 16 | 17 | # @return [Time] when the typing happened. 18 | attr_reader :timestamp 19 | 20 | def initialize(data, bot) 21 | @bot = bot 22 | 23 | @user_id = data['user_id'].to_i 24 | 25 | @channel_id = data['channel_id'].to_i 26 | @channel = bot.channel(@channel_id) 27 | 28 | @user = if channel.pm? 29 | channel.recipient 30 | elsif channel.group? 31 | bot.user(@user_id) 32 | else 33 | bot.member(@channel.server.id, @user_id) 34 | end 35 | 36 | @timestamp = Time.at(data['timestamp'].to_i) 37 | end 38 | end 39 | 40 | # Event handler for TypingEvent 41 | class TypingEventHandler < EventHandler 42 | def matches?(event) 43 | # Check for the proper event type 44 | return false unless event.is_a? TypingEvent 45 | 46 | [ 47 | matches_all(@attributes[:in], event.channel) do |a, e| 48 | case a 49 | when String 50 | a.delete('#') == e.name 51 | when Integer 52 | a == e.id 53 | else 54 | a == e 55 | end 56 | end, 57 | matches_all(@attributes[:from], event.user) do |a, e| 58 | a == case a 59 | when String 60 | e.name 61 | when Integer 62 | e.id 63 | else 64 | e 65 | end 66 | end, 67 | matches_all(@attributes[:after], event.timestamp) { |a, e| a > e }, 68 | matches_all(@attributes[:before], event.timestamp) { |a, e| a < e } 69 | ].reduce(true, &:&) 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/api/channel_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | 5 | describe Discordrb::API::Channel do 6 | let(:token) { double('token', to_s: 'bot_token') } 7 | let(:channel_id) { instance_double(String, 'channel_id', to_s: 'channel_id') } 8 | 9 | describe '.get_reactions' do 10 | let(:message_id) { double('message_id', to_s: 'message_id') } 11 | let(:before_id) { double('before_id', to_s: 'before_id') } 12 | let(:after_id) { double('after_id', to_s: 'after_id') } 13 | 14 | before do 15 | allow(Discordrb::API).to receive(:request) 16 | .with(anything, channel_id, :get, kind_of(String), any_args) 17 | end 18 | 19 | it 'sends requests' do 20 | expect(Discordrb::API).to receive(:request) 21 | .with( 22 | anything, 23 | channel_id, 24 | :get, 25 | "#{Discordrb::API.api_base}/channels/#{channel_id}/messages/#{message_id}/reactions/test?limit=27&before=#{before_id}&after=#{after_id}", 26 | any_args 27 | ) 28 | Discordrb::API::Channel.get_reactions(token, channel_id, message_id, 'test', before_id, after_id, 27) 29 | end 30 | 31 | it 'percent-encodes emoji' do 32 | expect(Discordrb::API).to receive(:request) 33 | .with( 34 | anything, 35 | channel_id, 36 | :get, 37 | "#{Discordrb::API.api_base}/channels/#{channel_id}/messages/#{message_id}/reactions/%F0%9F%91%8D?limit=27&before=#{before_id}&after=#{after_id}", 38 | any_args 39 | ) 40 | Discordrb::API::Channel.get_reactions(token, channel_id, message_id, "\u{1F44D}", before_id, after_id, 27) 41 | end 42 | 43 | it 'uses the maximum limit of 100 if nil is provided' do 44 | expect(Discordrb::API).to receive(:request) 45 | .with( 46 | anything, 47 | channel_id, 48 | :get, 49 | "#{Discordrb::API.api_base}/channels/#{channel_id}/messages/#{message_id}/reactions/test?limit=100&before=#{before_id}&after=#{after_id}", 50 | any_args 51 | ) 52 | Discordrb::API::Channel.get_reactions(token, channel_id, message_id, 'test', before_id, after_id, nil) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/discordrb/data/emoji.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # Server emoji 5 | class Emoji 6 | include IDObject 7 | 8 | # @return [String] the emoji name 9 | attr_reader :name 10 | 11 | # @return [Server, nil] the server of this emoji 12 | attr_reader :server 13 | 14 | # @return [Array, nil] roles this emoji is active for, or nil if the emoji's server is unknown 15 | attr_reader :roles 16 | 17 | # @return [true, false] if the emoji is animated 18 | attr_reader :animated 19 | alias_method :animated?, :animated 20 | 21 | # @!visibility private 22 | def initialize(data, bot, server = nil) 23 | @bot = bot 24 | @roles = nil 25 | 26 | @name = data['name'] 27 | @server = server 28 | @id = data['id'].nil? ? nil : data['id'].to_i 29 | @animated = data['animated'] 30 | 31 | process_roles(data['roles']) if server 32 | end 33 | 34 | # ID or name based comparison 35 | def ==(other) 36 | return false unless other.is_a? Emoji 37 | return Discordrb.id_compare(@id, other) if @id 38 | 39 | name == other.name 40 | end 41 | 42 | alias_method :eql?, :== 43 | 44 | # @return [String] the layout to mention it (or have it used) in a message 45 | def mention 46 | return name if id.nil? 47 | 48 | "<#{'a' if animated}:#{name}:#{id}>" 49 | end 50 | 51 | alias_method :use, :mention 52 | alias_method :to_s, :mention 53 | 54 | # @return [String] the layout to use this emoji in a reaction 55 | def to_reaction 56 | return name if id.nil? 57 | 58 | "#{name}:#{id}" 59 | end 60 | 61 | # @return [String] the icon URL of the emoji 62 | def icon_url 63 | API.emoji_icon_url(id) 64 | end 65 | 66 | # The inspect method is overwritten to give more useful output 67 | def inspect 68 | "" 69 | end 70 | 71 | # @!visibility private 72 | def process_roles(roles) 73 | @roles = [] 74 | return unless roles 75 | 76 | roles.each do |role_id| 77 | role = server.role(role_id) 78 | @roles << role 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /examples/commands.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This bot has various commands that show off CommandBot. 4 | 5 | require 'discordrb' 6 | 7 | # Here we instantiate a `CommandBot` instead of a regular `Bot`, which has the functionality to add commands using the 8 | # `command` method. We have to set a `prefix` here, which will be the character that triggers command execution. 9 | bot = Discordrb::Commands::CommandBot.new token: 'B0T.T0KEN.here', prefix: '!' 10 | 11 | bot.command :user do |event| 12 | # Commands send whatever is returned from the block to the channel. This allows for compact commands like this, 13 | # but you have to be aware of this so you don't accidentally return something you didn't intend to. 14 | # To prevent the return value to be sent to the channel, you can just return `nil`. 15 | event.user.name 16 | end 17 | 18 | bot.command :bold do |_event, *args| 19 | # Again, the return value of the block is sent to the channel 20 | "**#{args.join(' ')}**" 21 | end 22 | 23 | bot.command :italic do |_event, *args| 24 | "*#{args.join(' ')}*" 25 | end 26 | 27 | bot.command(:invite, chain_usable: false) do |event| 28 | # This simply sends the bot's invite URL, without any specific permissions, 29 | # to the channel. 30 | event.bot.invite_url 31 | end 32 | 33 | bot.command(:random, min_args: 0, max_args: 2, description: 'Generates a random number between 0 and 1, 0 and max or min and max.', usage: 'random [min/max] [max]') do |_event, min, max| 34 | # The `if` statement returns one of multiple different things based on the condition. Its return value 35 | # is then returned from the block and sent to the channel 36 | if max 37 | rand(min.to_i..max.to_i) 38 | elsif min 39 | rand(0..min.to_i) 40 | else 41 | rand 42 | end 43 | end 44 | 45 | bot.command :long do |event| 46 | event << 'This is a long message.' 47 | event << 'It has multiple lines that are each sent by doing `event << line`.' 48 | event << 'This is an easy way to do such long messages, or to create lines that should only be sent conditionally.' 49 | event << 'Anyway, have a nice day.' 50 | 51 | # Here we don't have to worry about the return value because the `event << line` statement automatically returns nil. 52 | end 53 | 54 | bot.run 55 | -------------------------------------------------------------------------------- /lib/discordrb/await.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # Awaits are a way to register new, temporary event handlers on the fly. Awaits can be 5 | # registered using {Bot#add_await}, {User#await}, {Message#await} and {Channel#await}. 6 | # 7 | # Awaits contain a block that will be called before the await event will be triggered. 8 | # If this block returns anything that is not `false` exactly, the await will be deleted. 9 | # If no block is present, the await will also be deleted. This is an easy way to make 10 | # temporary events that are only temporary under certain conditions. 11 | # 12 | # Besides the given block, an {Discordrb::Events::AwaitEvent} will also be executed with the key and 13 | # the type of the await that was triggered. It's possible to register multiple events 14 | # that trigger on the same await. 15 | class Await 16 | # The key that uniquely identifies this await. 17 | # @return [Symbol] The unique key. 18 | attr_reader :key 19 | 20 | # The class of the event that this await listens for. 21 | # @return [Class] The event class. 22 | attr_reader :type 23 | 24 | # The attributes of the event that will be listened for. 25 | # @return [Hash] A hash of attributes. 26 | attr_reader :attributes 27 | 28 | # Makes a new await. For internal use only. 29 | # @!visibility private 30 | def initialize(bot, key, type, attributes, block = nil) 31 | @bot = bot 32 | @key = key 33 | @type = type 34 | @attributes = attributes 35 | @block = block 36 | end 37 | 38 | # Checks whether the await can be triggered by the given event, and if it can, execute the block 39 | # and return its result along with this await's key. 40 | # @param event [Event] An event to check for. 41 | # @return [Array] This await's key and whether or not it should be deleted. If there was no match, both are nil. 42 | def match(event) 43 | dummy_handler = EventContainer.handler_class(@type).new(@attributes, @bot) 44 | return [nil, nil] unless event.instance_of?(@type) && dummy_handler.matches?(event) 45 | 46 | should_delete = true if (@block && @block.call(event) != false) || !@block 47 | 48 | [@key, should_delete] 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/discordrb/light/light_bot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/api' 4 | require 'discordrb/api/invite' 5 | require 'discordrb/api/user' 6 | require 'discordrb/light/data' 7 | require 'discordrb/light/integrations' 8 | 9 | # This module contains classes to allow connections to bots without a connection to the gateway socket, i.e. bots 10 | # that only use the REST part of the API. 11 | module Discordrb::Light 12 | # A bot that only uses the REST part of the API. Hierarchically unrelated to the regular {Discordrb::Bot}. Useful to 13 | # make applications integrated to Discord over OAuth, for example. 14 | class LightBot 15 | # Create a new LightBot. This does no networking yet, all networking is done by the methods on this class. 16 | # @param token [String] The token that should be used to authenticate to Discord. Can be an OAuth token or a regular 17 | # user account token. 18 | def initialize(token) 19 | if token.respond_to? :token 20 | # Parse AccessTokens from the OAuth2 gem 21 | token = token.token 22 | end 23 | 24 | unless token.include? '.' 25 | # Discord user/bot tokens always contain two dots, so if there's none we can assume it's an OAuth token. 26 | token = "Bearer #{token}" # OAuth tokens have to be prefixed with 'Bearer' for Discord to be able to use them 27 | end 28 | 29 | @token = token 30 | end 31 | 32 | # @return [LightProfile] the details of the user this bot is connected to. 33 | def profile 34 | response = Discordrb::API::User.profile(@token) 35 | LightProfile.new(JSON.parse(response), self) 36 | end 37 | 38 | # @return [Array] the servers this bot is connected to. 39 | def servers 40 | response = Discordrb::API::User.servers(@token) 41 | JSON.parse(response).map { |e| LightServer.new(e, self) } 42 | end 43 | 44 | # Joins a server using an instant invite. 45 | # @param code [String] The code part of the invite (for example 0cDvIgU2voWn4BaD if the invite URL is 46 | # https://discord.gg/0cDvIgU2voWn4BaD) 47 | def join(code) 48 | Discordrb::API::Invite.accept(@token, code) 49 | end 50 | 51 | # Gets the connections associated with this account. 52 | # @return [Array] this account's connections. 53 | def connections 54 | response = Discordrb::API::User.connections(@token) 55 | JSON.parse(response).map { |e| Connection.new(e, self) } 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/main_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | 5 | class SimpleIDObject 6 | include Discordrb::IDObject 7 | 8 | def initialize(id) 9 | @id = id 10 | end 11 | end 12 | 13 | describe Discordrb do 14 | it 'should split messages correctly' do 15 | split = Discordrb.split_message('a' * 5234) 16 | expect(split).to eq(['a' * 2000, 'a' * 2000, 'a' * 1234]) 17 | 18 | split_on_space = Discordrb.split_message("#{'a' * 1990} #{'b' * 2000}") 19 | expect(split_on_space).to eq(["#{'a' * 1990} ", 'b' * 2000]) 20 | 21 | # regression test 22 | # there had been an issue where this would have raised an error, 23 | # and (if it hadn't raised) produced incorrect results 24 | split = Discordrb.split_message("#{'a' * 800}\n" * 6) 25 | expect(split).to eq([ 26 | "#{'a' * 800}\n#{'a' * 800}\n", 27 | "#{'a' * 800}\n#{'a' * 800}\n", 28 | "#{'a' * 800}\n#{'a' * 800}" 29 | ]) 30 | end 31 | 32 | describe Discordrb::IDObject do 33 | describe '#==' do 34 | it 'should match identical values' do 35 | ido = SimpleIDObject.new(123) 36 | expect(ido == SimpleIDObject.new(123)).to eq(true) 37 | expect(ido == 123).to eq(true) 38 | expect(ido == '123').to eq(true) 39 | end 40 | 41 | it 'should not match different values' do 42 | ido = SimpleIDObject.new(123) 43 | expect(ido == SimpleIDObject.new(124)).to eq(false) 44 | expect(ido == 124).to eq(false) 45 | expect(ido == '124').to eq(false) 46 | end 47 | end 48 | 49 | describe '#creation_time' do 50 | it 'should return the correct time' do 51 | ido = SimpleIDObject.new(175_928_847_299_117_063) 52 | time = Time.new(2016, 4, 30, 11, 18, 25.796, 0) 53 | expect(ido.creation_time.utc).to be_within(0.0001).of(time) 54 | end 55 | end 56 | 57 | describe '.synthesise' do 58 | it 'should match a precalculated time' do 59 | snowflake = 175_928_847_298_985_984 60 | time = Time.new(2016, 4, 30, 11, 18, 25.796, 0) 61 | expect(Discordrb::IDObject.synthesise(time)).to eq(snowflake) 62 | end 63 | 64 | it 'should match #creation_time' do 65 | time = Time.new(2016, 4, 30, 11, 18, 25.796, 0) 66 | ido = SimpleIDObject.new(Discordrb::IDObject.synthesise(time)) 67 | expect(ido.creation_time).to be_within(0.0001).of(time) 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/discordrb/light/integrations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/data' 4 | require 'discordrb/light/data' 5 | 6 | module Discordrb::Light 7 | # A connection of your Discord account to a particular other service (currently, Twitch and YouTube) 8 | class Connection 9 | # @return [Symbol] what type of connection this is (either :twitch or :youtube currently) 10 | attr_reader :type 11 | 12 | # @return [true, false] whether this connection is revoked 13 | attr_reader :revoked 14 | alias_method :revoked?, :revoked 15 | 16 | # @return [String] the name of the connected account 17 | attr_reader :name 18 | 19 | # @return [String] the ID of the connected account 20 | attr_reader :id 21 | 22 | # @return [Array] the integrations associated with this connection 23 | attr_reader :integrations 24 | 25 | # @!visibility private 26 | def initialize(data, bot) 27 | @bot = bot 28 | 29 | @revoked = data['revoked'] 30 | @type = data['type'].to_sym 31 | @name = data['name'] 32 | @id = data['id'] 33 | 34 | @integrations = data['integrations'].map { |e| Integration.new(e, self, bot) } 35 | end 36 | end 37 | 38 | # An integration of a connection into a particular server, for example being a member of a subscriber-only Twitch 39 | # server. 40 | class Integration 41 | include Discordrb::IDObject 42 | 43 | # @return [UltraLightServer] the server associated with this integration 44 | attr_reader :server 45 | 46 | # @note The connection returned by this method will have no integrations itself, as Discord doesn't provide that 47 | # data. Also, it will always be considered not revoked. 48 | # @return [Connection] the server's underlying connection (for a Twitch subscriber-only server, it would be the 49 | # Twitch account connection of the server owner). 50 | attr_reader :server_connection 51 | 52 | # @return [Connection] the connection integrated with the server (i.e. your connection) 53 | attr_reader :integrated_connection 54 | 55 | # @!visibility private 56 | def initialize(data, integrated, bot) 57 | @bot = bot 58 | @integrated_connection = integrated 59 | 60 | @server = UltraLightServer.new(data['guild'], bot) 61 | 62 | # Restructure the given data so we can reuse the Connection initializer 63 | restructured = {} 64 | 65 | restructured['type'] = data['type'] 66 | restructured['id'] = data['account']['id'] 67 | restructured['name'] = data['account']['name'] 68 | restructured['integrations'] = [] 69 | 70 | @server_connection = Connection.new(restructured, bot) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/discordrb/events/roles.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/generic' 4 | require 'discordrb/data' 5 | 6 | module Discordrb::Events 7 | # Raised when a role is created on a server 8 | class ServerRoleCreateEvent < Event 9 | # @return [Role] the role that got created 10 | attr_reader :role 11 | 12 | # @return [Server] the server on which a role got created 13 | attr_reader :server 14 | 15 | # @!attribute [r] name 16 | # @return [String] this role's name 17 | # @see Role#name 18 | delegate :name, to: :role 19 | 20 | def initialize(data, bot) 21 | @bot = bot 22 | 23 | @server = bot.server(data['guild_id'].to_i) 24 | return unless @server 25 | 26 | role_id = data['role']['id'].to_i 27 | @role = @server.roles.find { |r| r.id == role_id } 28 | end 29 | end 30 | 31 | # Event handler for ServerRoleCreateEvent 32 | class ServerRoleCreateEventHandler < EventHandler 33 | def matches?(event) 34 | # Check for the proper event type 35 | return false unless event.is_a? ServerRoleCreateEvent 36 | 37 | [ 38 | matches_all(@attributes[:name], event.name) do |a, e| 39 | a == if a.is_a? String 40 | e.to_s 41 | else 42 | e 43 | end 44 | end 45 | ].reduce(true, &:&) 46 | end 47 | end 48 | 49 | # Raised when a role is deleted from a server 50 | class ServerRoleDeleteEvent < Event 51 | # @return [Integer] the ID of the role that got deleted. 52 | attr_reader :id 53 | 54 | # @return [Server] the server on which a role got deleted. 55 | attr_reader :server 56 | 57 | def initialize(data, bot) 58 | @bot = bot 59 | 60 | # The role should already be deleted from the server's list 61 | # by the time we create this event, so we'll create a temporary 62 | # role object for event consumers to use. 63 | @id = data['role_id'].to_i 64 | server_id = data['guild_id'].to_i 65 | @server = bot.server(server_id) 66 | end 67 | end 68 | 69 | # EventHandler for ServerRoleDeleteEvent 70 | class ServerRoleDeleteEventHandler < EventHandler 71 | def matches?(event) 72 | # Check for the proper event type 73 | return false unless event.is_a? ServerRoleDeleteEvent 74 | 75 | [ 76 | matches_all(@attributes[:id], event.id) do |a, e| 77 | a.resolve_id == e.resolve_id 78 | end 79 | ].reduce(true, &:&) 80 | end 81 | end 82 | 83 | # Event raised when a role updates on a server 84 | class ServerRoleUpdateEvent < ServerRoleCreateEvent; end 85 | 86 | # Event handler for ServerRoleUpdateEvent 87 | class ServerRoleUpdateEventHandler < ServerRoleCreateEventHandler; end 88 | end 89 | -------------------------------------------------------------------------------- /examples/voice_send.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # discordrb can send music or other audio data to voice channels. This example exists to show that off. 4 | # 5 | # To avoid copyright infringement, the example music I will be using is a self-composed piece of highly debatable 6 | # quality. If you want something better you can replace the files in the data/ directory. Make sure to execute this 7 | # example from the appropriate place, so that it has access to the files in that directory. 8 | 9 | require 'discordrb' 10 | 11 | bot = Discordrb::Commands::CommandBot.new token: 'B0T.T0KEN.here', prefix: '!' 12 | 13 | bot.command(:connect) do |event| 14 | # The `voice_channel` method returns the voice channel the user is currently in, or `nil` if the user is not in a 15 | # voice channel. 16 | channel = event.user.voice_channel 17 | 18 | # Here we return from the command unless the channel is not nil (i.e. the user is in a voice channel). The `next` 19 | # construct can be used to exit a command prematurely, and even send a message while we're at it. 20 | next "You're not in any voice channel!" unless channel 21 | 22 | # The `voice_connect` method does everything necessary for the bot to connect to a voice channel. Afterwards the bot 23 | # will be connected and ready to play stuff back. 24 | bot.voice_connect(channel) 25 | "Connected to voice channel: #{channel.name}" 26 | end 27 | 28 | # A simple command that plays back an mp3 file. 29 | bot.command(:play_mp3) do |event| 30 | # `event.voice` is a helper method that gets the correct voice bot on the server the bot is currently in. Since a 31 | # bot may be connected to more than one voice channel (never more than one on the same server, though), this is 32 | # necessary to allow the differentiation of servers. 33 | # 34 | # It returns a `VoiceBot` object that methods such as `play_file` can be called on. 35 | voice_bot = event.voice 36 | voice_bot.play_file('data/music.mp3') 37 | end 38 | 39 | # DCA is a custom audio format developed by a couple people from the Discord API community (including myself, meew0). 40 | # It represents the audio data exactly as Discord wants it in a format that is very simple to parse, so libraries can 41 | # very easily add support for it. It has the advantage that absolutely no transcoding has to be done, so it is very 42 | # light on CPU in comparison to `play_file`. 43 | # 44 | # A conversion utility that converts existing audio files to DCA can be found here: https://github.com/RaymondSchnyder/dca-rs 45 | bot.command(:play_dca) do |event| 46 | voice_bot = event.voice 47 | 48 | # Since the DCA format is non-standard (i.e. ffmpeg doesn't support it), a separate method other than `play_file` has 49 | # to be used to play DCA files back. `play_dca` fulfills that role. 50 | voice_bot.play_dca('data/music.dca') 51 | end 52 | 53 | bot.run 54 | -------------------------------------------------------------------------------- /examples/prefix_proc.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | 5 | # Here, we'll demonstrate one way to achieve a dynamic command prefix 6 | # in different contexts for your CommandBot. 7 | # We'll use a frozen hash configuration, but you're free to implement 8 | # any kind of lookup. (ex: server, channel, user, phase of the moon) 9 | 10 | # Define a map of Channel ID => Prefix string. 11 | # Here, we'll be using channel IDs so that it's easy to test in one server. 12 | PREFIXES = { 13 | 345687437722386433 => '!', 14 | 83281822225530880 => '?' 15 | }.freeze 16 | 17 | # The CommandBot initializer accepts a Proc as a prefix, which will be 18 | # evaluated with each message to parse the command string (the message) 19 | # that gets passed to the internal command parser. 20 | # We'll check what channel the message was in, get its prefix, and 21 | # then strip the prefix from the message, as the internal parser determines 22 | # what command to execute based off of the first word (the name of the command) 23 | # 24 | # The basic algorithm goes: 25 | # 1. Command: 26 | # "!roll 1d6" (in channel 345687437722386433) 27 | # 2. Get prefix: 28 | # PREFIXES[345687437722386433] #=> '!' 29 | # 3. Remove prefix from the string, so we don't parse it: 30 | # content[prefix.size..] #=> "roll 1d6" 31 | # 32 | # You can of course define any behavior you like in here, such as a database 33 | # lookup in SQL for example. 34 | prefix_proc = proc do |message| 35 | # Since we may get commands in channels we didn't define a prefix for, we can 36 | # use a logical OR to set a "default prefix" for any other channel as 37 | # PREFIXES[] will return nil. 38 | prefix = PREFIXES[message.channel.id] || '.' 39 | 40 | # We use [prefix.size..] so we can handle prefixes of any length 41 | message.content[prefix.size..] if message.content.start_with?(prefix) 42 | end 43 | 44 | # Setup a new bot with our prefix proc 45 | bot = Discordrb::Commands::CommandBot.new(token: 'token', prefix: prefix_proc) 46 | 47 | # A simple dice roll command, use it like: '!roll 2d10' 48 | bot.command(:roll, description: 'rolls some dice', 49 | usage: 'roll NdS', min_args: 1) do |_event, dnd_roll| 50 | # Parse the input 51 | number, sides = dnd_roll.split('d') 52 | 53 | # Check for valid input; make sure we got both numbers 54 | next 'Invalid syntax.. try: `roll 2d10`' unless number && sides 55 | 56 | # Check for valid input; make sure we actually got numbers and not words 57 | begin 58 | number = Integer(number, 10) 59 | sides = Integer(sides, 10) 60 | rescue ArgumentError 61 | next 'You must pass two *numbers*.. try: `roll 2d10`' 62 | end 63 | 64 | # Time to roll the dice! 65 | rolls = Array.new(number) { rand(1..sides) } 66 | sum = rolls.sum 67 | 68 | # Return the result 69 | "You rolled: `#{rolls}`, total: `#{sum}`" 70 | end 71 | 72 | bot.run 73 | -------------------------------------------------------------------------------- /lib/discordrb/events/members.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/generic' 4 | require 'discordrb/data' 5 | 6 | module Discordrb::Events 7 | # Generic subclass for server member events (add/update/delete) 8 | class ServerMemberEvent < Event 9 | # @return [Member] the member in question. 10 | attr_reader :user 11 | alias_method :member, :user 12 | 13 | # @return [Array] the member's roles. 14 | attr_reader :roles 15 | 16 | # @return [Server] the server on which the event happened. 17 | attr_reader :server 18 | 19 | def initialize(data, bot) 20 | @bot = bot 21 | 22 | @server = bot.server(data['guild_id'].to_i) 23 | return unless @server 24 | 25 | init_user(data, bot) 26 | init_roles(data, bot) 27 | end 28 | 29 | private 30 | 31 | def init_user(data, _) 32 | user_id = data['user']['id'].to_i 33 | @user = @server.member(user_id) 34 | end 35 | 36 | def init_roles(data, _) 37 | @roles = [@server.role(@server.id)] 38 | return unless data['roles'] 39 | 40 | data['roles'].each do |element| 41 | role_id = element.to_i 42 | @roles << @server.roles.find { |r| r.id == role_id } 43 | end 44 | end 45 | end 46 | 47 | # Generic event handler for member events 48 | class ServerMemberEventHandler < EventHandler 49 | def matches?(event) 50 | # Check for the proper event type 51 | return false unless event.is_a? ServerMemberEvent 52 | 53 | [ 54 | matches_all(@attributes[:username], event.user.name) do |a, e| 55 | a == if a.is_a? String 56 | e.to_s 57 | else 58 | e 59 | end 60 | end 61 | ].reduce(true, &:&) 62 | end 63 | end 64 | 65 | # Member joins 66 | # @see Discordrb::EventContainer#member_join 67 | class ServerMemberAddEvent < ServerMemberEvent; end 68 | 69 | # Event handler for {ServerMemberAddEvent} 70 | class ServerMemberAddEventHandler < ServerMemberEventHandler; end 71 | 72 | # Member is updated (roles added or deleted) 73 | # @see Discordrb::EventContainer#member_update 74 | class ServerMemberUpdateEvent < ServerMemberEvent; end 75 | 76 | # Event handler for {ServerMemberUpdateEvent} 77 | class ServerMemberUpdateEventHandler < ServerMemberEventHandler; end 78 | 79 | # Member leaves 80 | # @see Discordrb::EventContainer#member_leave 81 | class ServerMemberDeleteEvent < ServerMemberEvent 82 | # Override init_user to account for the deleted user on the server 83 | def init_user(data, bot) 84 | @user = Discordrb::User.new(data['user'], bot) 85 | end 86 | 87 | # @return [User] the user in question. 88 | attr_reader :user 89 | end 90 | 91 | # Event handler for {ServerMemberDeleteEvent} 92 | class ServerMemberDeleteEventHandler < ServerMemberEventHandler; end 93 | end 94 | -------------------------------------------------------------------------------- /lib/discordrb/websocket.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'websocket-client-simple' 4 | 5 | # The WSCS module which we're hooking 6 | # @see Websocket::Client::Simple::Client 7 | module WebSocket::Client::Simple 8 | # Patch to the WSCS class to allow reading the internal thread 9 | class Client 10 | # @return [Thread] the internal thread this client is using for the event loop. 11 | attr_reader :thread 12 | end 13 | end 14 | 15 | module Discordrb 16 | # Utility wrapper class that abstracts an instance of WSCS. Useful should we decide that WSCS isn't good either - 17 | # in that case we can just switch to something else 18 | class WebSocket 19 | attr_reader :open_handler, :message_handler, :close_handler, :error_handler 20 | 21 | # Create a new WebSocket and connect to the given endpoint. 22 | # @param endpoint [String] Where to connect to. 23 | # @param open_handler [#call] The handler that should be called when the websocket has opened successfully. 24 | # @param message_handler [#call] The handler that should be called when the websocket receives a message. The 25 | # handler can take one parameter which will have a `data` attribute for normal messages and `code` and `data` for 26 | # close frames. 27 | # @param close_handler [#call] The handler that should be called when the websocket is closed due to an internal 28 | # error. The error will be passed as the first parameter to the handler. 29 | # @param error_handler [#call] The handler that should be called when an error occurs in another handler. The error 30 | # will be passed as the first parameter to the handler. 31 | def initialize(endpoint, open_handler, message_handler, close_handler, error_handler) 32 | Discordrb::LOGGER.debug "Using WSCS version: #{::WebSocket::Client::Simple::VERSION}" 33 | 34 | @open_handler = open_handler 35 | @message_handler = message_handler 36 | @close_handler = close_handler 37 | @error_handler = error_handler 38 | 39 | instance = self # to work around WSCS's weird way of handling blocks 40 | 41 | @client = ::WebSocket::Client::Simple.connect(endpoint) do |ws| 42 | ws.on(:open) { instance.open_handler.call } 43 | ws.on(:message) do |msg| 44 | # If the message has a code attribute, it is in reality a close message 45 | if msg.code 46 | instance.close_handler.call(msg) 47 | else 48 | instance.message_handler.call(msg.data) 49 | end 50 | end 51 | ws.on(:close) { |err| instance.close_handler.call(err) } 52 | ws.on(:error) { |err| instance.error_handler.call(err) } 53 | end 54 | end 55 | 56 | # Send data over this WebSocket 57 | # @param data [String] What to send 58 | def send(data) 59 | @client.send(data) 60 | end 61 | 62 | # Close the WebSocket connection 63 | def close 64 | @client.close 65 | end 66 | 67 | # @return [Thread] the internal WSCS thread 68 | def thread 69 | @client.thread 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/discordrb/data/profile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # This class is a special variant of User that represents the bot's user profile (things like own username and the avatar). 5 | # It can be accessed using {Bot#profile}. 6 | class Profile < User 7 | # Whether or not the user is the bot. The Profile can only ever be the bot user, so this always returns true. 8 | # @return [true] 9 | def current_bot? 10 | true 11 | end 12 | 13 | # Sets the bot's username. 14 | # @param username [String] The new username. 15 | def username=(username) 16 | update_profile_data(username: username) 17 | end 18 | 19 | alias_method :name=, :username= 20 | 21 | # Changes the bot's avatar. 22 | # @param avatar [String, #read] A JPG file to be used as the avatar, either 23 | # something readable (e.g. File Object) or as a data URL. 24 | def avatar=(avatar) 25 | if avatar.respond_to? :read 26 | # Set the file to binary mode if supported, so we don't get problems with Windows 27 | avatar.binmode if avatar.respond_to?(:binmode) 28 | 29 | avatar_string = 'data:image/jpg;base64,' 30 | avatar_string += Base64.strict_encode64(avatar.read) 31 | update_profile_data(avatar: avatar_string) 32 | else 33 | update_profile_data(avatar: avatar) 34 | end 35 | end 36 | 37 | # Updates the cached profile data with the new one. 38 | # @note For internal use only. 39 | # @!visibility private 40 | def update_data(new_data) 41 | @username = new_data[:username] || @username 42 | @avatar_id = new_data[:avatar_id] || @avatar_id 43 | end 44 | 45 | # Sets the user status setting to Online. 46 | # @note Only usable on User accounts. 47 | def online 48 | update_profile_status_setting('online') 49 | end 50 | 51 | # Sets the user status setting to Idle. 52 | # @note Only usable on User accounts. 53 | def idle 54 | update_profile_status_setting('idle') 55 | end 56 | 57 | # Sets the user status setting to Do Not Disturb. 58 | # @note Only usable on User accounts. 59 | def dnd 60 | update_profile_status_setting('dnd') 61 | end 62 | 63 | alias_method(:busy, :dnd) 64 | 65 | # Sets the user status setting to Invisible. 66 | # @note Only usable on User accounts. 67 | def invisible 68 | update_profile_status_setting('invisible') 69 | end 70 | 71 | # The inspect method is overwritten to give more useful output 72 | def inspect 73 | "" 74 | end 75 | 76 | private 77 | 78 | # Internal handler for updating the user's status setting 79 | def update_profile_status_setting(status) 80 | API::User.change_status_setting(@bot.token, status) 81 | end 82 | 83 | def update_profile_data(new_data) 84 | API::User.update_profile(@bot.token, 85 | nil, nil, 86 | new_data[:username] || @username, 87 | new_data.key?(:avatar) ? new_data[:avatar] : @avatar_id) 88 | update_data(new_data) 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/discordrb/events/voice_state_update.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/generic' 4 | require 'discordrb/data' 5 | 6 | module Discordrb::Events 7 | # Event raised when a user's voice state updates 8 | class VoiceStateUpdateEvent < Event 9 | attr_reader :user, :token, :suppress, :session_id, :self_mute, :self_deaf, :mute, :deaf, :server, :channel 10 | 11 | # @return [Channel, nil] the old channel this user was on, or nil if the user is newly joining voice. 12 | attr_reader :old_channel 13 | 14 | def initialize(data, old_channel_id, bot) 15 | @bot = bot 16 | 17 | @token = data['token'] 18 | @suppress = data['suppress'] 19 | @session_id = data['session_id'] 20 | @self_mute = data['self_mute'] 21 | @self_deaf = data['self_deaf'] 22 | @mute = data['mute'] 23 | @deaf = data['deaf'] 24 | @server = bot.server(data['guild_id'].to_i) 25 | return unless @server 26 | 27 | @channel = bot.channel(data['channel_id'].to_i) if data['channel_id'] 28 | @old_channel = bot.channel(old_channel_id) if old_channel_id 29 | @user = bot.user(data['user_id'].to_i) 30 | end 31 | end 32 | 33 | # Event handler for VoiceStateUpdateEvent 34 | class VoiceStateUpdateEventHandler < EventHandler 35 | def matches?(event) 36 | # Check for the proper event type 37 | return false unless event.is_a? VoiceStateUpdateEvent 38 | 39 | [ 40 | matches_all(@attributes[:from], event.user) do |a, e| 41 | a == case a 42 | when String 43 | e.name 44 | when Integer 45 | e.id 46 | else 47 | e 48 | end 49 | end, 50 | matches_all(@attributes[:mute], event.mute) do |a, e| 51 | a == if a.is_a? String 52 | e.to_s 53 | else 54 | e 55 | end 56 | end, 57 | matches_all(@attributes[:deaf], event.deaf) do |a, e| 58 | a == if a.is_a? String 59 | e.to_s 60 | else 61 | e 62 | end 63 | end, 64 | matches_all(@attributes[:self_mute], event.self_mute) do |a, e| 65 | a == if a.is_a? String 66 | e.to_s 67 | else 68 | e 69 | end 70 | end, 71 | matches_all(@attributes[:self_deaf], event.self_deaf) do |a, e| 72 | a == if a.is_a? String 73 | e.to_s 74 | else 75 | e 76 | end 77 | end, 78 | matches_all(@attributes[:channel], event.channel) do |a, e| 79 | next unless e # Don't bother if the channel is nil 80 | 81 | a == case a 82 | when String 83 | e.name 84 | when Integer 85 | e.id 86 | else 87 | e 88 | end 89 | end, 90 | matches_all(@attributes[:old_channel], event.old_channel) do |a, e| 91 | next unless e # Don't bother if the channel is nil 92 | 93 | a == case a 94 | when String 95 | e.name 96 | when Integer 97 | e.id 98 | else 99 | e 100 | end 101 | end 102 | ].reduce(true, &:&) 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /examples/awaits.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # For use with bundler: 4 | # require 'rubygems' 5 | # require 'bundler/setup' 6 | 7 | require 'discordrb' 8 | 9 | # Create a bot 10 | bot = Discordrb::Bot.new token: 'B0T.T0KEN.here' 11 | 12 | # Discordrb features an Awaits system that allows you to instantiate 13 | # temporary event handlers. The following example depicts a simple 14 | # "Guess the number" game using an await set up to listen for a specific 15 | # user's follow-up messages until a condition is satisfied. 16 | # 17 | # Start the game by typing "!game" in chat. 18 | bot.message(start_with: '!game') do |event| 19 | # Pick a number between 1 and 10 20 | magic = rand(1..10) 21 | 22 | # Tell the user that we're ready. 23 | event.respond "Can you guess my secret number? It's between 1 and 10!" 24 | 25 | # Await a MessageEvent specifically from the invoking user. 26 | # Timeout defines how long a user can spend playing one game. 27 | # This does not affect subsequent games. 28 | # 29 | # You can omit the options hash if you don't want a timeout. 30 | event.user.await!(timeout: 300) do |guess_event| 31 | # Their message is a string - cast it to an integer 32 | guess = guess_event.message.content.to_i 33 | 34 | # If the block returns anything that *isn't* true, then the 35 | # event handler will persist and continue to handle messages. 36 | if guess == magic 37 | # This returns `true`, which will destroy the await so we don't reply anymore 38 | guess_event.respond 'you win!' 39 | true 40 | else 41 | # Let the user know if they guessed too high or low. 42 | guess_event.respond(guess > magic ? 'too high' : 'too low') 43 | 44 | # Return false so the await is not destroyed, and we continue to listen 45 | false 46 | end 47 | end 48 | event.respond "My number was: `#{magic}`." 49 | end 50 | 51 | # Above we used the provided User#await! method to easily set up 52 | # an await for a follow-up message from a user. 53 | # We can also manually register an await for specific kinds of events. 54 | # Here, we'll write a command that shows the current time and allows 55 | # the user to delete the message with a reaction. 56 | # We'll be using Bot#add_await! to do this: 57 | # https://www.rubydoc.info/gems/discordrb/Discordrb%2FBot:add_await! 58 | 59 | # the unicode ":x:" emoji 60 | CROSS_MARK = "\u274c" 61 | 62 | bot.message(content: '!time') do |event| 63 | # Send a message, and store a reference to it that we can add the reaction. 64 | message = event.respond "The current time is: #{Time.now.strftime('%F %T %Z')}" 65 | 66 | # React to the message to give a user an easy "button" to press 67 | message.react CROSS_MARK 68 | 69 | # Add an await for a ReactionAddEvent, that will only trigger for reactions 70 | # that match our CROSS_MARK emoji. To prevent the bot from cluttering up threads, we destroy the await after 30 seconds. 71 | bot.add_await!(Discordrb::Events::ReactionAddEvent, message: message, emoji: CROSS_MARK, timeout: 30) do |_reaction_event| 72 | message.delete # Delete the bot message 73 | end 74 | # This code executes after our await concludes, or when the timeout runs out. 75 | # For demonstration purposes, it just prints "Await destroyed.". In your actual code you might want to edit the message or something alike. 76 | puts 'Await destroyed.' 77 | end 78 | 79 | # Connect to Discord 80 | bot.run 81 | 82 | # For more details about Awaits, see: 83 | # https://www.rubydoc.info/gems/discordrb/Discordrb/Await 84 | # For a list of events you can use to await for, see: 85 | # https://www.rubydoc.info/gems/discordrb/Discordrb/Events 86 | -------------------------------------------------------------------------------- /lib/discordrb/voice/sodium.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb::Voice 4 | # @!visibility private 5 | module Sodium 6 | extend FFI::Library 7 | 8 | ffi_lib(['sodium', 'libsodium.so.18', 'libsodium.so.23']) 9 | 10 | # Encryption & decryption 11 | attach_function(:crypto_secretbox_xsalsa20poly1305, %i[pointer pointer ulong_long pointer pointer], :int) 12 | attach_function(:crypto_secretbox_xsalsa20poly1305_open, %i[pointer pointer ulong_long pointer pointer], :int) 13 | 14 | # Constants 15 | attach_function(:crypto_secretbox_xsalsa20poly1305_keybytes, [], :size_t) 16 | attach_function(:crypto_secretbox_xsalsa20poly1305_noncebytes, [], :size_t) 17 | attach_function(:crypto_secretbox_xsalsa20poly1305_zerobytes, [], :size_t) 18 | attach_function(:crypto_secretbox_xsalsa20poly1305_boxzerobytes, [], :size_t) 19 | end 20 | 21 | # Utility class for interacting with required `xsalsa20poly1305` functions for voice transmission 22 | # @!visibility private 23 | class SecretBox 24 | # Exception raised when a key or nonce with invalid length is used 25 | class LengthError < RuntimeError 26 | end 27 | 28 | # Exception raised when encryption or decryption fails 29 | class CryptoError < RuntimeError 30 | end 31 | 32 | # Required key length 33 | KEY_LENGTH = Sodium.crypto_secretbox_xsalsa20poly1305_keybytes 34 | 35 | # Required nonce length 36 | NONCE_BYTES = Sodium.crypto_secretbox_xsalsa20poly1305_noncebytes 37 | 38 | # Zero byte padding for encryption 39 | ZERO_BYTES = Sodium.crypto_secretbox_xsalsa20poly1305_zerobytes 40 | 41 | # Zero byte padding for decryption 42 | BOX_ZERO_BYTES = Sodium.crypto_secretbox_xsalsa20poly1305_boxzerobytes 43 | 44 | # @param key [String] Crypto key of length {KEY_LENGTH} 45 | def initialize(key) 46 | raise(LengthError, 'Key length') if key.bytesize != KEY_LENGTH 47 | 48 | @key = key 49 | end 50 | 51 | # Encrypts a message using this box's key 52 | # @param nonce [String] encryption nonce for this message 53 | # @param message [String] message to be encrypted 54 | def box(nonce, message) 55 | raise(LengthError, 'Nonce length') if nonce.bytesize != NONCE_BYTES 56 | 57 | message_padded = prepend_zeroes(ZERO_BYTES, message) 58 | buffer = zero_string(message_padded.bytesize) 59 | 60 | success = Sodium.crypto_secretbox_xsalsa20poly1305(buffer, message_padded, message_padded.bytesize, nonce, @key) 61 | raise(CryptoError, "Encryption failed (#{success})") unless success.zero? 62 | 63 | remove_zeroes(BOX_ZERO_BYTES, buffer) 64 | end 65 | 66 | # Decrypts the given ciphertext using this box's key 67 | # @param nonce [String] encryption nonce for this ciphertext 68 | # @param ciphertext [String] ciphertext to decrypt 69 | def open(nonce, ciphertext) 70 | raise(LengthError, 'Nonce length') if nonce.bytesize != NONCE_BYTES 71 | 72 | ct_padded = prepend_zeroes(BOX_ZERO_BYTES, ciphertext) 73 | buffer = zero_string(ct_padded.bytesize) 74 | 75 | success = Sodium.crypto_secretbox_xsalsa20poly1305_open(buffer, ct_padded, ct_padded.bytesize, nonce, @key) 76 | raise(CryptoError, "Decryption failed (#{success})") unless success.zero? 77 | 78 | remove_zeroes(ZERO_BYTES, buffer) 79 | end 80 | 81 | private 82 | 83 | def zero_string(size) 84 | str = "\0" * size 85 | str.force_encoding('ASCII-8BIT') if str.respond_to?(:force_encoding) 86 | end 87 | 88 | def prepend_zeroes(size, string) 89 | zero_string(size) + string 90 | end 91 | 92 | def remove_zeroes(size, string) 93 | string.slice!(size, string.bytesize - size) 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/discordrb/webhooks/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/webhooks/embeds' 4 | 5 | module Discordrb::Webhooks 6 | # A class that acts as a builder for a webhook message object. 7 | class Builder 8 | def initialize(content: '', username: nil, avatar_url: nil, tts: false, file: nil, embeds: [], allowed_mentions: nil) 9 | @content = content 10 | @username = username 11 | @avatar_url = avatar_url 12 | @tts = tts 13 | @file = file 14 | @embeds = embeds 15 | @allowed_mentions = allowed_mentions 16 | end 17 | 18 | # The content of the message. May be 2000 characters long at most. 19 | # @return [String] the content of the message. 20 | attr_accessor :content 21 | 22 | # The username the webhook will display as. If this is not set, the default username set in the webhook's settings 23 | # will be used instead. 24 | # @return [String] the username. 25 | attr_accessor :username 26 | 27 | # The URL of an image file to be used as an avatar. If this is not set, the default avatar from the webhook's 28 | # settings will be used instead. 29 | # @return [String] the avatar URL. 30 | attr_accessor :avatar_url 31 | 32 | # Whether this message should use TTS or not. By default, it doesn't. 33 | # @return [true, false] the TTS status. 34 | attr_accessor :tts 35 | 36 | # Sets a file to be sent together with the message. Mutually exclusive with embeds; a webhook message can contain 37 | # either a file to be sent or an embed. 38 | # @param file [File] A file to be sent. 39 | def file=(file) 40 | raise ArgumentError, 'Embeds and files are mutually exclusive!' unless @embeds.empty? 41 | 42 | @file = file 43 | end 44 | 45 | # Adds an embed to this message. 46 | # @param embed [Embed] The embed to add. 47 | def <<(embed) 48 | raise ArgumentError, 'Embeds and files are mutually exclusive!' if @file 49 | 50 | @embeds << embed 51 | end 52 | 53 | # Convenience method to add an embed using a block-style builder pattern 54 | # @example Add an embed to a message 55 | # builder.add_embed do |embed| 56 | # embed.title = 'Testing' 57 | # embed.image = Discordrb::Webhooks::EmbedImage.new(url: 'https://i.imgur.com/PcMltU7.jpg') 58 | # end 59 | # @param embed [Embed, nil] The embed to start the building process with, or nil if one should be created anew. 60 | # @return [Embed] The created embed. 61 | def add_embed(embed = nil) 62 | embed ||= Embed.new 63 | yield(embed) 64 | self << embed 65 | embed 66 | end 67 | 68 | # @return [File, nil] the file attached to this message. 69 | attr_reader :file 70 | 71 | # @return [Array] the embeds attached to this message. 72 | attr_reader :embeds 73 | 74 | # @return [Discordrb::AllowedMentions, Hash] Mentions that are allowed to ping in this message. 75 | # @see https://discord.com/developers/docs/resources/channel#allowed-mentions-object 76 | attr_accessor :allowed_mentions 77 | 78 | # @return [Hash] a hash representation of the created message, for JSON format. 79 | def to_json_hash 80 | { 81 | content: @content, 82 | username: @username, 83 | avatar_url: @avatar_url, 84 | tts: @tts, 85 | embeds: @embeds.map(&:to_hash), 86 | allowed_mentions: @allowed_mentions&.to_hash 87 | } 88 | end 89 | 90 | # @return [Hash] a hash representation of the created message, for multipart format. 91 | def to_multipart_hash 92 | { 93 | content: @content, 94 | username: @username, 95 | avatar_url: @avatar_url, 96 | tts: @tts, 97 | file: @file, 98 | allowed_mentions: @allowed_mentions&.to_hash 99 | } 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | commands: 4 | setup-env: 5 | description: Sets up the testing environment 6 | steps: 7 | - run: 8 | name: Install OS packages 9 | command: apk add git build-base ruby-dev ruby-etc ruby-json libsodium 10 | - checkout 11 | - run: 12 | name: "Ruby version" 13 | command: | 14 | ruby -v 15 | echo $RUBY_VERSION > ruby_version.txt 16 | - restore_cache: 17 | keys: 18 | - bundle-cache-v1-{{ checksum "ruby_version.txt" }}-{{ .Branch }}-{{ checksum "Gemfile" }}-{{ checksum "discordrb.gemspec" }} 19 | - bundle-cache-v1-{{ checksum "ruby_version.txt" }}-{{ .Branch }} 20 | - bundle-cache-v1-{{ checksum "ruby_version.txt" }} 21 | - run: 22 | name: Install dependencies 23 | command: bundle install --path vendor/bundle 24 | - save_cache: 25 | key: bundle-cache-v1-{{ checksum "ruby_version.txt" }}-{{ .Branch }}-{{ checksum "Gemfile" }}-{{ checksum "discordrb.gemspec" }} 26 | paths: 27 | - ./vendor/bundle 28 | 29 | jobs: 30 | test_ruby_26: 31 | docker: 32 | - image: ruby:2.6-alpine 33 | steps: 34 | - setup-env 35 | - run: 36 | name: Run RSpec 37 | command: bundle exec rspec 38 | 39 | test_ruby_27: 40 | docker: 41 | - image: ruby:2.7-alpine 42 | steps: 43 | - setup-env 44 | - run: 45 | name: Run RSpec 46 | command: bundle exec rspec 47 | 48 | test_ruby_30: 49 | docker: 50 | - image: ruby:3.0-alpine 51 | steps: 52 | - setup-env 53 | - run: 54 | name: Run RSpec 55 | command: bundle exec rspec 56 | 57 | rubocop: 58 | docker: 59 | - image: ruby:2.6-alpine 60 | steps: 61 | - setup-env 62 | - run: 63 | name: Run Rubocop 64 | command: bundle exec rubocop -P 65 | 66 | yard: 67 | docker: 68 | - image: ruby:2.6-alpine 69 | steps: 70 | - setup-env 71 | - attach_workspace: 72 | at: /tmp/workspace 73 | - run: 74 | name: Run YARD 75 | command: bundle exec yard --output-dir /tmp/workspace/docs 76 | - persist_to_workspace: 77 | root: /tmp/workspace 78 | paths: 79 | - docs 80 | 81 | pages: 82 | machine: true 83 | steps: 84 | - attach_workspace: 85 | at: /tmp/workspace 86 | - run: 87 | name: Clone docs 88 | command: git clone $CIRCLE_REPOSITORY_URL -b gh-pages . 89 | - add_ssh_keys: 90 | fingerprints: 91 | - "9a:4c:50:94:23:46:81:74:41:97:87:04:4e:59:4b:4e" 92 | - run: 93 | name: Push updated docs 94 | command: | 95 | git config user.name "Circle CI" 96 | git config user.email "ci-build@shardlab.dev" 97 | 98 | SOURCE_BRANCH=$CIRCLE_BRANCH 99 | if [ -n "$CIRCLE_TAG" ]; then 100 | SOURCE_BRANCH=$CIRCLE_TAG 101 | fi 102 | 103 | mkdir -p $SOURCE_BRANCH 104 | rm -rf $SOURCE_BRANCH/* 105 | cp -r /tmp/workspace/docs/. ./$SOURCE_BRANCH/ 106 | 107 | git add $SOURCE_BRANCH 108 | git commit --allow-empty -m "[skip ci] Deploy docs" 109 | git push -u origin gh-pages 110 | 111 | workflows: 112 | test: 113 | jobs: 114 | - test_ruby_26 115 | - test_ruby_27 116 | - test_ruby_30 117 | - rubocop 118 | - yard 119 | deploy: 120 | jobs: 121 | - yard: 122 | filters: 123 | branches: 124 | only: 125 | - main 126 | - slash_commands 127 | tags: 128 | only: /^v.*/ 129 | - pages: 130 | requires: 131 | - yard 132 | filters: 133 | branches: 134 | only: 135 | - main 136 | - slash_commands 137 | tags: 138 | only: /^v.*/ 139 | -------------------------------------------------------------------------------- /lib/discordrb/data/overwrite.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # A permissions overwrite, when applied to channels describes additional 5 | # permissions a member needs to perform certain actions in context. 6 | class Overwrite 7 | # Types of overwrites mapped to their API value. 8 | TYPES = { 9 | role: 0, 10 | member: 1 11 | }.freeze 12 | 13 | # @return [Integer] ID of the thing associated with this overwrite type 14 | attr_accessor :id 15 | 16 | # @return [Symbol] either :role or :member 17 | attr_accessor :type 18 | 19 | # @return [Permissions] allowed permissions for this overwrite type 20 | attr_accessor :allow 21 | 22 | # @return [Permissions] denied permissions for this overwrite type 23 | attr_accessor :deny 24 | 25 | # Creates a new Overwrite object 26 | # @example Create an overwrite for a role that can mention everyone, send TTS messages, but can't create instant invites 27 | # allow = Discordrb::Permissions.new 28 | # allow.can_mention_everyone = true 29 | # allow.can_send_tts_messages = true 30 | # 31 | # deny = Discordrb::Permissions.new 32 | # deny.can_create_instant_invite = true 33 | # 34 | # # Find some role by name 35 | # role = server.roles.find { |r| r.name == 'some role' } 36 | # 37 | # Overwrite.new(role, allow: allow, deny: deny) 38 | # @example Create an overwrite by ID and permissions bits 39 | # Overwrite.new(120571255635181568, type: 'member', allow: 1024, deny: 0) 40 | # @param object [Integer, #id] the ID or object this overwrite is for 41 | # @param type [String, Symbol, Integer] the type of object this overwrite is for (only required if object is an Integer) 42 | # @param allow [String, Integer, Permissions] allowed permissions for this overwrite, by bits or a Permissions object 43 | # @param deny [String, Integer, Permissions] denied permissions for this overwrite, by bits or a Permissions object 44 | # @raise [ArgumentError] if type is not :member or :role 45 | def initialize(object = nil, type: nil, allow: 0, deny: 0) 46 | if type 47 | type = TYPES.value?(type) ? TYPES.key(type) : type.to_sym 48 | raise ArgumentError, 'Overwrite type must be :member or :role' unless type 49 | end 50 | 51 | @id = object.respond_to?(:id) ? object.id : object 52 | 53 | @type = case object 54 | when User, Member, Recipient, Profile 55 | :member 56 | when Role 57 | :role 58 | else 59 | type 60 | end 61 | 62 | @allow = allow.is_a?(Permissions) ? allow : Permissions.new(allow) 63 | @deny = deny.is_a?(Permissions) ? deny : Permissions.new(deny) 64 | end 65 | 66 | # Comparison by attributes [:id, :type, :allow, :deny] 67 | def ==(other) 68 | false unless other.is_a? Discordrb::Overwrite 69 | id == other.id && 70 | type == other.type && 71 | allow == other.allow && 72 | deny == other.deny 73 | end 74 | 75 | # @return [Overwrite] create an overwrite from a hash payload 76 | # @!visibility private 77 | def self.from_hash(data) 78 | new( 79 | data['id'].to_i, 80 | type: TYPES.key(data['type']), 81 | allow: Permissions.new(data['allow']), 82 | deny: Permissions.new(data['deny']) 83 | ) 84 | end 85 | 86 | # @return [Overwrite] copies an overwrite from another Overwrite 87 | # @!visibility private 88 | def self.from_other(other) 89 | new( 90 | other.id, 91 | type: other.type, 92 | allow: Permissions.new(other.allow.bits), 93 | deny: Permissions.new(other.deny.bits) 94 | ) 95 | end 96 | 97 | # @return [Hash] hash representation of an overwrite 98 | # @!visibility private 99 | def to_hash 100 | { 101 | id: id, 102 | type: TYPES[type], 103 | allow: allow.bits, 104 | deny: deny.bits 105 | } 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/discordrb/data/integration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # Integration Account 5 | class IntegrationAccount 6 | # @return [String] this account's name. 7 | attr_reader :name 8 | 9 | # @return [Integer] this account's ID. 10 | attr_reader :id 11 | 12 | def initialize(data) 13 | @name = data['name'] 14 | @id = data['id'].to_i 15 | end 16 | end 17 | 18 | # Bot/OAuth2 application for discord integrations 19 | class IntegrationApplication 20 | # @return [Integer] the ID of the application. 21 | attr_reader :id 22 | 23 | # @return [String] the name of the application. 24 | attr_reader :name 25 | 26 | # @return [String, nil] the icon hash of the application. 27 | attr_reader :icon 28 | 29 | # @return [String] the description of the application. 30 | attr_reader :description 31 | 32 | # @return [String] the summary of the application. 33 | attr_reader :summary 34 | 35 | # @return [User, nil] the bot associated with this application. 36 | attr_reader :bot 37 | 38 | def initialize(data, bot) 39 | @id = data['id'].to_i 40 | @name = data['name'] 41 | @icon = data['icon'] 42 | @description = data['description'] 43 | @summary = data['summary'] 44 | @bot = Discordrb::User.new(data['user'], bot) if data['user'] 45 | end 46 | end 47 | 48 | # Server integration 49 | class Integration 50 | include IDObject 51 | 52 | # @return [String] the integration name 53 | attr_reader :name 54 | 55 | # @return [Server] the server the integration is linked to 56 | attr_reader :server 57 | 58 | # @return [User] the user the integration is linked to 59 | attr_reader :user 60 | 61 | # @return [Integer, nil] the role that this integration uses for "subscribers" 62 | attr_reader :role_id 63 | 64 | # @return [true, false] whether emoticons are enabled 65 | attr_reader :emoticon 66 | alias_method :emoticon?, :emoticon 67 | 68 | # @return [String] the integration type (YouTube, Twitch, etc.) 69 | attr_reader :type 70 | 71 | # @return [true, false] whether the integration is enabled 72 | attr_reader :enabled 73 | 74 | # @return [true, false] whether the integration is syncing 75 | attr_reader :syncing 76 | 77 | # @return [IntegrationAccount] the integration account information 78 | attr_reader :account 79 | 80 | # @return [Time] the time the integration was synced at 81 | attr_reader :synced_at 82 | 83 | # @return [Symbol] the behaviour of expiring subscribers (:remove = Remove User from role; :kick = Kick User from server) 84 | attr_reader :expire_behaviour 85 | alias_method :expire_behavior, :expire_behaviour 86 | 87 | # @return [Integer] the grace period before subscribers expire (in days) 88 | attr_reader :expire_grace_period 89 | 90 | # @return [Integer, nil] how many subscribers this integration has. 91 | attr_reader :subscriber_count 92 | 93 | # @return [true, false] has this integration been revoked. 94 | attr_reader :revoked 95 | 96 | def initialize(data, bot, server) 97 | @bot = bot 98 | 99 | @name = data['name'] 100 | @server = server 101 | @id = data['id'].to_i 102 | @enabled = data['enabled'] 103 | @syncing = data['syncing'] 104 | @type = data['type'] 105 | @account = IntegrationAccount.new(data['account']) 106 | @synced_at = Time.parse(data['synced_at']) 107 | @expire_behaviour = %i[remove kick][data['expire_behavior']] 108 | @expire_grace_period = data['expire_grace_period'] 109 | @user = @bot.ensure_user(data['user']) 110 | @role_id = data['role_id']&.to_i 111 | @emoticon = data['enable_emoticons'] 112 | @subscriber_count = data['subscriber_count']&.to_i 113 | @revoked = data['revoked'] 114 | @application = IntegrationApplication.new(data['application'], bot) if data['application'] 115 | end 116 | 117 | # The inspect method is overwritten to give more useful output 118 | def inspect 119 | "" 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/discordrb/events/invites.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb::Events 4 | # Raised when an invite is created. 5 | class InviteCreateEvent < Event 6 | # @return [Invite] The invite that was created. 7 | attr_reader :invite 8 | 9 | # @return [Server, nil] The server the invite was created for. 10 | attr_reader :server 11 | 12 | # @return [Channel] The channel the invite was created for. 13 | attr_reader :channel 14 | 15 | # @!attribute [r] code 16 | # @return [String] The code for the created invite. 17 | # @see Invite#code 18 | # @!attribute [r] created_at 19 | # @return [Time] The time the invite was created at. 20 | # @see Invite#created_at 21 | # @!attribute [r] max_age 22 | # @return [Integer] The maximum age of the created invite. 23 | # @see Invite#max_age 24 | # @!attribute [r] max_uses 25 | # @return [Integer] The maximum number of uses before the invite expires. 26 | # @see Invite#max_uses 27 | # @!attribute [r] temporary 28 | # @return [true, false] Whether or not this invite grants temporary membership. 29 | # @see Invite#temporary 30 | # @!attribute [r] inviter 31 | # @return [User] The user that created the invite. 32 | # @see Invite#inviter 33 | delegate :code, :created_at, :max_age, :max_uses, :temporary, :inviter, to: :invite 34 | 35 | alias temporary? temporary 36 | 37 | def initialize(data, invite, bot) 38 | @bot = bot 39 | @invite = invite 40 | @channel = bot.channel(data['channel_id']) 41 | @server = bot.server(data['guild_id']) if data['guild_id'] 42 | end 43 | end 44 | 45 | # Raised when an invite is deleted. 46 | class InviteDeleteEvent < Event 47 | # @return [Channel] The channel the deleted invite was for. 48 | attr_reader :channel 49 | 50 | # @return [Server, nil] The server the deleted invite was for. 51 | attr_reader :server 52 | 53 | # @return [String] The code of the deleted invite. 54 | attr_reader :code 55 | 56 | def initialize(data, bot) 57 | @bot = bot 58 | @channel = bot.channel(data['channel_id']) 59 | @server = bot.server(data['guild_id']) if data['guild_id'] 60 | @code = data['code'] 61 | end 62 | end 63 | 64 | # Event handler for InviteCreateEvent. 65 | class InviteCreateEventHandler < EventHandler 66 | def matches?(event) 67 | return false unless event.is_a? InviteCreateEvent 68 | 69 | [ 70 | matches_all(@attributes[:server], event.server) do |a, e| 71 | a == case a 72 | when String 73 | e.name 74 | when Integer 75 | e.id 76 | else 77 | e 78 | end 79 | end, 80 | matches_all(@attributes[:channel], event.channel) do |a, e| 81 | a == case a 82 | when String 83 | e.name 84 | when Integer 85 | e.id 86 | else 87 | e 88 | end 89 | end, 90 | matches_all(@attributes[:temporary], event.temporary?, &:==), 91 | matches_all(@attributes[:inviter], event.inviter, &:==) 92 | ].reduce(true, &:&) 93 | end 94 | end 95 | 96 | # Event handler for InviteDeleteEvent 97 | class InviteDeleteEventHandler < EventHandler 98 | def matches?(event) 99 | return false unless event.is_a? InviteDeleteEvent 100 | 101 | [ 102 | matches_all(@attributes[:server], event.server) do |a, e| 103 | a == case a 104 | when String 105 | e.name 106 | when Integer 107 | e.id 108 | else 109 | e 110 | end 111 | end, 112 | matches_all(@attributes[:channel], event.channel) do |a, e| 113 | a == case a 114 | when String 115 | e.name 116 | when Integer 117 | e.id 118 | else 119 | e 120 | end 121 | end 122 | ].reduce(true, &:&) 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /spec/overwrite_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | 5 | describe Discordrb::Overwrite do 6 | describe '#initialize' do 7 | context 'when object is an Integer' do 8 | let(:id) { instance_double(Integer) } 9 | 10 | it 'accepts the API value for `type`' do 11 | overwrite = described_class.new(id, type: 0, allow: 0, deny: 0) 12 | 13 | expect(overwrite.type).to eq Discordrb::Overwrite::TYPES.key(0) 14 | end 15 | 16 | it 'accepts a short name value for `type`' do 17 | overwrite = described_class.new(id, type: :role, allow: 0, deny: 0) 18 | 19 | expect(overwrite.type).to eq :role 20 | end 21 | 22 | it 'accepts a string as a value for `type`' do 23 | overwrite = described_class.new(id, type: 'role', allow: 0, deny: 0) 24 | 25 | expect(overwrite.type).to eq :role 26 | end 27 | end 28 | 29 | context 'when object is a User type' do 30 | let(:user_types) { [Discordrb::User, Discordrb::Member, Discordrb::Recipient, Discordrb::Profile] } 31 | let(:users) do 32 | user_types.collect { |k| [k, instance_double(k)] }.to_h 33 | end 34 | 35 | before do 36 | users.each do |user_type, dbl| 37 | allow(user_type).to receive(:===).with(anything).and_return(false) 38 | allow(user_type).to receive(:===).with(dbl).and_return(true) 39 | end 40 | end 41 | 42 | it 'infers type from a User object' do 43 | users.each do |_user_type, user| 44 | expect(described_class.new(user).type).to eq :member 45 | end 46 | end 47 | end 48 | 49 | context 'when object is a Role' do 50 | let(:role) { instance_double(Discordrb::Role) } 51 | 52 | it 'infers type from a Role object' do 53 | allow(Discordrb::Role).to receive(:===).with(anything).and_return(false) 54 | allow(Discordrb::Role).to receive(:===).with(role).and_return(true) 55 | 56 | expect(described_class.new(role).type).to eq :role 57 | end 58 | end 59 | end 60 | 61 | describe '#to_hash' do 62 | let(:id) { instance_double(Integer) } 63 | let(:allow_perm) { instance_double(Discordrb::Permissions, bits: allow_bits) } 64 | let(:allow_bits) { instance_double(Integer) } 65 | let(:deny_perm) { instance_double(Discordrb::Permissions, bits: deny_bits) } 66 | let(:deny_bits) { instance_double(Integer) } 67 | 68 | before do 69 | allow(allow_perm).to receive(:is_a?).with(Discordrb::Permissions).and_return(true) 70 | allow(deny_perm).to receive(:is_a?).with(Discordrb::Permissions).and_return(true) 71 | end 72 | 73 | it 'creates a hash from the relevant values' do 74 | overwrite = described_class.new(id, type: :member, allow: allow_perm, deny: deny_perm) 75 | expect(overwrite.to_hash).to eq({ 76 | id: id, 77 | type: Discordrb::Overwrite::TYPES[:member], 78 | allow: allow_bits, 79 | deny: deny_bits 80 | }) 81 | end 82 | end 83 | 84 | describe '.from_hash' do 85 | let(:id) { instance_double(Integer) } 86 | let(:type) { Discordrb::Overwrite::TYPES[:role] } 87 | 88 | before do 89 | allow(id).to receive(:to_i).and_return(id) 90 | end 91 | 92 | it 'converts a hash to an Overwrite' do 93 | overwrite = described_class.from_hash({ 94 | 'id' => id, 'type' => type, 'allow' => 0, deny: 0 95 | }) 96 | 97 | expect(overwrite).to eq described_class.new(id, type: :role, allow: 0, deny: 0) 98 | end 99 | end 100 | 101 | describe '.from_other' do 102 | let(:original) { described_class.new(12_345, type: :role, allow: 100, deny: 100) } 103 | 104 | it 'creates a new object from another Overwrite' do 105 | copy = described_class.from_other(original) 106 | 107 | expect(copy).to eq original 108 | end 109 | 110 | it 'creates new permission objects' do 111 | copy = described_class.from_other(original) 112 | 113 | expect(copy.allow).not_to be original.allow 114 | expect(copy.deny).not_to be original.deny 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/discordrb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/version' 4 | require 'discordrb/bot' 5 | require 'discordrb/commands/command_bot' 6 | require 'discordrb/logger' 7 | 8 | # All discordrb functionality, to be extended by other files 9 | module Discordrb 10 | Thread.current[:discordrb_name] = 'main' 11 | 12 | # The default debug logger used by discordrb. 13 | LOGGER = Logger.new(ENV['DISCORDRB_FANCY_LOG']) 14 | 15 | # The Unix timestamp Discord IDs are based on 16 | DISCORD_EPOCH = 1_420_070_400_000 17 | 18 | # Used to declare what events you wish to recieve from Discord. 19 | # @see https://discord.com/developers/docs/topics/gateway#gateway-intents 20 | INTENTS = { 21 | servers: 1 << 0, 22 | server_members: 1 << 1, 23 | server_bans: 1 << 2, 24 | server_emojis: 1 << 3, 25 | server_integrations: 1 << 4, 26 | server_webhooks: 1 << 5, 27 | server_invites: 1 << 6, 28 | server_voice_states: 1 << 7, 29 | server_presences: 1 << 8, 30 | server_messages: 1 << 9, 31 | server_message_reactions: 1 << 10, 32 | server_message_typing: 1 << 11, 33 | direct_messages: 1 << 12, 34 | direct_message_reactions: 1 << 13, 35 | direct_message_typing: 1 << 14 36 | }.freeze 37 | 38 | # All available intents 39 | ALL_INTENTS = INTENTS.values.reduce(&:|) 40 | 41 | # All unprivileged intents 42 | # @see https://discord.com/developers/docs/topics/gateway#privileged-intents 43 | UNPRIVILEGED_INTENTS = ALL_INTENTS & ~(INTENTS[:server_members] | INTENTS[:server_presences]) 44 | 45 | # No intents 46 | NO_INTENTS = 0 47 | 48 | # Compares two objects based on IDs - either the objects' IDs are equal, or one object is equal to the other's ID. 49 | def self.id_compare(one_id, other) 50 | other.respond_to?(:resolve_id) ? (one_id.resolve_id == other.resolve_id) : (one_id == other) 51 | end 52 | 53 | # The maximum length a Discord message can have 54 | CHARACTER_LIMIT = 2000 55 | 56 | # Splits a message into chunks of 2000 characters. Attempts to split by lines if possible. 57 | # @param msg [String] The message to split. 58 | # @return [Array] the message split into chunks 59 | def self.split_message(msg) 60 | # If the messages is empty, return an empty array 61 | return [] if msg.empty? 62 | 63 | # Split the message into lines 64 | lines = msg.lines 65 | 66 | # Turn the message into a "triangle" of consecutively longer slices, for example the array [1,2,3,4] would become 67 | # [ 68 | # [1], 69 | # [1, 2], 70 | # [1, 2, 3], 71 | # [1, 2, 3, 4] 72 | # ] 73 | tri = (0...lines.length).map { |i| lines.combination(i + 1).first } 74 | 75 | # Join the individual elements together to get an array of strings with consecutively more lines 76 | joined = tri.map(&:join) 77 | 78 | # Find the largest element that is still below the character limit, or if none such element exists return the first 79 | ideal = joined.max_by { |e| e.length > CHARACTER_LIMIT ? -1 : e.length } 80 | 81 | # If it's still larger than the character limit (none was smaller than it) split it into the largest chunk without 82 | # cutting words apart, breaking on the nearest space within character limit, otherwise just return an array with one element 83 | ideal_ary = ideal.length > CHARACTER_LIMIT ? ideal.split(/(.{1,#{CHARACTER_LIMIT}}\b|.{1,#{CHARACTER_LIMIT}})/o).reject(&:empty?) : [ideal] 84 | 85 | # Slice off the ideal part and strip newlines 86 | rest = msg[ideal.length..].strip 87 | 88 | # If none remains, return an empty array -> we're done 89 | return [] unless rest 90 | 91 | # Otherwise, call the method recursively to split the rest of the string and add it onto the ideal array 92 | ideal_ary + split_message(rest) 93 | end 94 | end 95 | 96 | # In discordrb, Integer and {String} are monkey-patched to allow for easy resolution of IDs 97 | class Integer 98 | # @return [Integer] The Discord ID represented by this integer, i.e. the integer itself 99 | def resolve_id 100 | self 101 | end 102 | end 103 | 104 | # In discordrb, {Integer} and String are monkey-patched to allow for easy resolution of IDs 105 | class String 106 | # @return [Integer] The Discord ID represented by this string, i.e. the string converted to an integer 107 | def resolve_id 108 | to_i 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/discordrb/events/presence.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/generic' 4 | require 'discordrb/data' 5 | 6 | module Discordrb::Events 7 | # Event raised when a user's presence state updates (idle or online) 8 | class PresenceEvent < Event 9 | # @return [Server] the server on which the presence update happened. 10 | attr_reader :server 11 | 12 | # @return [User] the user whose status got updated. 13 | attr_reader :user 14 | 15 | # @return [Symbol] the new status. 16 | attr_reader :status 17 | 18 | # @return [Hash] the current online status (`:online`, `:idle` or `:dnd`) of the user 19 | # on various device types (`:desktop`, `:mobile`, or `:web`). The value will be `nil` if the user is offline or invisible. 20 | attr_reader :client_status 21 | 22 | def initialize(data, bot) 23 | @bot = bot 24 | 25 | @user = bot.user(data['user']['id'].to_i) 26 | @status = data['status'].to_sym 27 | @client_status = user.client_status 28 | @server = bot.server(data['guild_id'].to_i) 29 | end 30 | end 31 | 32 | # Event handler for PresenceEvent 33 | class PresenceEventHandler < EventHandler 34 | def matches?(event) 35 | # Check for the proper event type 36 | return false unless event.is_a? PresenceEvent 37 | 38 | [ 39 | matches_all(@attributes[:from], event.user) do |a, e| 40 | a == case a 41 | when String 42 | e.name 43 | when Integer 44 | e.id 45 | else 46 | e 47 | end 48 | end, 49 | matches_all(@attributes[:status], event.status) do |a, e| 50 | a == if a.is_a? String 51 | e.to_s 52 | else 53 | e 54 | end 55 | end 56 | ].reduce(true, &:&) 57 | end 58 | end 59 | 60 | # Event raised when a user starts or stops playing a game 61 | class PlayingEvent < Event 62 | # @return [Server] the server on which the presence update happened. 63 | attr_reader :server 64 | 65 | # @return [User] the user whose status got updated. 66 | attr_reader :user 67 | 68 | # @return [Discordrb::Activity] The new activity 69 | attr_reader :activity 70 | 71 | # @!attribute [r] url 72 | # @return [String] the URL to the stream 73 | 74 | # @!attribute [r] details 75 | # @return [String] what the player is currently doing (ex. game being streamed) 76 | 77 | # @!attribute [r] type 78 | # @return [Integer] the type of play. See {Discordrb::Activity} 79 | delegate :url, :details, :type, to: :activity 80 | 81 | # @return [Hash] the current online status (`:online`, `:idle` or `:dnd`) of the user 82 | # on various device types (`:desktop`, `:mobile`, or `:web`). The value will be `nil` if the user is offline or invisible. 83 | attr_reader :client_status 84 | 85 | def initialize(data, activity, bot) 86 | @bot = bot 87 | @activity = activity 88 | 89 | @server = bot.server(data['guild_id'].to_i) 90 | @user = bot.user(data['user']['id'].to_i) 91 | @client_status = @user.client_status 92 | end 93 | 94 | # @return [String] the name of the new game the user is playing. 95 | def game 96 | @activity.name 97 | end 98 | end 99 | 100 | # Event handler for PlayingEvent 101 | class PlayingEventHandler < EventHandler 102 | def matches?(event) 103 | # Check for the proper event type 104 | return false unless event.is_a? PlayingEvent 105 | 106 | [ 107 | matches_all(@attributes[:from], event.user) do |a, e| 108 | a == case a 109 | when String 110 | e.name 111 | when Integer 112 | e.id 113 | else 114 | e 115 | end 116 | end, 117 | matches_all(@attributes[:game], event.game) do |a, e| 118 | a == e 119 | end, 120 | matches_all(@attributes[:type], event.type) do |a, e| 121 | a == e 122 | end, 123 | matches_all(@attributes[:client_status], event.client_status) do |a, e| 124 | e.slice(a.keys) == a 125 | end 126 | ].reduce(true, &:&) 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/discordrb/voice/encoder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This makes opus an optional dependency 4 | begin 5 | require 'opus-ruby' 6 | OPUS_AVAILABLE = true 7 | rescue LoadError 8 | OPUS_AVAILABLE = false 9 | end 10 | 11 | # Discord voice chat support 12 | module Discordrb::Voice 13 | # This class conveniently abstracts opus and ffmpeg/avconv, for easy implementation of voice sending. It's not very 14 | # useful for most users, but I guess it can be useful sometimes. 15 | class Encoder 16 | # Whether or not avconv should be used instead of ffmpeg. If possible, it is recommended to use ffmpeg instead, 17 | # as it is better supported. 18 | # @return [true, false] whether avconv should be used instead of ffmpeg. 19 | attr_accessor :use_avconv 20 | 21 | # @see VoiceBot#filter_volume= 22 | # @return [Integer] the volume used as a filter to ffmpeg/avconv. 23 | attr_accessor :filter_volume 24 | 25 | # Create a new encoder 26 | def initialize 27 | sample_rate = 48_000 28 | frame_size = 960 29 | channels = 2 30 | @filter_volume = 1 31 | 32 | raise LoadError, 'Opus unavailable - voice not supported! Please install opus for voice support to work.' unless OPUS_AVAILABLE 33 | 34 | @opus = Opus::Encoder.new(sample_rate, frame_size, channels) 35 | end 36 | 37 | # Set the opus encoding bitrate 38 | # @param value [Integer] The new bitrate to use, in bits per second (so 64000 if you want 64 kbps) 39 | def bitrate=(value) 40 | @opus.bitrate = value 41 | end 42 | 43 | # Encodes the given buffer using opus. 44 | # @param buffer [String] An unencoded PCM (s16le) buffer. 45 | # @return [String] A buffer encoded using opus. 46 | def encode(buffer) 47 | @opus.encode(buffer, 1920) 48 | end 49 | 50 | # One frame of complete silence Opus encoded 51 | OPUS_SILENCE = [0xF8, 0xFF, 0xFE].pack('C*').freeze 52 | 53 | # Adjusts the volume of a given buffer of s16le PCM data. 54 | # @param buf [String] An unencoded PCM (s16le) buffer. 55 | # @param mult [Float] The volume multiplier, 1 for same volume. 56 | # @return [String] The buffer with adjusted volume, s16le again 57 | def adjust_volume(buf, mult) 58 | # We don't need to adjust anything if the buf is nil so just return in that case 59 | return unless buf 60 | 61 | # buf is s16le so use 's<' for signed, 16 bit, LE 62 | result = buf.unpack('s<*').map do |sample| 63 | sample *= mult 64 | 65 | # clamp to s16 range 66 | [32_767, [-32_768, sample].max].min 67 | end 68 | 69 | # After modification, make it s16le again 70 | result.pack('s<*') 71 | end 72 | 73 | # Encodes a given file (or rather, decodes it) using ffmpeg. This accepts pretty much any format, even videos with 74 | # an audio track. For a list of supported formats, see https://ffmpeg.org/general.html#Audio-Codecs. It even accepts 75 | # URLs, though encoding them is pretty slow - I recommend to make a stream of it and then use {#encode_io} instead. 76 | # @param file [String] The path or URL to encode. 77 | # @param options [String] ffmpeg options to pass after the -i flag 78 | # @return [IO] the audio, encoded as s16le PCM 79 | def encode_file(file, options = '') 80 | command = "#{ffmpeg_command} -loglevel 0 -i \"#{file}\" #{options} -f s16le -ar 48000 -ac 2 #{filter_volume_argument} pipe:1" 81 | IO.popen(command) 82 | end 83 | 84 | # Encodes an arbitrary IO audio stream using ffmpeg. Accepts pretty much any media format, even videos with audio 85 | # tracks. For a list of supported audio formats, see https://ffmpeg.org/general.html#Audio-Codecs. 86 | # @param io [IO] The stream to encode. 87 | # @param options [String] ffmpeg options to pass after the -i flag 88 | # @return [IO] the audio, encoded as s16le PCM 89 | def encode_io(io, options = '') 90 | command = "#{ffmpeg_command} -loglevel 0 -i - #{options} -f s16le -ar 48000 -ac 2 #{filter_volume_argument} pipe:1" 91 | IO.popen(command, in: io) 92 | end 93 | 94 | private 95 | 96 | def ffmpeg_command 97 | @use_avconv ? 'avconv' : 'ffmpeg' 98 | end 99 | 100 | def filter_volume_argument 101 | return '' if @filter_volume == 1 102 | 103 | @use_avconv ? "-vol #{(@filter_volume * 256).ceil}" : "-af volume=#{@filter_volume}" 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /examples/slash_commands.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | 5 | bot = Discordrb::Bot.new(token: ENV['SLASH_COMMAND_BOT_TOKEN'], intents: [:server_messages]) 6 | 7 | # We need to register our application comomands separately from the handlers with a special DSL. 8 | # This example uses server specific commands so that they appear immediately for testing, 9 | # but you can omit the server_id as well to register a global command that can take up to an hour 10 | # to appear. 11 | # 12 | # You may want to have a separate script for registering your commands so you don't need to do this every 13 | # time you start your bot. 14 | bot.register_application_command(:example, 'Example commands', server_id: ENV['SLASH_COMMAND_BOT_SERVER_ID']) do |cmd| 15 | cmd.subcommand_group(:fun, 'Fun things!') do |group| 16 | group.subcommand('8ball', 'Shake the magic 8 ball') do |sub| 17 | sub.string('question', 'Ask a question to receive wisdom', required: true) 18 | end 19 | 20 | group.subcommand('java', 'What if it was java?') 21 | 22 | group.subcommand('calculator', 'do math!') do |sub| 23 | sub.integer('first', 'First number') 24 | sub.string('operation', 'What to do', choices: { times: '*', divided_by: '/', plus: '+', minus: '-' }) 25 | sub.integer('second', 'Second number') 26 | end 27 | 28 | group.subcommand('button-test', 'Test a button!') 29 | end 30 | end 31 | 32 | bot.register_application_command(:spongecase, 'Are you mocking me?', server_id: ENV['SLASH_COMMAND_BOT_SERVER_ID']) do |cmd| 33 | cmd.string('message', 'Message to spongecase') 34 | cmd.boolean('with_picture', 'Show the mocking sponge?') 35 | end 36 | 37 | # This is a really large and fairly pointless example of a subcommand. 38 | # You can also create subcommand handlers directly on the command like so 39 | # bot.application_command(:other_example).subcommand(:test) do |event| 40 | # # ... 41 | # end 42 | # bot.application_command(:other_example).subcommand(:test2) do |event| 43 | # # ... 44 | # end 45 | bot.application_command(:example).group(:fun) do |group| 46 | group.subcommand('8ball') do |event| 47 | wisdom = ['Yes', 'No', 'Try Again Later'].sample 48 | event.respond(content: <<~STR, ephemeral: true) 49 | ``` 50 | #{event.options['question']} 51 | ``` 52 | _#{wisdom}_ 53 | STR 54 | end 55 | 56 | group.subcommand(:java) do |event| 57 | javaisms = %w[Factory Builder Service Provider Instance Class Reducer Map] 58 | jumble = [] 59 | [*5..10].sample.times do 60 | jumble << javaisms.sample 61 | end 62 | 63 | event.respond(content: jumble.join) 64 | end 65 | 66 | group.subcommand(:calculator) do |event| 67 | result = event.options['first'].send(event.options['operation'], event.options['second']) 68 | event.respond(content: result) 69 | end 70 | 71 | group.subcommand(:'button-test') do |event| 72 | event.respond(content: 'Button test') do |_, view| 73 | view.row do |r| 74 | r.button(label: 'Test!', style: :primary, emoji: 577663465322315786, custom_id: 'test_button:1') 75 | end 76 | 77 | view.row do |r| 78 | r.select_menu(custom_id: 'test_select', placeholder: 'Select me!', max_values: 3) do |s| 79 | s.option(label: 'Foo', value: 'foo') 80 | s.option(label: 'Bar', value: 'bar') 81 | s.option(label: 'Baz', value: 'baz') 82 | s.option(label: 'Bazinga', value: 'bazinga') 83 | end 84 | end 85 | end 86 | end 87 | end 88 | 89 | bot.application_command(:spongecase) do |event| 90 | ops = %i[upcase downcase] 91 | text = event.options['message'].chars.map { |x| x.__send__(ops.sample) }.join 92 | event.respond(content: text) 93 | 94 | event.send_message(content: 'https://pyxis.nymag.com/v1/imgs/09c/923/65324bb3906b6865f904a72f8f8a908541-16-spongebob-explainer.rsquare.w700.jpg') if event.options['with_picture'] 95 | end 96 | 97 | bot.button(custom_id: /^test_button:/) do |event| 98 | num = event.interaction.button.custom_id.split(':')[1].to_i 99 | 100 | event.update_message(content: num.to_s) do |_, view| 101 | view.row do |row| 102 | row.button(label: '-', style: :danger, custom_id: "test_button:#{num - 1}") 103 | row.button(label: '+', style: :success, custom_id: "test_button:#{num + 1}") 104 | end 105 | end 106 | end 107 | 108 | bot.select_menu(custom_id: 'test_select') do |event| 109 | event.respond(content: "You selected: #{event.values.join(', ')}") 110 | end 111 | 112 | bot.run 113 | -------------------------------------------------------------------------------- /lib/discordrb/logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # The format log timestamps should be in, in strftime format 5 | LOG_TIMESTAMP_FORMAT = '%Y-%m-%d %H:%M:%S.%L' 6 | 7 | # Logs debug messages 8 | class Logger 9 | # @return [true, false] whether this logger is in extra-fancy mode! 10 | attr_writer :fancy 11 | 12 | # @return [String, nil] The bot token to be redacted or nil if it shouldn't. 13 | attr_writer :token 14 | 15 | # @return [Array, Array<#puts & #flush>] the streams the logger should write to. 16 | attr_accessor :streams 17 | 18 | # Creates a new logger. 19 | # @param fancy [true, false] Whether this logger uses fancy mode (ANSI escape codes to make the output colourful) 20 | # @param streams [Array, Array<#puts & #flush>] the streams the logger should write to. 21 | def initialize(fancy = false, streams = [$stdout]) 22 | @fancy = fancy 23 | self.mode = :normal 24 | 25 | @streams = streams 26 | end 27 | 28 | # The modes this logger can have. This is probably useless unless you want to write your own Logger 29 | MODES = { 30 | debug: { long: 'DEBUG', short: 'D', format_code: '' }, 31 | good: { long: 'GOOD', short: '✓', format_code: "\u001B[32m" }, # green 32 | info: { long: 'INFO', short: 'i', format_code: '' }, 33 | warn: { long: 'WARN', short: '!', format_code: "\u001B[33m" }, # yellow 34 | error: { long: 'ERROR', short: '✗', format_code: "\u001B[31m" }, # red 35 | out: { long: 'OUT', short: '→', format_code: "\u001B[36m" }, # cyan 36 | in: { long: 'IN', short: '←', format_code: "\u001B[35m" }, # purple 37 | ratelimit: { long: 'RATELIMIT', short: 'R', format_code: "\u001B[41m" } # red background 38 | }.freeze 39 | 40 | # The ANSI format code that resets formatting 41 | FORMAT_RESET = "\u001B[0m" 42 | 43 | # The ANSI format code that makes something bold 44 | FORMAT_BOLD = "\u001B[1m" 45 | 46 | MODES.each do |mode, hash| 47 | define_method(mode) do |message| 48 | write(message.to_s, hash) if @enabled_modes.include? mode 49 | end 50 | end 51 | 52 | # Sets the logging mode to :debug 53 | # @param value [true, false] Whether debug mode should be on. If it is off the mode will be set to :normal. 54 | def debug=(value) 55 | self.mode = value ? :debug : :normal 56 | end 57 | 58 | # Sets the logging mode 59 | # Possible modes are: 60 | # * :debug logs everything 61 | # * :verbose logs everything except for debug messages 62 | # * :normal logs useful information, warnings and errors 63 | # * :quiet only logs warnings and errors 64 | # * :silent logs nothing 65 | # @param value [Symbol] What logging mode to use 66 | def mode=(value) 67 | case value 68 | when :debug 69 | @enabled_modes = %i[debug good info warn error out in ratelimit] 70 | when :verbose 71 | @enabled_modes = %i[good info warn error out in ratelimit] 72 | when :normal 73 | @enabled_modes = %i[info warn error ratelimit] 74 | when :quiet 75 | @enabled_modes = %i[warn error] 76 | when :silent 77 | @enabled_modes = %i[] 78 | end 79 | end 80 | 81 | # Logs an exception to the console. 82 | # @param e [Exception] The exception to log. 83 | def log_exception(e) 84 | error("Exception: #{e.inspect}") 85 | e.backtrace.each { |line| error(line) } 86 | end 87 | 88 | private 89 | 90 | def write(message, mode) 91 | thread_name = Thread.current[:discordrb_name] 92 | timestamp = Time.now.strftime(LOG_TIMESTAMP_FORMAT) 93 | 94 | # Redact token if set 95 | log = if @token && @token != '' 96 | message.to_s.gsub(@token, 'REDACTED_TOKEN') 97 | else 98 | message.to_s 99 | end 100 | 101 | @streams.each do |stream| 102 | if @fancy && !stream.is_a?(File) 103 | fancy_write(stream, log, mode, thread_name, timestamp) 104 | else 105 | simple_write(stream, log, mode, thread_name, timestamp) 106 | end 107 | end 108 | end 109 | 110 | def fancy_write(stream, message, mode, thread_name, timestamp) 111 | stream.puts "#{timestamp} #{FORMAT_BOLD}#{thread_name.ljust(16)}#{FORMAT_RESET} #{mode[:format_code]}#{mode[:short]}#{FORMAT_RESET} #{message}" 112 | stream.flush 113 | end 114 | 115 | def simple_write(stream, message, mode, thread_name, timestamp) 116 | stream.puts "[#{mode[:long]} : #{thread_name} @ #{timestamp}] #{message}" 117 | stream.flush 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/discordrb/api/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # API calls for User object 4 | module Discordrb::API::User 5 | module_function 6 | 7 | # Get user data 8 | # https://discord.com/developers/docs/resources/user#get-user 9 | def resolve(token, user_id) 10 | Discordrb::API.request( 11 | :users_uid, 12 | nil, 13 | :get, 14 | "#{Discordrb::API.api_base}/users/#{user_id}", 15 | Authorization: token 16 | ) 17 | end 18 | 19 | # Get profile data 20 | # https://discord.com/developers/docs/resources/user#get-current-user 21 | def profile(token) 22 | Discordrb::API.request( 23 | :users_me, 24 | nil, 25 | :get, 26 | "#{Discordrb::API.api_base}/users/@me", 27 | Authorization: token 28 | ) 29 | end 30 | 31 | # Change the current bot's nickname on a server 32 | # https://discord.com/developers/docs/resources/user#modify-current-user 33 | def change_own_nickname(token, server_id, nick, reason = nil) 34 | Discordrb::API.request( 35 | :guilds_sid_members_me_nick, 36 | server_id, # This is technically a guild endpoint 37 | :patch, 38 | "#{Discordrb::API.api_base}/guilds/#{server_id}/members/@me/nick", 39 | { nick: nick }.to_json, 40 | Authorization: token, 41 | content_type: :json, 42 | 'X-Audit-Log-Reason': reason 43 | ) 44 | end 45 | 46 | # Update user data 47 | # https://discord.com/developers/docs/resources/user#modify-current-user 48 | def update_profile(token, email, password, new_username, avatar, new_password = nil) 49 | Discordrb::API.request( 50 | :users_me, 51 | nil, 52 | :patch, 53 | "#{Discordrb::API.api_base}/users/@me", 54 | { avatar: avatar, email: email, new_password: new_password, password: password, username: new_username }.to_json, 55 | Authorization: token, 56 | content_type: :json 57 | ) 58 | end 59 | 60 | # Get the servers a user is connected to 61 | # https://discord.com/developers/docs/resources/user#get-current-user-guilds 62 | def servers(token) 63 | Discordrb::API.request( 64 | :users_me_guilds, 65 | nil, 66 | :get, 67 | "#{Discordrb::API.api_base}/users/@me/guilds", 68 | Authorization: token 69 | ) 70 | end 71 | 72 | # Leave a server 73 | # https://discord.com/developers/docs/resources/user#leave-guild 74 | def leave_server(token, server_id) 75 | Discordrb::API.request( 76 | :users_me_guilds_sid, 77 | nil, 78 | :delete, 79 | "#{Discordrb::API.api_base}/users/@me/guilds/#{server_id}", 80 | Authorization: token 81 | ) 82 | end 83 | 84 | # Get the DMs for the current user 85 | # https://discord.com/developers/docs/resources/user#get-user-dms 86 | def user_dms(token) 87 | Discordrb::API.request( 88 | :users_me_channels, 89 | nil, 90 | :get, 91 | "#{Discordrb::API.api_base}/users/@me/channels", 92 | Authorization: token 93 | ) 94 | end 95 | 96 | # Create a DM to another user 97 | # https://discord.com/developers/docs/resources/user#create-dm 98 | def create_pm(token, recipient_id) 99 | Discordrb::API.request( 100 | :users_me_channels, 101 | nil, 102 | :post, 103 | "#{Discordrb::API.api_base}/users/@me/channels", 104 | { recipient_id: recipient_id }.to_json, 105 | Authorization: token, 106 | content_type: :json 107 | ) 108 | end 109 | 110 | # Get information about a user's connections 111 | # https://discord.com/developers/docs/resources/user#get-users-connections 112 | def connections(token) 113 | Discordrb::API.request( 114 | :users_me_connections, 115 | nil, 116 | :get, 117 | "#{Discordrb::API.api_base}/users/@me/connections", 118 | Authorization: token 119 | ) 120 | end 121 | 122 | # Change user status setting 123 | def change_status_setting(token, status) 124 | Discordrb::API.request( 125 | :users_me_settings, 126 | nil, 127 | :patch, 128 | "#{Discordrb::API.api_base}/users/@me/settings", 129 | { status: status }.to_json, 130 | Authorization: token, 131 | content_type: :json 132 | ) 133 | end 134 | 135 | # Returns one of the "default" discord avatars from the CDN given a discriminator 136 | def default_avatar(discrim = 0) 137 | index = discrim.to_i % 5 138 | "#{Discordrb::API.cdn_url}/embed/avatars/#{index}.png" 139 | end 140 | 141 | # Make an avatar URL from the user and avatar IDs 142 | def avatar_url(user_id, avatar_id, format = nil) 143 | format ||= if avatar_id.start_with?('a_') 144 | 'gif' 145 | else 146 | 'webp' 147 | end 148 | "#{Discordrb::API.cdn_url}/avatars/#{user_id}/#{avatar_id}.#{format}" 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /spec/rate_limiter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | 5 | # alias so I don't have to type it out every time... 6 | BUCKET = Discordrb::Commands::Bucket 7 | RATELIMITER = Discordrb::Commands::RateLimiter 8 | 9 | describe Discordrb::Commands::Bucket do 10 | describe 'rate_limited?' do 11 | it 'should not rate limit one request' do 12 | expect(BUCKET.new(1, 5, 2).rate_limited?(:a)).to be_falsy 13 | expect(BUCKET.new(nil, nil, 2).rate_limited?(:a)).to be_falsy 14 | expect(BUCKET.new(1, 5, nil).rate_limited?(:a)).to be_falsy 15 | expect(BUCKET.new(0, 1, nil).rate_limited?(:a)).to be_falsy 16 | expect(BUCKET.new(0, 1_000_000_000, 500_000_000).rate_limited?(:a)).to be_falsy 17 | end 18 | 19 | it 'should fail to initialize with invalid arguments' do 20 | expect { BUCKET.new(0, nil, 0) }.to raise_error(ArgumentError) 21 | end 22 | 23 | it 'should fail to rate limit something invalid' do 24 | expect { BUCKET.new(1, 5, 2).rate_limited?("can't RL a string!") }.to raise_error(ArgumentError) 25 | end 26 | 27 | it 'should rate limit one request over the limit' do 28 | b = BUCKET.new(1, 5, nil) 29 | expect(b.rate_limited?(:a)).to be_falsy 30 | expect(b.rate_limited?(:a)).to be_truthy 31 | end 32 | 33 | it 'should rate limit multiple requests that are over the limit' do 34 | b = BUCKET.new(3, 5, nil) 35 | expect(b.rate_limited?(:a)).to be_falsy 36 | expect(b.rate_limited?(:a)).to be_falsy 37 | expect(b.rate_limited?(:a)).to be_falsy 38 | expect(b.rate_limited?(:a)).to be_truthy 39 | end 40 | 41 | it 'should allow to be passed a custom increment' do 42 | b = BUCKET.new(5, 5, nil) 43 | expect(b.rate_limited?(:a, increment: 2)).to be_falsy 44 | expect(b.rate_limited?(:a, increment: 2)).to be_falsy 45 | expect(b.rate_limited?(:a, increment: 2)).to be_truthy 46 | end 47 | 48 | it 'should not rate limit after the limit ran out' do 49 | b = BUCKET.new(2, 5, nil) 50 | expect(b.rate_limited?(:a)).to be_falsy 51 | expect(b.rate_limited?(:a)).to be_falsy 52 | expect(b.rate_limited?(:a)).to be_truthy 53 | expect(b.rate_limited?(:a, Time.now + 4)).to be_truthy 54 | expect(b.rate_limited?(:a, Time.now + 5)).to be_falsy 55 | end 56 | 57 | it 'should reset the limit after it ran out' do 58 | b = BUCKET.new(2, 5, nil) 59 | expect(b.rate_limited?(:a)).to be_falsy 60 | expect(b.rate_limited?(:a)).to be_falsy 61 | expect(b.rate_limited?(:a)).to be_truthy 62 | expect(b.rate_limited?(:a, Time.now + 5)).to be_falsy 63 | expect(b.rate_limited?(:a, Time.now + 5.01)).to be_falsy 64 | expect(b.rate_limited?(:a, Time.now + 5.02)).to be_truthy 65 | end 66 | 67 | it 'should rate limit based on delay' do 68 | b = BUCKET.new(nil, nil, 2) 69 | expect(b.rate_limited?(:a)).to be_falsy 70 | expect(b.rate_limited?(:a)).to be_truthy 71 | end 72 | 73 | it 'should not rate limit after the delay ran out' do 74 | b = BUCKET.new(nil, nil, 2) 75 | expect(b.rate_limited?(:a)).to be_falsy 76 | expect(b.rate_limited?(:a)).to be_truthy 77 | expect(b.rate_limited?(:a, Time.now + 2)).to be_falsy 78 | expect(b.rate_limited?(:a, Time.now + 2)).to be_truthy 79 | expect(b.rate_limited?(:a, Time.now + 4)).to be_falsy 80 | expect(b.rate_limited?(:a, Time.now + 4)).to be_truthy 81 | end 82 | 83 | it 'should rate limit based on both limit and delay' do 84 | b = BUCKET.new(2, 5, 2) 85 | expect(b.rate_limited?(:a)).to be_falsy 86 | expect(b.rate_limited?(:a)).to be_truthy 87 | expect(b.rate_limited?(:a, Time.now + 2)).to be_falsy 88 | expect(b.rate_limited?(:a, Time.now + 2)).to be_truthy 89 | expect(b.rate_limited?(:a, Time.now + 4)).to be_truthy 90 | expect(b.rate_limited?(:a, Time.now + 5)).to be_falsy 91 | expect(b.rate_limited?(:a, Time.now + 6)).to be_truthy 92 | 93 | b = BUCKET.new(2, 5, 2) 94 | expect(b.rate_limited?(:a)).to be_falsy 95 | expect(b.rate_limited?(:a)).to be_truthy 96 | expect(b.rate_limited?(:a, Time.now + 4)).to be_falsy 97 | expect(b.rate_limited?(:a, Time.now + 4)).to be_truthy 98 | expect(b.rate_limited?(:a, Time.now + 5)).to be_truthy 99 | end 100 | 101 | it 'should return correct times' do 102 | start_time = Time.now 103 | b = BUCKET.new(2, 5, 2) 104 | expect(b.rate_limited?(:a, start_time)).to be_falsy 105 | expect(b.rate_limited?(:a, start_time).round(2)).to eq(2) 106 | expect(b.rate_limited?(:a, start_time + 1).round(2)).to eq(1) 107 | expect(b.rate_limited?(:a, start_time + 2.01)).to be_falsy 108 | expect(b.rate_limited?(:a, start_time + 2).round(2)).to eq(3) 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/discordrb/data/invite.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discordrb 4 | # A channel referenced by an invite. It has less data than regular channels, so it's a separate class 5 | class InviteChannel 6 | include IDObject 7 | 8 | # @return [String] this channel's name. 9 | attr_reader :name 10 | 11 | # @return [Integer] this channel's type (0: text, 1: private, 2: voice, 3: group). 12 | attr_reader :type 13 | 14 | # @!visibility private 15 | def initialize(data, bot) 16 | @bot = bot 17 | 18 | @id = data['id'].to_i 19 | @name = data['name'] 20 | @type = data['type'] 21 | end 22 | end 23 | 24 | # A server referenced to by an invite 25 | class InviteServer 26 | include IDObject 27 | 28 | # @return [String] this server's name. 29 | attr_reader :name 30 | 31 | # @return [String, nil] the hash of the server's invite splash screen (for partnered servers) or nil if none is 32 | # present 33 | attr_reader :splash_hash 34 | 35 | # @!visibility private 36 | def initialize(data, bot) 37 | @bot = bot 38 | 39 | @id = data['id'].to_i 40 | @name = data['name'] 41 | @splash_hash = data['splash_hash'] 42 | end 43 | end 44 | 45 | # A Discord invite to a channel 46 | class Invite 47 | # @return [InviteChannel, Channel] the channel this invite references. 48 | attr_reader :channel 49 | 50 | # @return [InviteServer, Server] the server this invite references. 51 | attr_reader :server 52 | 53 | # @return [Integer] the amount of uses left on this invite. 54 | attr_reader :uses 55 | alias_method :max_uses, :uses 56 | 57 | # @return [User, nil] the user that made this invite. May also be nil if the user can't be determined. 58 | attr_reader :inviter 59 | alias_method :user, :inviter 60 | 61 | # @return [true, false] whether or not this invite grants temporary membership. If someone joins a server with this invite, they will be removed from the server when they go offline unless they've received a role. 62 | attr_reader :temporary 63 | alias_method :temporary?, :temporary 64 | 65 | # @return [true, false] whether this invite is still valid. 66 | attr_reader :revoked 67 | alias_method :revoked?, :revoked 68 | 69 | # @return [String] this invite's code 70 | attr_reader :code 71 | 72 | # @return [Integer, nil] the amount of members in the server. Will be nil if it has not been resolved. 73 | attr_reader :member_count 74 | alias_method :user_count, :member_count 75 | 76 | # @return [Integer, nil] the amount of online members in the server. Will be nil if it has not been resolved. 77 | attr_reader :online_member_count 78 | alias_method :online_user_count, :online_member_count 79 | 80 | # @return [Integer, nil] the invites max age before it expires, or nil if it's unknown. If the max age is 0, the invite will never expire unless it's deleted. 81 | attr_reader :max_age 82 | 83 | # @return [Time, nil] when this invite was created, or nil if it's unknown 84 | attr_reader :created_at 85 | 86 | # @!visibility private 87 | def initialize(data, bot) 88 | @bot = bot 89 | 90 | @channel = if data['channel_id'] 91 | bot.channel(data['channel_id']) 92 | else 93 | InviteChannel.new(data['channel'], bot) 94 | end 95 | 96 | @server = if data['guild_id'] 97 | bot.server(data['guild_id']) 98 | else 99 | InviteServer.new(data['guild'], bot) 100 | end 101 | 102 | @uses = data['uses'] 103 | @inviter = data['inviter'] ? (@bot.user(data['inviter']['id'].to_i) || User.new(data['inviter'], bot)) : nil 104 | @temporary = data['temporary'] 105 | @revoked = data['revoked'] 106 | @online_member_count = data['approximate_presence_count'] 107 | @member_count = data['approximate_member_count'] 108 | @max_age = data['max_age'] 109 | @created_at = data['created_at'] 110 | 111 | @code = data['code'] 112 | end 113 | 114 | # Code based comparison 115 | def ==(other) 116 | other.respond_to?(:code) ? (@code == other.code) : (@code == other) 117 | end 118 | 119 | # Deletes this invite 120 | # @param reason [String] The reason the invite is being deleted. 121 | def delete(reason = nil) 122 | API::Invite.delete(@bot.token, @code, reason) 123 | end 124 | 125 | alias_method :revoke, :delete 126 | 127 | # The inspect method is overwritten to give more useful output 128 | def inspect 129 | "" 130 | end 131 | 132 | # Creates an invite URL. 133 | def url 134 | "https://discord.gg/#{@code}" 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/discordrb/events/reactions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/generic' 4 | require 'discordrb/data' 5 | 6 | module Discordrb::Events 7 | # Generic superclass for events about adding and removing reactions 8 | class ReactionEvent < Event 9 | include Respondable 10 | 11 | # @return [Emoji] the emoji that was reacted with. 12 | attr_reader :emoji 13 | 14 | # @!visibility private 15 | attr_reader :message_id 16 | 17 | def initialize(data, bot) 18 | @bot = bot 19 | 20 | @emoji = Discordrb::Emoji.new(data['emoji'], bot, nil) 21 | @user_id = data['user_id'].to_i 22 | @message_id = data['message_id'].to_i 23 | @channel_id = data['channel_id'].to_i 24 | end 25 | 26 | # @return [User, Member] the user that reacted to this message, or member if a server exists. 27 | def user 28 | # Cache the user so we don't do requests all the time 29 | @user ||= if server 30 | @server.member(@user_id) 31 | else 32 | @bot.user(@user_id) 33 | end 34 | end 35 | 36 | # @return [Message] the message that was reacted to. 37 | def message 38 | @message ||= channel.load_message(@message_id) 39 | end 40 | 41 | # @return [Channel] the channel that was reacted in. 42 | def channel 43 | @channel ||= @bot.channel(@channel_id) 44 | end 45 | 46 | # @return [Server, nil] the server that was reacted in. If reacted in a PM channel, it will be nil. 47 | def server 48 | @server ||= channel.server 49 | end 50 | end 51 | 52 | # Generic superclass for event handlers pertaining to adding and removing reactions 53 | class ReactionEventHandler < EventHandler 54 | def matches?(event) 55 | # Check for the proper event type 56 | return false unless event.is_a? ReactionEvent 57 | 58 | [ 59 | matches_all(@attributes[:emoji], event.emoji) do |a, e| 60 | case a 61 | when Integer 62 | e.id == a 63 | when String 64 | e.name == a || e.name == a.delete(':') || e.id == a.resolve_id 65 | else 66 | e == a 67 | end 68 | end, 69 | matches_all(@attributes[:message], event.message_id) do |a, e| 70 | a == e 71 | end, 72 | matches_all(@attributes[:in], event.channel) do |a, e| 73 | case a 74 | when String 75 | # Make sure to remove the "#" from channel names in case it was specified 76 | a.delete('#') == e.name 77 | when Integer 78 | a == e.id 79 | else 80 | a == e 81 | end 82 | end, 83 | matches_all(@attributes[:from], event.user) do |a, e| 84 | case a 85 | when String 86 | a == e.name 87 | when :bot 88 | e.current_bot? 89 | else 90 | a == e 91 | end 92 | end 93 | ].reduce(true, &:&) 94 | end 95 | end 96 | 97 | # Event raised when somebody reacts to a message 98 | class ReactionAddEvent < ReactionEvent; end 99 | 100 | # Event handler for {ReactionAddEvent} 101 | class ReactionAddEventHandler < ReactionEventHandler; end 102 | 103 | # Event raised when somebody removes a reaction to a message 104 | class ReactionRemoveEvent < ReactionEvent; end 105 | 106 | # Event handler for {ReactionRemoveEvent} 107 | class ReactionRemoveEventHandler < ReactionEventHandler; end 108 | 109 | # Event raised when somebody removes all reactions from a message 110 | class ReactionRemoveAllEvent < Event 111 | include Respondable 112 | 113 | # @!visibility private 114 | attr_reader :message_id 115 | 116 | def initialize(data, bot) 117 | @bot = bot 118 | 119 | @message_id = data['message_id'].to_i 120 | @channel_id = data['channel_id'].to_i 121 | end 122 | 123 | # @return [Channel] the channel where the removal occurred. 124 | def channel 125 | @channel ||= @bot.channel(@channel_id) 126 | end 127 | 128 | # @return [Message] the message all reactions were removed from. 129 | def message 130 | @message ||= channel.load_message(@message_id) 131 | end 132 | end 133 | 134 | # Event handler for {ReactionRemoveAllEvent} 135 | class ReactionRemoveAllEventHandler < EventHandler 136 | def matches?(event) 137 | # Check for the proper event type 138 | return false unless event.is_a? ReactionRemoveAllEvent 139 | 140 | [ 141 | matches_all(@attributes[:message], event.message_id) do |a, e| 142 | a == e 143 | end, 144 | matches_all(@attributes[:in], event.channel) do |a, e| 145 | case a 146 | when String 147 | # Make sure to remove the "#" from channel names in case it was specified 148 | a.delete('#') == e.name 149 | when Integer 150 | a == e.id 151 | else 152 | a == e 153 | end 154 | end 155 | ].reduce(true, &:&) 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/discordrb/api/webhook.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # API calls for Webhook object 4 | module Discordrb::API::Webhook 5 | module_function 6 | 7 | # Get a webhook 8 | # https://discord.com/developers/docs/resources/webhook#get-webhook 9 | def webhook(token, webhook_id) 10 | Discordrb::API.request( 11 | :webhooks_wid, 12 | nil, 13 | :get, 14 | "#{Discordrb::API.api_base}/webhooks/#{webhook_id}", 15 | Authorization: token 16 | ) 17 | end 18 | 19 | # Get a webhook via webhook token 20 | # https://discord.com/developers/docs/resources/webhook#get-webhook-with-token 21 | def token_webhook(webhook_token, webhook_id) 22 | Discordrb::API.request( 23 | :webhooks_wid, 24 | nil, 25 | :get, 26 | "#{Discordrb::API.api_base}/webhooks/#{webhook_id}/#{webhook_token}" 27 | ) 28 | end 29 | 30 | # Execute a webhook via token. 31 | # https://discord.com/developers/docs/resources/webhook#execute-webhook 32 | def token_execute_webhook(webhook_token, webhook_id, wait = false, content = nil, username = nil, avatar_url = nil, tts = nil, file = nil, embeds = nil, allowed_mentions = nil, flags = nil, components = nil) 33 | body = { content: content, username: username, avatar_url: avatar_url, tts: tts, embeds: embeds&.map(&:to_hash), allowed_mentions: allowed_mentions, flags: flags, components: components } 34 | body = if file 35 | { file: file, payload_json: body.to_json } 36 | else 37 | body.to_json 38 | end 39 | 40 | headers = { content_type: :json } unless file 41 | 42 | Discordrb::API.request( 43 | :webhooks_wid, 44 | webhook_id, 45 | :post, 46 | "#{Discordrb::API.api_base}/webhooks/#{webhook_id}/#{webhook_token}?wait=#{wait}", 47 | body, 48 | headers 49 | ) 50 | end 51 | 52 | # Update a webhook 53 | # https://discord.com/developers/docs/resources/webhook#modify-webhook 54 | def update_webhook(token, webhook_id, data, reason = nil) 55 | Discordrb::API.request( 56 | :webhooks_wid, 57 | webhook_id, 58 | :patch, 59 | "#{Discordrb::API.api_base}/webhooks/#{webhook_id}", 60 | data.to_json, 61 | Authorization: token, 62 | content_type: :json, 63 | 'X-Audit-Log-Reason': reason 64 | ) 65 | end 66 | 67 | # Update a webhook via webhook token 68 | # https://discord.com/developers/docs/resources/webhook#modify-webhook-with-token 69 | def token_update_webhook(webhook_token, webhook_id, data, reason = nil) 70 | Discordrb::API.request( 71 | :webhooks_wid, 72 | webhook_id, 73 | :patch, 74 | "#{Discordrb::API.api_base}/webhooks/#{webhook_id}/#{webhook_token}", 75 | data.to_json, 76 | content_type: :json, 77 | 'X-Audit-Log-Reason': reason 78 | ) 79 | end 80 | 81 | # Deletes a webhook 82 | # https://discord.com/developers/docs/resources/webhook#delete-webhook 83 | def delete_webhook(token, webhook_id, reason = nil) 84 | Discordrb::API.request( 85 | :webhooks_wid, 86 | webhook_id, 87 | :delete, 88 | "#{Discordrb::API.api_base}/webhooks/#{webhook_id}", 89 | Authorization: token, 90 | 'X-Audit-Log-Reason': reason 91 | ) 92 | end 93 | 94 | # Deletes a webhook via webhook token 95 | # https://discord.com/developers/docs/resources/webhook#delete-webhook-with-token 96 | def token_delete_webhook(webhook_token, webhook_id, reason = nil) 97 | Discordrb::API.request( 98 | :webhooks_wid, 99 | webhook_id, 100 | :delete, 101 | "#{Discordrb::API.api_base}/webhooks/#{webhook_id}/#{webhook_token}", 102 | 'X-Audit-Log-Reason': reason 103 | ) 104 | end 105 | 106 | # Get a message that was created by the webhook corresponding to the provided token. 107 | # https://discord.com/developers/docs/resources/webhook#get-webhook-message 108 | def token_get_message(webhook_token, webhook_id, message_id) 109 | Discordrb::API.request( 110 | :webhooks_wid_messages_mid, 111 | webhook_id, 112 | :get, 113 | "#{Discordrb::API.api_base}/webhooks/#{webhook_id}/#{webhook_token}/messages/#{message_id}" 114 | ) 115 | end 116 | 117 | # Edit a webhook message via webhook token 118 | # https://discord.com/developers/docs/resources/webhook#edit-webhook-message 119 | def token_edit_message(webhook_token, webhook_id, message_id, content = nil, embeds = nil, allowed_mentions = nil, components = nil) 120 | Discordrb::API.request( 121 | :webhooks_wid_messages, 122 | webhook_id, 123 | :patch, 124 | "#{Discordrb::API.api_base}/webhooks/#{webhook_id}/#{webhook_token}/messages/#{message_id}", 125 | { content: content, embeds: embeds, allowed_mentions: allowed_mentions, components: components }.to_json, 126 | content_type: :json 127 | ) 128 | end 129 | 130 | # Delete a webhook message via webhook token. 131 | # https://discord.com/developers/docs/resources/webhook#delete-webhook-message 132 | def token_delete_message(webhook_token, webhook_id, message_id) 133 | Discordrb::API.request( 134 | :webhooks_wid_messages, 135 | webhook_id, 136 | :delete, 137 | "#{Discordrb::API.api_base}/webhooks/#{webhook_id}/#{webhook_token}/messages/#{message_id}" 138 | ) 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/discordrb/events/generic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Events used by discordrb 4 | module Discordrb::Events 5 | # A negated object, used to not match something in event parameters. 6 | # @see Discordrb::Events.matches_all 7 | class Negated 8 | attr_reader :object 9 | 10 | def initialize(object) 11 | @object = object 12 | end 13 | end 14 | 15 | # Attempts to match possible formats of event attributes to a set comparison value, using a comparison block. 16 | # It allows five kinds of attribute formats: 17 | # 0. nil -> always returns true 18 | # 1. A single attribute, not negated 19 | # 2. A single attribute, negated 20 | # 3. An array of attributes, not negated 21 | # 4. An array of attributes, not negated 22 | # Note that it doesn't allow an array of negated attributes. For info on negation stuff, see {::#not!} 23 | # @param attributes [Object, Array, Negated, Negated>, nil] One or more attributes to 24 | # compare to the to_check value. 25 | # @param to_check [Object] What to compare the attributes to. 26 | # @yield [a, e] The block will be called when a comparison happens. 27 | # @yieldparam [Object] a The attribute to compare to the value to check. Will always be a single not-negated object, 28 | # all the negation and array handling is done by the method 29 | # @yieldparam [Object] e The value to compare the attribute to. Will always be the value passed to the function as 30 | # to_check. 31 | # @yieldreturn [true, false] Whether or not the attribute a matches the given comparison value e. 32 | # @return [true, false] whether the attributes match the comparison value in at least one way. 33 | def self.matches_all(attributes, to_check, &block) 34 | # "Zeroth" case: attributes is nil 35 | return true if attributes.nil? 36 | 37 | # First case: there's a single negated attribute 38 | if attributes.is_a? Negated 39 | # The contained object might also be an array, so recursively call matches_all (and negate the result) 40 | return !matches_all(attributes.object, to_check, &block) 41 | end 42 | 43 | # Second case: there's a single, not-negated attribute 44 | return yield(attributes, to_check) unless attributes.is_a? Array 45 | 46 | # Third case: it's an array of attributes 47 | attributes.reduce(false) do |result, element| 48 | result || yield(element, to_check) 49 | end 50 | end 51 | 52 | # Generic event class that can be extended 53 | class Event 54 | # @return [Bot] the bot used to initialize this event. 55 | attr_reader :bot 56 | 57 | class << self 58 | protected 59 | 60 | # Delegates a list of methods to a particular object. This is essentially a reimplementation of ActiveSupport's 61 | # `#delegate`, but without the overhead provided by the rest. Used in subclasses of `Event` to delegate properties 62 | # on events to properties on data objects. 63 | # @param methods [Array] The methods to delegate. 64 | # @param hash [Hash Symbol>] A hash with one `:to` key and the value the method to be delegated to. 65 | def delegate(*methods, hash) 66 | methods.each do |e| 67 | define_method(e) do 68 | object = __send__(hash[:to]) 69 | object.__send__(e) 70 | end 71 | end 72 | end 73 | end 74 | end 75 | 76 | # Generic event handler that can be extended 77 | class EventHandler 78 | def initialize(attributes, block) 79 | @attributes = attributes 80 | @block = block 81 | end 82 | 83 | # Whether or not this event handler matches the given event with its attributes. 84 | # @raise [RuntimeError] if this method is called - overwrite it in your event handler! 85 | def matches?(_) 86 | raise 'Attempted to call matches?() from a generic EventHandler' 87 | end 88 | 89 | # Checks whether this handler matches the given event, and then calls it. 90 | # @param event [Object] The event object to match and call the handler with 91 | def match(event) 92 | call(event) if matches? event 93 | end 94 | 95 | # Calls this handler 96 | # @param event [Object] The event object to call this handler with 97 | def call(event) 98 | @block.call(event) 99 | end 100 | 101 | # to be overwritten by extending event handlers 102 | def after_call(event); end 103 | 104 | # @see Discordrb::Events::matches_all 105 | def matches_all(attributes, to_check, &block) 106 | Discordrb::Events.matches_all(attributes, to_check, &block) 107 | end 108 | end 109 | 110 | # Event handler that matches all events. Only useful for making an event that has no attributes, such as {ReadyEvent}. 111 | class TrueEventHandler < EventHandler 112 | # Always returns true. 113 | # @return [true] 114 | def matches?(_) 115 | true 116 | end 117 | end 118 | end 119 | 120 | # Utility function that creates a negated object for {Discordrb::Events.matches_all} 121 | # @param [Object] object The object to negate 122 | # @see Discordrb::Events::Negated 123 | # @see Discordrb::Events.matches_all 124 | # @return [Negated] the object, negated, as an attribute to pass to matches_all 125 | def not!(object) 126 | Discordrb::Events::Negated.new(object) 127 | end 128 | -------------------------------------------------------------------------------- /spec/permissions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb' 4 | 5 | describe Discordrb::Permissions do 6 | subject { Discordrb::Permissions.new } 7 | 8 | describe Discordrb::Permissions::FLAGS do 9 | it 'creates a setter for each flag' do 10 | responds_to_methods = Discordrb::Permissions::FLAGS.map do |_, flag| 11 | subject.respond_to?(:"can_#{flag}=") 12 | end 13 | 14 | expect(responds_to_methods.all?).to eq true 15 | end 16 | 17 | it 'calls #write on its writer' do 18 | writer = double 19 | expect(writer).to receive(:write) 20 | 21 | Discordrb::Permissions.new(0, writer).can_read_messages = true 22 | end 23 | end 24 | 25 | context 'with FLAGS stubbed' do 26 | before do 27 | stub_const('Discordrb::Permissions::FLAGS', 0 => :foo, 1 => :bar) 28 | end 29 | 30 | describe '#init_vars' do 31 | it 'sets an attribute for each flag' do 32 | expect( 33 | [ 34 | subject.instance_variable_get('@foo'), 35 | subject.instance_variable_get('@bar') 36 | ] 37 | ).to eq [false, false] 38 | end 39 | end 40 | 41 | describe '.bits' do 42 | it 'returns the correct packed bits from an array of symbols' do 43 | expect(Discordrb::Permissions.bits(%i[foo bar])).to eq 3 44 | end 45 | end 46 | 47 | describe '#bits=' do 48 | it 'updates the cached value' do 49 | allow(subject).to receive(:init_vars) 50 | subject.bits = 1 51 | expect(subject.bits).to eq(1) 52 | end 53 | 54 | it 'calls #init_vars' do 55 | expect(subject).to receive(:init_vars) 56 | subject.bits = 0 57 | end 58 | end 59 | 60 | describe '#initialize' do 61 | it 'initializes with 0 bits' do 62 | expect(subject.bits).to eq 0 63 | end 64 | 65 | it 'can initialize with an array of symbols' do 66 | instance = Discordrb::Permissions.new %i[foo bar] 67 | expect(instance.bits).to eq 3 68 | end 69 | 70 | it 'calls #init_vars' do 71 | expect_any_instance_of(Discordrb::Permissions).to receive(:init_vars) 72 | subject 73 | end 74 | end 75 | 76 | describe '#defined_permissions' do 77 | it 'returns the defined permissions' do 78 | instance = Discordrb::Permissions.new 3 79 | expect(instance.defined_permissions).to eq %i[foo bar] 80 | end 81 | end 82 | end 83 | end 84 | 85 | class ExampleCalculator 86 | include Discordrb::PermissionCalculator 87 | attr_accessor :server, :roles 88 | end 89 | 90 | describe Discordrb::PermissionCalculator do 91 | subject { ExampleCalculator.new } 92 | 93 | describe '#defined_role_permission?' do 94 | it 'solves permissions (issue #607)' do 95 | everyone_role = double('everyone role', id: 0, position: 0, permissions: Discordrb::Permissions.new) 96 | role_a = double('role a', id: 1, position: 1, permissions: Discordrb::Permissions.new) 97 | role_b = double('role b', id: 2, position: 2, permissions: Discordrb::Permissions.new([:manage_messages])) 98 | 99 | channel = double('channel') 100 | allow(subject).to receive(:permission_overwrite) 101 | .with(:manage_messages, channel, everyone_role.id) 102 | .and_return(false) 103 | 104 | allow(subject).to receive(:permission_overwrite) 105 | .with(:manage_messages, channel, role_a.id) 106 | .and_return(true) 107 | 108 | allow(subject).to receive(:permission_overwrite) 109 | .with(:manage_messages, channel, role_b.id) 110 | .and_return(false) 111 | 112 | subject.server = double('server', everyone_role: everyone_role) 113 | subject.roles = [role_a, role_b] 114 | permission = subject.__send__(:defined_role_permission?, :manage_messages, channel) 115 | expect(permission).to eq true 116 | 117 | subject.roles = [role_b, role_a] 118 | permission = subject.__send__(:defined_role_permission?, :manage_messages, channel) 119 | expect(permission).to eq true 120 | end 121 | 122 | it 'takes overwrites into account' do 123 | everyone_role = double('everyone role', id: 0, position: 0, permissions: Discordrb::Permissions.new) 124 | role_a = double('role a', id: 1, position: 1, permissions: Discordrb::Permissions.new([:manage_messages])) 125 | role_b = double('role b', id: 2, position: 2, permissions: Discordrb::Permissions.new) 126 | channel = double('channel') 127 | 128 | subject.server = double('server', everyone_role: everyone_role) 129 | subject.roles = [role_a, role_b] 130 | 131 | allow(subject).to receive(:permission_overwrite).and_return(nil) 132 | 133 | allow(subject).to receive(:permission_overwrite) 134 | .with(:manage_messages, channel, role_a.id) 135 | .and_return(:deny) 136 | 137 | allow(subject).to receive(:permission_overwrite) 138 | .with(:manage_messages, channel, role_b.id) 139 | .and_return(:allow) 140 | 141 | subject.roles = [role_a] 142 | expect(subject.__send__(:defined_role_permission?, :manage_messages, channel)).to be false 143 | 144 | subject.roles = [role_a, role_b] 145 | expect(subject.__send__(:defined_role_permission?, :manage_messages, channel)).to be true 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /lib/discordrb/webhooks/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rest-client' 4 | require 'json' 5 | 6 | require 'discordrb/webhooks/builder' 7 | 8 | module Discordrb::Webhooks 9 | # A client for a particular webhook added to a Discord channel. 10 | class Client 11 | # Create a new webhook 12 | # @param url [String] The URL to post messages to. 13 | # @param id [Integer] The webhook's ID. Will only be used if `url` is not 14 | # set. 15 | # @param token [String] The webhook's authorisation token. Will only be used 16 | # if `url` is not set. 17 | def initialize(url: nil, id: nil, token: nil) 18 | @url = url || generate_url(id, token) 19 | end 20 | 21 | # Executes the webhook this client points to with the given data. 22 | # @param builder [Builder, nil] The builder to start out with, or nil if one should be created anew. 23 | # @param wait [true, false] Whether Discord should wait for the message to be successfully received by clients, or 24 | # whether it should return immediately after sending the message. 25 | # @yield [builder] Gives the builder to the block to add additional steps, or to do the entire building process. 26 | # @yieldparam builder [Builder] The builder given as a parameter which is used as the initial step to start from. 27 | # @example Execute the webhook with an already existing builder 28 | # builder = Discordrb::Webhooks::Builder.new # ... 29 | # client.execute(builder) 30 | # @example Execute the webhook by building a new message 31 | # client.execute do |builder| 32 | # builder.content = 'Testing' 33 | # builder.username = 'discordrb' 34 | # builder.add_embed do |embed| 35 | # embed.timestamp = Time.now 36 | # embed.title = 'Testing' 37 | # embed.image = Discordrb::Webhooks::EmbedImage.new(url: 'https://i.imgur.com/PcMltU7.jpg') 38 | # end 39 | # end 40 | # @return [RestClient::Response] the response returned by Discord. 41 | def execute(builder = nil, wait = false) 42 | raise TypeError, 'builder needs to be nil or like a Discordrb::Webhooks::Builder!' unless 43 | (builder.respond_to?(:file) && builder.respond_to?(:to_multipart_hash)) || builder.respond_to?(:to_json_hash) || builder.nil? 44 | 45 | builder ||= Builder.new 46 | 47 | yield builder if block_given? 48 | 49 | if builder.file 50 | post_multipart(builder, wait) 51 | else 52 | post_json(builder, wait) 53 | end 54 | end 55 | 56 | # Modify this webhook's properties. 57 | # @param name [String, nil] The default name. 58 | # @param avatar [String, #read, nil] The new avatar, in base64-encoded JPG format. 59 | # @param channel_id [String, Integer, nil] The channel to move the webhook to. 60 | # @return [RestClient::Response] the response returned by Discord. 61 | def modify(name: nil, avatar: nil, channel_id: nil) 62 | RestClient.patch(@url, { name: name, avatar: avatarise(avatar), channel_id: channel_id }.compact.to_json, content_type: :json) 63 | end 64 | 65 | # Delete this webhook. 66 | # @param reason [String, nil] The reason this webhook was deleted. 67 | # @return [RestClient::Response] the response returned by Discord. 68 | # @note This is permanent and cannot be undone. 69 | def delete(reason: nil) 70 | RestClient.delete(@url, 'X-Audit-Log-Reason': reason) 71 | end 72 | 73 | # Edit a message from this webhook. 74 | # @param message_id [String, Integer] The ID of the message to edit. 75 | # @param builder [Builder, nil] The builder to start out with, or nil if one should be created anew. 76 | # @param content [String] The message content. 77 | # @param embeds [Array] 78 | # @param allowed_mentions [Hash] 79 | # @return [RestClient::Response] the response returned by Discord. 80 | # @example Edit message content 81 | # client.edit_message(message_id, content: 'goodbye world!') 82 | # @example Edit a message via builder 83 | # client.edit_message(message_id) do |builder| 84 | # builder.add_embed do |e| 85 | # e.description = 'Hello World!' 86 | # end 87 | # end 88 | # @note Not all builder options are available when editing. 89 | def edit_message(message_id, builder: nil, content: nil, embeds: nil, allowed_mentions: nil) 90 | builder ||= Builder.new 91 | 92 | yield builder if block_given? 93 | 94 | data = builder.to_json_hash.merge({ content: content, embeds: embeds, allowed_mentions: allowed_mentions }.compact) 95 | RestClient.patch("#{@url}/messages/#{message_id}", data.compact.to_json, content_type: :json) 96 | end 97 | 98 | # Delete a message created by this webhook. 99 | # @param message_id [String, Integer] The ID of the message to delete. 100 | # @return [RestClient::Response] the response returned by Discord. 101 | def delete_message(message_id) 102 | RestClient.delete("#{@url}/messages/#{message_id}") 103 | end 104 | 105 | private 106 | 107 | # Convert an avatar to API ready data. 108 | # @param avatar [String, #read] Avatar data. 109 | def avatarise(avatar) 110 | if avatar.respond_to? :read 111 | "data:image/jpg;base64,#{Base64.strict_encode64(avatar.read)}" 112 | else 113 | avatar 114 | end 115 | end 116 | 117 | def post_json(builder, wait) 118 | RestClient.post(@url + (wait ? '?wait=true' : ''), builder.to_json_hash.to_json, content_type: :json) 119 | end 120 | 121 | def post_multipart(builder, wait) 122 | RestClient.post(@url + (wait ? '?wait=true' : ''), builder.to_multipart_hash) 123 | end 124 | 125 | def generate_url(id, token) 126 | "https://discord.com/api/v8/webhooks/#{id}/#{token}" 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec-prof' 4 | require 'simplecov' 5 | SimpleCov.start 6 | 7 | require 'json' 8 | 9 | # This file was generated by the `rspec --init` command. Conventionally, all 10 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 11 | # The generated `.rspec` file contains `--require spec_helper` which will cause 12 | # this file to always be loaded, without a need to explicitly require it in any 13 | # files. 14 | # 15 | # Given that it is always loaded, you are encouraged to keep this file as 16 | # light-weight as possible. Requiring heavyweight dependencies from this file 17 | # will add to the boot time of your test suite on EVERY test run, even for an 18 | # individual file that may not need all of that loaded. Instead, consider making 19 | # a separate helper file that requires the additional dependencies and performs 20 | # the additional setup, and require it from the spec files that actually need 21 | # it. 22 | # 23 | # The `.rspec` file also contains a few flags that are not defaults but that 24 | # users commonly want. 25 | # 26 | # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 27 | RSpec.configure do |config| 28 | # rspec-expectations config goes here. You can use an alternate 29 | # assertion/expectation library such as wrong or the stdlib/minitest 30 | # assertions if you prefer. 31 | config.expect_with :rspec do |expectations| 32 | # This option will default to `true` in RSpec 4. It makes the `description` 33 | # and `failure_message` of custom matchers include text for helper methods 34 | # defined using `chain`, e.g.: 35 | # be_bigger_than(2).and_smaller_than(4).description 36 | # # => "be bigger than 2 and smaller than 4" 37 | # ...rather than: 38 | # # => "be bigger than 2" 39 | expectations.syntax = :expect 40 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 41 | end 42 | 43 | # rspec-mocks config goes here. You can use an alternate test double 44 | # library (such as bogus or mocha) by changing the `mock_with` option here. 45 | config.mock_with :rspec do |mocks| 46 | # Prevents you from mocking or stubbing a method that does not exist on 47 | # a real object. This is generally recommended, and will default to 48 | # `true` in RSpec 4. 49 | mocks.verify_partial_doubles = true 50 | end 51 | 52 | # This setting enables warnings. It's recommended, but in some cases may 53 | # be too noisy due to issues in dependencies. 54 | config.warnings = false 55 | 56 | # Print the 10 slowest examples and example groups at the 57 | # end of the spec run, to help surface which specs are running 58 | # particularly slow. 59 | config.profile_examples = 10 60 | 61 | # Run specs in random order to surface order dependencies. If you find an 62 | # order dependency and want to debug it, you can fix the order by providing 63 | # the seed, which is printed after each run. 64 | # --seed 1234 65 | config.order = :random 66 | 67 | # Seed global randomization in this process using the `--seed` CLI option. 68 | # Setting this allows you to use `--seed` to deterministically reproduce 69 | # test failures related to randomization by passing the same `--seed` value 70 | # as the one that triggered the failure. 71 | Kernel.srand config.seed 72 | 73 | config.filter_run_when_matching focus: true 74 | end 75 | 76 | # Keeps track of what events are run; if one isn't run it will fail 77 | class EventTracker 78 | def initialize(messages) 79 | @messages = messages 80 | @tracker = [nil] * @messages.length 81 | end 82 | 83 | def track(num) 84 | @tracker[num - 1] = true 85 | end 86 | 87 | def summary 88 | @tracker.each_with_index do |tracked, num| 89 | raise "Not executed: #{@messages[num]}" unless tracked 90 | end 91 | end 92 | end 93 | 94 | def track(*messages) 95 | EventTracker.new(messages) 96 | end 97 | 98 | RSpec::Matchers.define :something_including do |x| 99 | match { |actual| actual.include? x } 100 | end 101 | 102 | RSpec::Matchers.define :something_not_including do |x| 103 | match { |actual| !actual.include?(x) } 104 | end 105 | 106 | RSpec::Matchers.define_negated_matcher :an_array_excluding, :include 107 | 108 | def load_data_file(*name) 109 | JSON.parse(File.read("#{File.dirname(__FILE__)}/json_examples/#{name.join('/')}.json")) 110 | end 111 | 112 | # Creates a helper method that gives access to a particular fixture's data. 113 | # @example Load the JSON file at "spec/data/folder/filename.json" as a "data_name" helper method 114 | # fixture :data_name, [:folder, :filename] 115 | # @param name [Symbol] The name the helper method should have 116 | # @param path [Array] The path to the data file to load, originating from "spec/data" 117 | def fixture(name, path) 118 | let name do 119 | load_data_file(*path) 120 | end 121 | end 122 | 123 | # Creates a helper method that gives access to a specific property on a particular fixture. 124 | # @example Add a helper method called "property_value" for `data_name['properties'][0]['value'].to_i` 125 | # fixture_property :property_value, :data_name, ['properties', 0, 'value'], :to_i 126 | # @param name [Symbol] The name the helper method should have 127 | # @param fixture [Symbol] The name of the fixture the property is on 128 | # @param trace [Array] The objects to consecutively pass to the #[] method when accessing the data. 129 | # @param filter [Symbol, nil] An optional method to call on the result, in case some conversion is necessary. 130 | def fixture_property(name, fixture, trace, filter = nil) 131 | let name do 132 | data = send(fixture) 133 | 134 | trace.each do |e| 135 | data = data[e] 136 | end 137 | 138 | if filter 139 | data.send(filter) 140 | else 141 | data 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/discordrb/events/guilds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'discordrb/events/generic' 4 | require 'discordrb/data' 5 | 6 | module Discordrb::Events 7 | # Generic subclass for server events (create/update/delete) 8 | class ServerEvent < Event 9 | # @return [Server] the server in question. 10 | attr_reader :server 11 | 12 | def initialize(data, bot) 13 | @bot = bot 14 | 15 | init_server(data, bot) 16 | end 17 | 18 | # Initializes this event with server data. Should be overwritten in case the server doesn't exist at the time 19 | # of event creation (e. g. {ServerDeleteEvent}) 20 | def init_server(data, bot) 21 | @server = bot.server(data['id'].to_i) 22 | end 23 | end 24 | 25 | # Generic event handler for member events 26 | class ServerEventHandler < EventHandler 27 | def matches?(event) 28 | # Check for the proper event type 29 | return false unless event.is_a? ServerEvent 30 | 31 | [ 32 | matches_all(@attributes[:server], event.server) do |a, e| 33 | a == case a 34 | when String 35 | e.name 36 | when Integer 37 | e.id 38 | else 39 | e 40 | end 41 | end 42 | ].reduce(true, &:&) 43 | end 44 | end 45 | 46 | # Server is created 47 | # @see Discordrb::EventContainer#server_create 48 | class ServerCreateEvent < ServerEvent; end 49 | 50 | # Event handler for {ServerCreateEvent} 51 | class ServerCreateEventHandler < ServerEventHandler; end 52 | 53 | # Server is updated (e.g. name changed) 54 | # @see Discordrb::EventContainer#server_update 55 | class ServerUpdateEvent < ServerEvent; end 56 | 57 | # Event handler for {ServerUpdateEvent} 58 | class ServerUpdateEventHandler < ServerEventHandler; end 59 | 60 | # Server is deleted, the server was left because the bot was kicked, or the 61 | # bot made itself leave the server. 62 | # @see Discordrb::EventContainer#server_delete 63 | class ServerDeleteEvent < ServerEvent 64 | # @return [Integer] The ID of the server that was left. 65 | attr_reader :server 66 | 67 | # Override init_server to account for the deleted server 68 | def init_server(data, _bot) 69 | @server = data['id'].to_i 70 | end 71 | end 72 | 73 | # Event handler for {ServerDeleteEvent} 74 | class ServerDeleteEventHandler < ServerEventHandler; end 75 | 76 | # Emoji is created/deleted/updated 77 | class ServerEmojiChangeEvent < ServerEvent 78 | # @return [Server] the server in question. 79 | attr_reader :server 80 | 81 | # @return [Array] array of emojis. 82 | attr_reader :emoji 83 | 84 | def initialize(server, data, bot) 85 | @bot = bot 86 | @server = server 87 | process_emoji(data) 88 | end 89 | 90 | # @!visibility private 91 | def process_emoji(data) 92 | @emoji = data['emojis'].map do |e| 93 | @server.emoji[e['id']] 94 | end 95 | end 96 | end 97 | 98 | # Generic event helper for when an emoji is either created or deleted 99 | class ServerEmojiCDEvent < ServerEvent 100 | # @return [Server] the server in question. 101 | attr_reader :server 102 | 103 | # @return [Emoji] the emoji data. 104 | attr_reader :emoji 105 | 106 | def initialize(server, emoji, bot) 107 | @bot = bot 108 | @emoji = emoji 109 | @server = server 110 | end 111 | end 112 | 113 | # Emoji is created 114 | class ServerEmojiCreateEvent < ServerEmojiCDEvent; end 115 | 116 | # Emoji is deleted 117 | class ServerEmojiDeleteEvent < ServerEmojiCDEvent; end 118 | 119 | # Emoji is updated 120 | class ServerEmojiUpdateEvent < ServerEvent 121 | # @return [Server] the server in question. 122 | attr_reader :server 123 | 124 | # @return [Emoji, nil] the emoji data before the event. 125 | attr_reader :old_emoji 126 | 127 | # @return [Emoji, nil] the updated emoji data. 128 | attr_reader :emoji 129 | 130 | def initialize(server, old_emoji, emoji, bot) 131 | @bot = bot 132 | @old_emoji = old_emoji 133 | @emoji = emoji 134 | @server = server 135 | end 136 | end 137 | 138 | # Event handler for {ServerEmojiChangeEvent} 139 | class ServerEmojiChangeEventHandler < ServerEventHandler; end 140 | 141 | # Generic handler for emoji create and delete 142 | class ServerEmojiCDEventHandler < ServerEventHandler 143 | def matches?(event) 144 | # Check for the proper event type 145 | return false unless event.is_a? ServerEmojiCDEvent 146 | 147 | [ 148 | matches_all(@attributes[:server], event.server) do |a, e| 149 | a == case a 150 | when String 151 | e.name 152 | when Integer 153 | e.id 154 | else 155 | e 156 | end 157 | end, 158 | matches_all(@attributes[:id], event.emoji.id) { |a, e| a.resolve_id == e.resolve_id }, 159 | matches_all(@attributes[:name], event.emoji.name) { |a, e| a == e } 160 | ].reduce(true, &:&) 161 | end 162 | end 163 | 164 | # Event handler for {ServerEmojiCreateEvent} 165 | class ServerEmojiCreateEventHandler < ServerEmojiCDEventHandler; end 166 | 167 | # Event handler for {ServerEmojiDeleteEvent} 168 | class ServerEmojiDeleteEventHandler < ServerEmojiCDEventHandler; end 169 | 170 | # Event handler for {ServerEmojiUpdateEvent} 171 | class ServerEmojiUpdateEventHandler < EventHandler 172 | def matches?(event) 173 | # Check for the proper event type 174 | return false unless event.is_a? ServerEmojiUpdateEvent 175 | 176 | [ 177 | matches_all(@attributes[:server], event.server) do |a, e| 178 | a == case a 179 | when String 180 | e.name 181 | when Integer 182 | e.id 183 | else 184 | e 185 | end 186 | end, 187 | matches_all(@attributes[:id], event.old_emoji.id) { |a, e| a.resolve_id == e.resolve_id }, 188 | matches_all(@attributes[:old_name], event.old_emoji.name) { |a, e| a == e }, 189 | matches_all(@attributes[:name], event.emoji.name) { |a, e| a == e } 190 | ].reduce(true, &:&) 191 | end 192 | end 193 | end 194 | --------------------------------------------------------------------------------