From 1bad4386020f3e02638083d298a235d0c42f0a29 Mon Sep 17 00:00:00 2001 From: Matthew Carey Date: Mon, 14 Sep 2020 12:56:21 -0400 Subject: [PATCH 1/6] Initial cache implementation --- lib/vox/cache/base.rb | 14 ++++++++++++++ lib/vox/cache/http.rb | 25 +++++++++++++++++++++++++ lib/vox/cache/manager.rb | 40 ++++++++++++++++++++++++++++++++++++++++ lib/vox/cache/memory.rb | 21 +++++++++++++++++++++ lib/vox/cache/redis.rb | 28 ++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 lib/vox/cache/base.rb create mode 100644 lib/vox/cache/http.rb create mode 100644 lib/vox/cache/manager.rb create mode 100644 lib/vox/cache/memory.rb create mode 100644 lib/vox/cache/redis.rb diff --git a/lib/vox/cache/base.rb b/lib/vox/cache/base.rb new file mode 100644 index 0000000..a28e3a1 --- /dev/null +++ b/lib/vox/cache/base.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Vox + module Cache + # Noop cache that only provides interface methods + class Base + def get(key) + end + + def set(key, value) + end + end + end +end diff --git a/lib/vox/cache/http.rb b/lib/vox/cache/http.rb new file mode 100644 index 0000000..0f3eb26 --- /dev/null +++ b/lib/vox/cache/http.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'vox/http/client' +require 'vox/cache/memory' + +module Vox + module Cache + class HTTP < Memory + def initialize(key, http) + @key = key + @http = http + @fetch_method = :"get_#{key}" + super(key) + end + + def get(id) + @data[id] ||= fetch(id) + end + + def fetch(id) + @http.__send__(@fetch_method, id) + end + end + end +end diff --git a/lib/vox/cache/manager.rb b/lib/vox/cache/manager.rb new file mode 100644 index 0000000..75b9e33 --- /dev/null +++ b/lib/vox/cache/manager.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# frozen_string_literal: true + +require 'vox/cache/memory' + +module Vox + module Cache + class Manager + attr_accessor :default + + def initialize(default: Memory, **options, &block) + @caches = options + yield(self) + end + + def get(cache, key) + cache_or_default(cache) + + @caches[cache].get(key) + end + + def set(cache, key, value) + cache_or_default(cache) + + @caches[cache].set(key, value) + end + + private + + def cache_or_default(cache) + if @default_cache.respond_to?(:call) + @caches[cache] ||= @default.call(cache) + else + @caches[cache] ||= @default.new(cache) + end + end + end + end +end \ No newline at end of file diff --git a/lib/vox/cache/memory.rb b/lib/vox/cache/memory.rb new file mode 100644 index 0000000..81dbe93 --- /dev/null +++ b/lib/vox/cache/memory.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'vox/cache/base' + +module Vox + module Cache + class Memory < Base + def initialize(_key) + @data = {} + end + + def get(key) + @data[key] + end + + def set(key, value) + @data[key, value] + end + end + end +end diff --git a/lib/vox/cache/redis.rb b/lib/vox/cache/redis.rb new file mode 100644 index 0000000..bba0f01 --- /dev/null +++ b/lib/vox/cache/redis.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'vox/cache/http' + +module Vox + module Cache + class Redis < HTTP + def initialize(key, http, redis) + @redis = redis + super(key, http) + end + + def set(id, data) + @redis.set("#{@key}:#{id}", MultiJson.dump(data)) + end + + def fetch(id) + data = @redis.get("#{@key}:#{id}") + return MultiJson.load(data, symbolize_keys: true) if data + + data = super + set(id, data) + + data + end + end + end +end From f75ca267ff03b4f647fe6538c0a6abb66f9e0c45 Mon Sep 17 00:00:00 2001 From: Matthew Carey Date: Tue, 15 Sep 2020 14:11:23 -0400 Subject: [PATCH 2/6] Add `get?` --- lib/vox/cache/base.rb | 3 +++ lib/vox/cache/memory.rb | 4 ++++ lib/vox/cache/redis.rb | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/lib/vox/cache/base.rb b/lib/vox/cache/base.rb index a28e3a1..e6de52d 100644 --- a/lib/vox/cache/base.rb +++ b/lib/vox/cache/base.rb @@ -7,6 +7,9 @@ class Base def get(key) end + def get?(key) + end + def set(key, value) end end diff --git a/lib/vox/cache/memory.rb b/lib/vox/cache/memory.rb index 81dbe93..47016e5 100644 --- a/lib/vox/cache/memory.rb +++ b/lib/vox/cache/memory.rb @@ -13,6 +13,10 @@ def get(key) @data[key] end + def get?(key) + @data[key] + end + def set(key, value) @data[key, value] end diff --git a/lib/vox/cache/redis.rb b/lib/vox/cache/redis.rb index bba0f01..51e5d25 100644 --- a/lib/vox/cache/redis.rb +++ b/lib/vox/cache/redis.rb @@ -10,6 +10,10 @@ def initialize(key, http, redis) super(key, http) end + def get?(id) + @redis.get("#{@key}:#{id}") + end + def set(id, data) @redis.set("#{@key}:#{id}", MultiJson.dump(data)) end From e49771247dafc5ac12cc916364e9b6319c8b9ecf Mon Sep 17 00:00:00 2001 From: Matthew Carey Date: Wed, 16 Sep 2020 00:27:54 -0400 Subject: [PATCH 3/6] Update caches --- lib/vox/cache/memory.rb | 2 +- lib/vox/cache/redis.rb | 20 +++++++------------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/lib/vox/cache/memory.rb b/lib/vox/cache/memory.rb index 47016e5..c6b1475 100644 --- a/lib/vox/cache/memory.rb +++ b/lib/vox/cache/memory.rb @@ -14,7 +14,7 @@ def get(key) end def get?(key) - @data[key] + get(key) end def set(key, value) diff --git a/lib/vox/cache/redis.rb b/lib/vox/cache/redis.rb index 51e5d25..0943f58 100644 --- a/lib/vox/cache/redis.rb +++ b/lib/vox/cache/redis.rb @@ -4,29 +4,23 @@ module Vox module Cache - class Redis < HTTP - def initialize(key, http, redis) + class Redis < Base + def initialize(key, redis) @redis = redis super(key, http) end + def get(id) + MultiJson.load(@redis.get("#{@key}:#{id}")) + end + def get?(id) - @redis.get("#{@key}:#{id}") + get(id) end def set(id, data) @redis.set("#{@key}:#{id}", MultiJson.dump(data)) end - - def fetch(id) - data = @redis.get("#{@key}:#{id}") - return MultiJson.load(data, symbolize_keys: true) if data - - data = super - set(id, data) - - data - end end end end From 9b4e9fbdb393a94f2e0fd970897a5c1a08a5cf39 Mon Sep 17 00:00:00 2001 From: Matthew Carey Date: Thu, 17 Sep 2020 16:22:33 -0400 Subject: [PATCH 4/6] Update cache --- lib/vox/cache/base.rb | 4 ++-- lib/vox/cache/http.rb | 25 ------------------------- lib/vox/cache/manager.rb | 23 +++++++++++++++++------ lib/vox/cache/memory.rb | 10 +++++----- lib/vox/cache/redis.rb | 26 -------------------------- 5 files changed, 24 insertions(+), 64 deletions(-) delete mode 100644 lib/vox/cache/http.rb delete mode 100644 lib/vox/cache/redis.rb diff --git a/lib/vox/cache/base.rb b/lib/vox/cache/base.rb index e6de52d..9f7eee8 100644 --- a/lib/vox/cache/base.rb +++ b/lib/vox/cache/base.rb @@ -7,10 +7,10 @@ class Base def get(key) end - def get?(key) + def set(key, value) end - def set(key, value) + def delete(key) end end end diff --git a/lib/vox/cache/http.rb b/lib/vox/cache/http.rb deleted file mode 100644 index 0f3eb26..0000000 --- a/lib/vox/cache/http.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -require 'vox/http/client' -require 'vox/cache/memory' - -module Vox - module Cache - class HTTP < Memory - def initialize(key, http) - @key = key - @http = http - @fetch_method = :"get_#{key}" - super(key) - end - - def get(id) - @data[id] ||= fetch(id) - end - - def fetch(id) - @http.__send__(@fetch_method, id) - end - end - end -end diff --git a/lib/vox/cache/manager.rb b/lib/vox/cache/manager.rb index 75b9e33..48c25a7 100644 --- a/lib/vox/cache/manager.rb +++ b/lib/vox/cache/manager.rb @@ -9,15 +9,16 @@ module Cache class Manager attr_accessor :default - def initialize(default: Memory, **options, &block) + def initialize(default: Memory, **options) @caches = options - yield(self) + @default = default + yield(self) if block_given? end - def get(cache, key) + def get(cache, key, &block) cache_or_default(cache) - @caches[cache].get(key) + @caches[cache].get(key, &block) end def set(cache, key, value) @@ -25,14 +26,24 @@ def set(cache, key, value) @caches[cache].set(key, value) end + + def [](name) + @caches[name] ||= cache_or_default(name) + end + + def delete(cache, key) + cache_or_default(cache) + + @caches[cache].delete(key) + end private def cache_or_default(cache) if @default_cache.respond_to?(:call) - @caches[cache] ||= @default.call(cache) + @caches[cache] ||= @default.call else - @caches[cache] ||= @default.new(cache) + @caches[cache] ||= @default.new end end end diff --git a/lib/vox/cache/memory.rb b/lib/vox/cache/memory.rb index c6b1475..10af157 100644 --- a/lib/vox/cache/memory.rb +++ b/lib/vox/cache/memory.rb @@ -5,7 +5,7 @@ module Vox module Cache class Memory < Base - def initialize(_key) + def initialize @data = {} end @@ -13,12 +13,12 @@ def get(key) @data[key] end - def get?(key) - get(key) + def set(key, value) + @data[key] = value end - def set(key, value) - @data[key, value] + def delete(key) + @data.delete(key) end end end diff --git a/lib/vox/cache/redis.rb b/lib/vox/cache/redis.rb deleted file mode 100644 index 0943f58..0000000 --- a/lib/vox/cache/redis.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require 'vox/cache/http' - -module Vox - module Cache - class Redis < Base - def initialize(key, redis) - @redis = redis - super(key, http) - end - - def get(id) - MultiJson.load(@redis.get("#{@key}:#{id}")) - end - - def get?(id) - get(id) - end - - def set(id, data) - @redis.set("#{@key}:#{id}", MultiJson.dump(data)) - end - end - end -end From f1e61f2c6119a3bca55ef15f8ac3b0aa76cacfa7 Mon Sep 17 00:00:00 2001 From: Matthew Carey Date: Mon, 21 Sep 2020 16:47:04 -0400 Subject: [PATCH 5/6] wip: adding object and client http methods --- lib/vox/client.rb | 49 +++++++ lib/vox/objects.rb | 12 ++ lib/vox/objects/api_object.rb | 78 ++++++++++ lib/vox/objects/audit_log.rb | 106 ++++++++++++++ lib/vox/objects/channel.rb | 192 +++++++++++++++++++++++++ lib/vox/objects/emoji.rb | 80 +++++++++++ lib/vox/objects/guild.rb | 252 +++++++++++++++++++++++++++++++++ lib/vox/objects/invite.rb | 67 +++++++++ lib/vox/objects/message.rb | 195 +++++++++++++++++++++++++ lib/vox/objects/permissions.rb | 48 +++++++ lib/vox/objects/role.rb | 50 +++++++ lib/vox/objects/user.rb | 222 +++++++++++++++++++++++++++++ lib/vox/objects/webhook.rb | 96 +++++++++++++ 13 files changed, 1447 insertions(+) create mode 100644 lib/vox/client.rb create mode 100644 lib/vox/objects.rb create mode 100644 lib/vox/objects/api_object.rb create mode 100644 lib/vox/objects/audit_log.rb create mode 100644 lib/vox/objects/channel.rb create mode 100644 lib/vox/objects/emoji.rb create mode 100644 lib/vox/objects/guild.rb create mode 100644 lib/vox/objects/invite.rb create mode 100644 lib/vox/objects/message.rb create mode 100644 lib/vox/objects/permissions.rb create mode 100644 lib/vox/objects/role.rb create mode 100644 lib/vox/objects/user.rb create mode 100644 lib/vox/objects/webhook.rb diff --git a/lib/vox/client.rb b/lib/vox/client.rb new file mode 100644 index 0000000..42f9128 --- /dev/null +++ b/lib/vox/client.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'vox/http/client' +require 'vox/gateway/client' +require 'vox/cache/manager' +require 'vox/objects/user' + +module Vox + + + + class Client + include EventEmitter + + attr_reader :http + attr_reader :gateway + + def initialize(token:, cache_manager: Cache::Manager.new, gateway_options: {}, http_options: {}, gateway: nil, http: nil) + @cache_manager = cache_manager + @http = http || HTTP::Client.new(**{ token: token }.merge(http_options)) + @gateway = gateway || create_gateway(token: token, options: gateway_options) + end + + def cache(cache_key, key, cached = true, &block) + puts "caching #{cache_key} #{key}" + if cached + @cache_manager.get(cache_key, key) || @cache_manager.set(cache_key, key, block.call) + else + @cache_manager.set(cache_key, key, block.call) + end + end + + def user(id, cached: true) + cache(:user, id, cached) { User.new(self, @http.get_user(id)) } + end + + def current_user(cached: true) + data = cache(:user, :@me, cached) { Profile.new(self, @http.get_current_user) } + @cache_manager.set(:user, data.id, data) + end + + private + + def create_gateway(token:, options: {}) + options[:url] ||= @http.get_gateway_bot[:url] + Vox::Gateway::Client.new(**{ token: token }.merge(**options)) + end + end +end \ No newline at end of file diff --git a/lib/vox/objects.rb b/lib/vox/objects.rb new file mode 100644 index 0000000..171aa27 --- /dev/null +++ b/lib/vox/objects.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'vox/objects/api_object' +require 'vox/objects/channel' +require 'vox/objects/emoji' +require 'vox/objects/guild' +require 'vox/objects/invite' +require 'vox/objects/message' +require 'vox/objects/role' +require 'vox/objects/user' +require 'vox/objects/webhook' +require 'vox/objects/permissions' diff --git a/lib/vox/objects/api_object.rb b/lib/vox/objects/api_object.rb new file mode 100644 index 0000000..9660113 --- /dev/null +++ b/lib/vox/objects/api_object.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Vox + # Base object for objects from the API that reference + # a client and the response data. + # + # All returned fields are made available as reader methods. + class APIObject + def initialize(client, data) + @client = client + @mutex = Mutex.new + update_data(data) + end + + # Generic update_data for objects without nesting + # @api private + def update_data(data) + # Used for when subclasses wrap in a synchronize + own_lock = @mutex.owned? + + @mutex.lock unless own_lock + keys = data.keys + keys.each { |key| instance_variable_set("@#{key}", data[key]) } + @mutex.unlock unless own_lock + end + + # Override the default inspect to not show internal instance variables. + # @!visibility private + def inspect + relevant_ivars = instance_variables - %i[@client @__events @mutex] + ivar_pairs = relevant_ivars.collect { |ivar| [ivar, instance_variable_get(ivar)] } + ivar_strings = ivar_pairs.collect { |iv| "#{iv[0]}=#{iv[1].inspect}" } + "#<#{self.class} #{ivar_strings.join(' ')}>" + end + + # Compare other objects that respond to `#id` or suitible ID types, + # `String` and `Numeric`. + def ==(other) + if other.respond_to?(:id) && other.is_a?(self.class) + @id.to_s == other.id.to_s + elsif other.is_a?(Numeric) || other.is_a?(String) + @id.to_s == other.to_s + else + false + end + end + + # Explicit conversion of API object to hash. + # @!visibility private + def to_hash + (instance_variables - [@client, @mutex]).collect do |ivar| + [ivar[1..-1].to_sym, instance_variable_get(ivar)] + end.to_h + end + + # Declares a reader, as well as an http based writer. + def self.modifiable(key) + attr_reader key + + define_method("#{key}=") { |data| modify(key => data) } + end + + # Declares a set of bit flag keys, creating `?` methods for checking if + # the flag is set. + # @example + # class User < APIObject + # flags :@public_flags, discord_employee: 1 << 3 + # end + # + # User.new(client, { flags: 0b111111111 }).discord_employee? + # # => true + def self.flags(var, **flags) + flags.each do |flag, value| + define_method("#{flag}?") { (instance_variable_get(var) & value).positive? } + end + end + end +end diff --git a/lib/vox/objects/audit_log.rb b/lib/vox/objects/audit_log.rb new file mode 100644 index 0000000..2f9c9bd --- /dev/null +++ b/lib/vox/objects/audit_log.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'vox/objects/api_object' + +module Vox + class AuditLog < APIObject + class Entry < APIObject + # Optional audit log information. + class Options < APIObject + # @!attribute [r] delete_member_days + # @return [String] + attr_reader :delete_member_days + + # @!attribute [r] members_removed + # @return [String] + + # @!attribute [r] channel_id + # @return [String] + attr_reader :channel_id + + # @!attribute [r] message_id + # @return [String] + attr_reader :message_id + + # @!attribute [r] count + # @return [String] + attr_reader :count + + # @!attribute [r] id + # @return [String] + attr_reader :id + + # @!attribute [r] type + # @return [String] + attr_reader :type + + # @!attribute [r] role_name + # @return [String] + attr_reader :role_name + + def update_data(data) + data[:channel_id] = data[:channel_id]&.to_s + data[:message_id] = data[:message_id]&.to_s + data[:id] = data[:id]&.to_s + + super + end + end + + # A change within the audit log. + class Change + # @!attribute [r] new_value + # @return [Object, nil] + attr_reader :new_value + + # @!attribute [r] old_value + # @return [Object, nil] + attr_reader :old_value + + # @!attribute [r] key + # @return [String] + attr_reader :key + + def initialize(data) + @new_value = data[:new_value] + @old_value = data[:old_value] + @key = data[:key] + end + end + + # @!attribute [r] target_id + # @return [String] + attr_reader :target_id + + # @!attribute [r] changes + # @return [Array] + attr_reader :changes + + # @!attribute [r] id + # @return [String] + attr_reader :id + + # @!attribute [r] action_type + # @return [Integer] + attr_reader :action_type + + # @!attribute [r] options + # @return [Options, nil] + attr_reader :options + + # @!attribute [r] reason + # @return [String, nil] + attr_reader :reason + + def update_data(data) + data[:target_id] = data[:target_id]&.to_s + data[:changes] = data[:changes].collect { |c| Change.new(c) } if data[:changes] + data[:user_id] = data[:user_id]&.to_s + data[:id] = data[:id]&.id + data[:options] = Options.new(data[:options]) if data[:options] + + super + end + end + end +end diff --git a/lib/vox/objects/channel.rb b/lib/vox/objects/channel.rb new file mode 100644 index 0000000..85e5b60 --- /dev/null +++ b/lib/vox/objects/channel.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require 'time' +require 'vox/objects/api_object' +require 'vox/objects/permissions' + +module Vox + # A channel object with HTTP methods. + class Channel < APIObject + # Overwrite object for channel permissions. + class Overwrite + # Allowed permissions bit set. + # @return [Permissions] + attr_reader :allow + + # Denied permissions bit set. + # @return [Permissions] + attr_reader :deny + + # Role or User ID. + # @return [String] + attr_reader :id + + # The type this override corresponds to. + # @return ["role", "member"] + attr_reader :type + + # @param data [Hash] + # @option data [String] allow Allowed permissions bit set. + # @option data [String] deny Denied permissions bit set. + # @option data [String] id Role or User ID. + # @option data ["role", "member"] type The type this override corresponds to. + def initialize(**data) + @allow = Permissions.new(data[:allow]) + @deny = Permissions.new(data[:deny]) + @id = data[:id].to_s + @type = data[:type] + end + end + + # @!visibility private + TYPES = { + text: 0, + dm: 1, + voice: 2, + group_dm: 3, + category: 4, + news: 5, + store: 6 + }.freeze + + # @!group Type Checking + + # @!method text? + # @return [true, false] + # @!method dm? + # @return [true, false] + # @!method voice? + # @return [true, false] + # @!method group_dm? + # @return [true, false] + # @!method category? + # @return [true, false] + # @!method news? + # @return [true, false] + # @!method store? + # @return [true, false] + + TYPES.each do |name, value| + define_method("#{name}?") { @type == value } + end + # @!endgroup + + # @return [String] + attr_reader :id + + # @return [String] + attr_reader :guild_id + + # @return [String] + attr_reader :last_message_id + + # @return [Array] + attr_reader :recipients + + # @return [String] + attr_reader :icon + + # @return [String] + attr_reader :owner_id + + # @return [String] + attr_reader :application_id + + # @return [Time] + attr_reader :last_pin_timestamp + + # @!group Modifiable Attributes + + # @!attribute [rw] name + # @return [String] + modifiable :name + + # @!attribute [rw] type + # @return [Integer] + modifiable :type + + # @!attribute [rw] position + # @return [Integer] + modifiable :position + + # @!attribute [rw] topic + # @return [String] + modifiable :topic + + # @!attribute [rw] nsfw + # @return [true, false] + modifiable :nsfw + alias nsfw? nsfw + + # @!attribute [rw] rate_limit_per_user + # @return [Integer] + modifiable :rate_limit_per_user + + # @!attribute [rw] bitrate + # @return [Integer] + modifiable :bitrate + + # @!attribute [rw] user_limit + # @return [Integer] + modifiable :user_limit + + # @!attribute [rw] permission_overwrites + # @return [Array] + modifiable :permission_overwrites + + # @!attribute [rw] parent_id + # @return [String, Integer] + modifiable :parent_id + + # @!endgroup + + # @param [Hash] attrs the attributes to modify on the channel. + # @option attrs [String] name + # @option attrs [Integer] type Only conversion between text and news is supported. + # @option attrs [Integer] position + # @option attrs [String] topic + # @option attrs [true, false] nsfw + # @option attrs [Integer] rate_limit_per_user + # @option attrs [Integer] bitrate + # @option attrs [Integer] user_limit + # @option attrs [Overwrite, Hash] permission_overwrites + # @option attrs [String, Integer] parent_id + # @see Vox::HTTP::Routes::Channel#modify_channel Modify channel options + def modify(**attrs) + data = @client.http.modify_channel(@id, **attrs) + update_data(data) + end + + # @!attribute [r] guild + # @return [Guild, nil] The guild that owns this channel. + def guild + @guild_id ? @client.guild(@guild_id) : nil + end + + # @!attribute [r] parent + # @return [Channel, nil] The parent category channel. + def parent + @parent_id ? @client.channel(@parent_id) : nil + end + + # @!visibility private + # @param data [Hash] The data to update the object with. + def update_data(data) + @mutex.synchronize do + super + + if data.include?(:recipients) + @recipients.each do |user_data| + @client.cache_upsert(:user, user_data[:id], User.new(@client, user_data)) + end + @recipients = data[:recipients].collect { |user| @client.user(user[:id]) } + end + + if data.include?(:last_pin_timestamp) + lpt = data[:last_pin_timestamp] + @last_pin_timestamp = lpt ? Time.iso8601(lpt) : nil + end + end + end + end +end diff --git a/lib/vox/objects/emoji.rb b/lib/vox/objects/emoji.rb new file mode 100644 index 0000000..f7e696a --- /dev/null +++ b/lib/vox/objects/emoji.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'vox/objects/api_object' + +module Vox + # Custom emojis object. + class Emoji < APIObject + # @!attribute [r] id + # @return [String] + attr_reader :id + + # @!attribute [r] name + # @return [String] + attr_reader :name + + # @!attribute [r] roles + # @return [Array, nil] + attr_reader :roles + + # @!attribute [r] user + # @return [User, nil] + attr_reader :user + + # @!attribute [r] require_colons + # @return [true, false, nil] + attr_reader :require_colons + alias require_colons? require_colons + + # @!attribute [r] managed + # @return [true, false, nil] + attr_reader :managed + alias managed? managed + + # @!attribute [r] animated + # @return [true, false, nil] + attr_reader :animated + alias animated? animated + + # @!attribute [r] available + # @return [true, false, nil] + attr_reader :available + alias available? available + + # @!group Modifiable Attributes + + # Set roles that are whitelisted to use this emoji. + modifiable :roles + + # The name of this emoji. + modifiable :name + + # @!endgroup + + # Modify attributes of this emoji. + # @param name [String] + # @param roles [Array] A list of roles or IDs to restrict usage to. + def modify(name: :undef, roles: :undef) + roles = roles.is_a?(Array) ? roles.collect { |obj| obj&.id || obj } : roles + + data = @client.modify_guild_emoji(guild.id, @id, name: name, role: roles) + update_data(data) + end + + # @return [Guild, nil] The guild that owns this emoji. + # @note Emoji's are not guaranteed to have an associated Guild available. + def guild + raise Vox::Error.new('No associated guild') unless @guild_id + + @client.guild(@guild_id) + end + + # @!visibility private + def update_data(data) + data[:user] = User.new(@client, data[:user]) if data[:user] + data[:id] = data[:id].to_s if data[:id] + + super + end + end +end diff --git a/lib/vox/objects/guild.rb b/lib/vox/objects/guild.rb new file mode 100644 index 0000000..1788268 --- /dev/null +++ b/lib/vox/objects/guild.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +require 'vox/objects/api_object' + +module Vox + # Guilds in Discord represent an isolated collection of users and channels, + # and are often referred to as "servers" in the UI. + class Guild < APIObject + # @!group Modifiable Attributes + + # @!attribute [rw] name + # @return [String] + modifiable :name + + # @!attribute [rw] region + # @return [String] + modifiable :region + + # @!attribute [rw] verification_level + # @return [Integer] + modifiable :verification_level + + # @!attribute [rw] default_message_notifications + # @return [Integer] + modifiable :default_message_notifications + + # @!attribute [rw] explicit_content_filter + # @return [Integer] + modifiable :explicit_content_filter + + # @!attribute [rw] afk_channel_id + # @return [String] + modifiable :afk_channel_id + + # @!attribute [rw] afk_timeout + # @return [Integer] + modifiable :afk_timeout + + # @!attribute [rw] icon + # @return [String] + modifiable :icon + + # @!attribute [rw] owner_id + # @return [String] + modifiable :owner_id + + # @!attribute [rw] splash + # @return [String] + modifiable :splash + + # @!attribute [rw] banner + # @return [String] + modifiable :banner + + # @!attribute [rw] system_channel_id + # @return [String] + modifiable :system_channel_id + + # @!attribute [rw] rules_channel_id + # @return [String] + modifiable :rules_channel_id + + # @!attribute [rw] public_updates_channel_id + # @return [String] + modifiable :public_updates_channel_id + + # @!attribute [rw] preferred_locale + # @return [String] + modifiable :preferred_locale + + # @!endgroup + + def roles(cached: true) + return @roles if @roles && cached + + update_data({ roles: @client.http.get_guild_roles(@id) }) + end + + def role(id) + roles.find { |r| r.id == id } + end + + def emojis(cached: true) + return @emojis if @emojis && cached + + update_data({ emojis: @client.http.get_guild_emojis(@id) }) + end + + def emoji(id) + emojis.find { |e| e.id == id } + end + + def audit_log(user_id: :undef, action_type: :undef, before: :undef, limit: :undef) + log = @client.http.get_guild_audit_log(@id, user_id: user_id, action_type: action_type, + before: before, limit: limit) + AuditLog.new(@client, log) + end + + # @!visibility private + def update_data(data) + id_keys = %i[afk_channel_id owner_id system_channel_id rules_channel_id public_updates_channel_id] + data.slice(id_keys).transform_values(&:to_s) + + data[:roles] = data[:roles].collect { |role_data| Role.new(@client, role_data) } + data[:roles].each { |role| @client.cache_upsert(:role, role.id, role) } + + data[:emojis] = data[:emojis].collect { |emoji_data| Emoji.new(@client, emoji_data) } + data[:emojis].each { |emoji| @client.cache_upsert(:emoji, emoji.id, emoji) } + + super + end + + # Information about an integration with an external service. + class Integration < APIObject + # @!visibility private + EXPIRE_BEHAVIOR = { + remove_role: 0, + kick: 1 + }.freeze + + # Integration account information. + class Account < APIObject + # @!attribute [r] id + # @return [String] + attr_reader :id + + # @!attribute [r] name + # @return [String] + attr_reader :name + + # @!visibility private + def update_data(data) + data[:id] = data[:id].to_s + super + end + end + + # @!attribute [r] id + # @return [String] + attr_reader :id + + # @!attribute [r] name + # @return [String] + attr_reader :name + + # @!attribute [r] type + # @return [String] + attr_reader :type + + # @!attribute [r] enabled + # @return [true, false] + attr_reader :enabled + alias enabled? enabled + + # @!attribute [r] syncing + # @return [true, false] + attr_reader :syncing + alias syncing? syncing + + # @!attribute [r] role_id + # @return [String] + attr_reader :role_id + + # @!attribute [r] enabled_emoticons + # @return [true, false, nil] + attr_reader :enabled_emoticons + alias enabled_emoticons? enabled_emoticons + + # @!attribute [r] expire_behavior + # @return [Integer] + attr_reader :expire_behavior + + # @!attribute [r] expire_grace_period + # @return [Integer] + attr_reader :expire_grace_period + + # @!attribute [r] user + # @return [User] + attr_reader :user + + # @!attribute [r] synced_at + # @return [Time] + attr_reader :synced_at + + # @return [true, false] + def kick_on_expire? + @expire_behavior == EXPIRE_BEHAVIOR[:kick] + end + + # @return [true, false] + def remove_role_on_expire? + @expire_behavior == EXPIRE_BEHAVIOR[:remove_role] + end + + # @!visibility private + def update_data(data) + data[:user] = @client.cache_upsert(:user, data[:user][:id], data[:user]) + data[:account] = Account.new(data[:account]) + data[:synced_at] = Time.iso8601(data[:synced_at]) + super + end + end + end + + # A user within the context of a {Guild}. + class Member < APIObject + # @!attribute [r] user + # @return [User, nil] + attr_reader :user + + # @!attribute [r] nick + # @return [String] + attr_reader :nick + + # @!attribute [r] roles + # @return [Array] + attr_reader :roles + + # @!attribute [r] joined_at + # @return [Time] + attr_reader :joined_at + + # @!attribute [r] premium_since + # @return [Time, nil] + attr_reader :premium_since + + # @!attribute [r] deaf + # @return [true, false] + attr_reader :deaf + alias deaf? deaf + + # @!attribute [r] mute + # @return [true, false] + attr_reader :mute + alias mute? mute + + def initialize(client, data, guild) + @guild = guild + super(client, data) + end + + # @!visibility private + def update_data(data) + data[:user] = User.new(@client, data[:user]) if data[:user] + data[:joined_at] = Time.iso8601(data[:joined_at]) if data[:joined_at] + data[:premium_since] = Time.iso8601(data[:premium_since]) if data[:premium_since] + data[:roles] = @guild.roles.select { |r| data[:roles].include? r.id } + + super + end + end +end diff --git a/lib/vox/objects/invite.rb b/lib/vox/objects/invite.rb new file mode 100644 index 0000000..14fcd38 --- /dev/null +++ b/lib/vox/objects/invite.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Vox + # Represents a code that when used, adds a user to a guild or group DM channel. + class Invite < APIObject + # @!attribute [r] code + # @return [String] The invite code. + attr_reader :code + + # @!attribute [r] guild + # @return [Guild] The guild this invite is for. + attr_reader :guild + + # @!attribute [r] channel + # @return [Channel] The channel this invite is for. + attr_reader :channel + + # @!attribute [r] inviter + # @return [User, nil] The user who created the invite. + attr_reader :inviter + + # @!attribute [r] target_user + # @return [User, nil] The user this invite is intended for. + attr_reader :target_user + + # @!attribute [r] target_user_type + # @return [Integer, nil] The type of user target for this invite. + attr_reader :target_user_type + + # @!attribute [r] approximate_presence_count + # @return [Integer, nil] The approximate count of online members. + attr_reader :approximate_presence_count + + # @!attribute [r] approximate_member_count + # @return [Integer, nil] The approximate count of total members. + attr_reader :approximate_member_count + + # Check if the target is a stream. + def stream_target? + @target_user_type == 1 + end + + # Delete this invite. + def delete + @client.http.delete_invite(@code) + end + + def update_data(data) + inviter = data[:inviter] + data[:inviter] = @client.cache_upsert(:user, inviter[:id], User.new(@client, inviter)) if inviter + + target = data[:target_user] + data[:target_user] = @client.cache_upsert(:user, target[:id], User.new(@client, target)) if target + + guild = data[:guild] + data[:guild] = @client.cache_upsert(:guild, guild[:id], Guild.new(@client, guild)) if guild + + channel = data[:channel] + data[:channel] = @client.cache_upsert(:channel, channel[:id], Channel.new(@client, channel)) if channel + + created_at = data[:created_at] + data[:created_at] = Time.iso8601(created_at) if created_at + + super + end + end +end diff --git a/lib/vox/objects/message.rb b/lib/vox/objects/message.rb new file mode 100644 index 0000000..df6b7d5 --- /dev/null +++ b/lib/vox/objects/message.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +require 'vox/objects/api_object' + +module Vox + # A message sent in a channel. + class Message < APIObject + # Flags that give information about a message. + # @note `suppress_embeds` can me modified with {#suppress_embeds=} + # or {#modify}. This requires the `MANAGE_MESSAGES` permission. + FLAGS = { + crossposted: 1 << 0, + is_crosspost: 1 << 1, + suppress_embeds: 1 << 2, + source_message_deleted: 1 << 3, + urgent: 1 << 4 + }.freeze + + # @!group Flags + + # @!method crossposted? + # @return [true, false] + # @!method is_crosspost? + # @return [true, false] + # @!method suppress_embeds? + # @return [true, false] + # @!method source_message_deleted? + # @return [true, false] + # @!method urgent? + # @return [true, false] + flags :@flags, **FLAGS + + # @!endgroup + + # @!attribute [r] id + # @return [true, false] + attr_reader :id + alias id? id + + # @!attribute [r] channel_id + # @return [String] + attr_reader :channel_id + + # @!attribute [r] guild_id + # @return [String] + attr_reader :guild_id + + # @!attribute [r] author + # @return [User] + attr_reader :author + + # @!attribute [r] member + # @return [Member, nil] + attr_reader :member + + # @!attribute [r] content + # @return [String] + attr_reader :content + + # @!attribute [r] timestamp + # @return [Time] + attr_reader :timestamp + + # @!attribute [r] edited_timestamp + # @return [Time] + attr_reader :edited_timestamp + + # @!attribute [r] tts + # @return [true, false] + attr_reader :tts + alias tts? tts + + # @!attribute [r] mention_everyone + # @return [true, false] + attr_reader :mention_everyone + alias mention_everyone? mention_everyone + + # @!attribute [r] mentions + # @return [Array] + attr_reader :mentions + + # @!attribute [r] mention_roles + # @return [Array, nil] + attr_reader :mention_roles + + # @!attribute [r] mention_channels + # @return [Array, nil] + attr_reader :mention_channels + + # @!attribute [r] attachments + # @return [Array] + attr_reader :attachments + + # @!attribute [r] embeds + # @return [Array] + attr_reader :embeds + + # @!attribute [r] reactions + # @return [Array, nil] + attr_reader :reactions + + # @!attribute [r] nonce + # @return [String, Integer] + attr_reader :nonce + + # @!attribute [r] pinned + # @return [true, false] + attr_reader :pinned + alias pinned? pinned + + # @!attribute [r] webhook_id + # @return [String, nil] + attr_reader :webhook_id + + # @!attribute [r] type + # @return [Integer] + attr_reader :type + + # @!attribute [r] activity + # @return [Activity, nil] + attr_reader :activity + + # @!attribute [r] application + # @return [Application, nil] + attr_reader :application + + # @!attribute [r] message_reference + # @return [Reference, nil] + attr_reader :message_reference + + # @!attribute [r] flags + # @return [Integer, nil] + attr_reader :flags + + # @!group Modifiable Attributes + + # @!attribute [rw] content + # @return [String] The message content. + modifiable :content + + # @!attribute [rw] embed + # @return [Embed] Embedded rich content. + modifiable :embed + + # @!attribute [rw] allowed_mentions + # @return [AllowedMentions] + modifiable :allowed_mentions + + # @!attribute [rw] flags + # @return [Integer] + modifiable :flags + + # @!endgroup + + # @param value [true, false] + # @return [true, false] + def suppress_embeds=(value) + return if value == suppress_embeds? + + new_flags = value ? (@flags | FLAGS[:suppress_embeds]) : (@flags ^ FLAGS[:suppress_embeds]) + data = modify(flags: new_flags) + update_data(data) + end + + # Send a message in the same channel as this message. + # @return [Message] The created message. + def reply(**hash) + data = @client.http.create_message(@channel_id, **hash) + Message.new(@client, data) + end + + # @param content [String] + # @param embed [Embed, Hash] + # @param flags [Integer] + def modify(content: :undef, embed: :undef, flags: :undef) + @client.http.edit_message(@channel_id, @id, content: content, embed: embed, flags: flags) + end + alias edit modify + + # @!visibility private + def update_data(data) + if data.include? :timestamp + ts = data[:timestamp] + data[:timestamp] = ts ? Time.iso8601(ts) : nil + end + + if data.include? :edited_timestamp + ts = data[:edited_timestamp] + @edited_timestamp = ts ? Time.iso8601(ts) : nil + end + + super + end + end +end diff --git a/lib/vox/objects/permissions.rb b/lib/vox/objects/permissions.rb new file mode 100644 index 0000000..8472ffe --- /dev/null +++ b/lib/vox/objects/permissions.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Vox + # Permission bitset. + class Permissions + FLAGS = { + create_instant_invite: 0x00000001, + kick_members: 0x00000002, + ban_members: 0x00000004, + administrator: 0x00000008, + manage_channels: 0x00000010, + manage_guild: 0x00000020, + add_reactions: 0x00000040, + view_audit_log: 0x00000080, + priority_speaker: 0x00000100, + stream: 0x00000200, + view_channel: 0x00000400, + send_messages: 0x00000800, + send_tts_messages: 0x00001000, + manage_messages: 0x00002000, + embed_links: 0x00004000, + attach_files: 0x00008000, + read_message_history: 0x00010000, + mention_everyone: 0x00020000, + use_external_emojis: 0x00040000, + view_guild_insights: 0x00080000, + connect: 0x00100000, + speak: 0x00200000, + mute_members: 0x00400000, + deafen_members: 0x00800000, + move_members: 0x01000000, + use_vad: 0x02000000, + change_nickname: 0x04000000, + manage_nicknames: 0x08000000, + manage_roles: 0x10000000, + manage_webhooks: 0x20000000, + manage_emojis: 0x40000000 + }.freeze + + def initialize(packed_integer) + @value = packed_integer + end + + FLAGS.each do |flag, bit| + define_method("#{flag}?") { @value & bit == bit } + end + end +end diff --git a/lib/vox/objects/role.rb b/lib/vox/objects/role.rb new file mode 100644 index 0000000..4106b85 --- /dev/null +++ b/lib/vox/objects/role.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'vox/objects/api_object' +require 'vox/objects/permissions' + +module Vox + # A permission object for a {User} in a {Guild}. + class Role < APIObject + # @!attribute [r] id + # @return [String] + attr_reader :id + + # @!attribute [r] name + # @return [String] + attr_reader :name + + # @!attribute [r] color + # @return [Integer] + attr_reader :color + + # @!attribute [r] hoist + # @return [true, false] + attr_reader :hoist + alias hoist? hoist + + # @!attribute [r] position + # @return [Integer] + attr_reader :position + + # @!attribute [r] permissions + # @return [Permissions] + attr_reader :permissions + + # @!attribute [r] managed + # @return [true, false] + attr_reader :managed + alias managed? managed + + # @!attribute [r] mentionable + # @return [true, false] + attr_reader :mentionable + alias mentionable? mentionable + + def update_data(data) + data[:permissions] = Permissions.new(data[:permissions].to_i) if data[:permissions] + + super + end + end +end diff --git a/lib/vox/objects/user.rb b/lib/vox/objects/user.rb new file mode 100644 index 0000000..a512d17 --- /dev/null +++ b/lib/vox/objects/user.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require 'vox/objects/api_object' + +module Vox + # Users in Discord are generally considered the base entity. Users can spawn across the entire platform, + # be members of guilds, participate in text and voice chat, and much more. Users are separated by a distinction of + # "bot" vs "normal." Although they are similar, bot users are automated users that are "owned" by another user. + # Unlike normal users, bot users do not have a limitation on the number of Guilds they can be a part of. + class User < APIObject + # Flags that give information about badges on a + # user. + FLAGS = { + discord_employee: 1 << 0, + discord_partner: 1 << 1, + hypesquad_events: 1 << 2, + bug_hunter_level_1: 1 << 3, + house_bravery: 1 << 6, + house_brilliance: 1 << 7, + house_balance: 1 << 8, + early_supporter: 1 << 9, + team_user: 1 << 10, + system: 1 << 12, + bug_hunter_level_2: 1 << 14, + verified_bot: 1 << 16, + verified_bot_developer: 1 << 17 + }.freeze + + # @!attribute [r] + # @return [String] + attr_reader :id + + # @!attribute [r] + # @return [String] + attr_reader :username + + # @!attribute [r] + # @return [String] + attr_reader :discriminator + + # @!attribute [r] + # @return [String, nil] + attr_reader :avatar + + # @!attribute [r] + # @return [true, false, nil] + attr_reader :bot + + # @!attribute [r] + # @return [true, false, nil] + attr_reader :system + + # @!attribute [r] + # @return [true, false, nil] + attr_reader :mfa_enabled + + # @!attribute [r] + # @return [String, nil] + attr_reader :locale + + # @!attribute [r] + # @return [true, false, nil] + attr_reader :verified + + # @!attribute [r] + # @return [String, nil] + attr_reader :email + + # @!attribute [r] + # @return [Integer, nil] + attr_reader :flags + + # @!attribute [r] + # @return [Integer, nil] + attr_reader :premium_type + + # @!attribute [r] + # @return [Integer, nil] + attr_reader :public_flags + + # @return [true, false] + def nitro? + @premium_type&.positive? + end + + # @return [:none, :classic, :nitro] + def nitro_type + %i[none classic nitro][@premium_type] + end + + # @!group Flags + + # @!method discord_employee? + # @return [true, false] + # @!method discord_partner? + # @return [true, false] + # @!method hypesquad_events? + # @return [true, false] + # @!method bug_hunter_level_1? + # @return [true, false] + # @!method house_bravery? + # @return [true, false] + # @!method house_brilliance? + # @return [true, false] + # @!method house_balance? + # @return [true, false] + # @!method early_supporter? + # @return [true, false] + # @!method team_user? + # @return [true, false] + # @!method system? + # @return [true, false] + # @!method bug_hunter_level_2? + # @return [true, false] + # @!method verified_bot? + # @return [true, false] + # @!method verified_bot_developer? + # @return [true, false] + flags :@public_flags, **FLAGS + + # @endgroup + end + + # A Profile is a special type of user object that refers to a OAuth2 user, + # or the current bot application. + class Profile < User + # @return [String] + attr_reader :token + + # Retrieve a list of DM channels for a user. + # @return [Array] + def dms + @client.http.get_user_dms.colect do |data| + @client.cache_upsert(:channel, data[:id].to_s, Channel.new(@client, data)) + end + end + + # Create a new DM channel with for a target user. + # @param recipient_id [String, Integer] The target user ID. + # @return [Channel] + def create_dm(recipient_id) + data = @client.http.create_dm(recipient_id) + @client.cache_upsert(:channel, data[:id], Channel.new(@client, data)) + end + + # Leave a guild. + # @param guild_id [String, Integer] The ID of the guild to leave. + def leave_guild(guild_id) + @client.http.leave_guild(guild_id) + end + + # The connections of the user. + # @return [Array] + def connections + @client.http.get_user_connections + end + + # @!group Modifiable Attributes + + # @return [String] + modifiable :username + + # @return [String, nil] + modifiable :avatar + + # @!endgroup + + def modify(username: nil, avatar: nil) + update_data(@client.http.modify_current_user(username: username, avatar: avatar)) + end + + # A connection a user has to a service. + class Connection < APIObject + # @!attribute [r] id + # @return [String] + attr_reader :id + + # @!attribute [r] name + # @return [String] + attr_reader :name + + # @!attribute [r] type + # @return [String] + attr_reader :type + + # @!attribute [r] revoked + # @return [true, false, nil] + attr_reader :revoked + alias revoked? revoked + + # @!attribute [r] integrations + # @return [Array, nil] + attr_reader :integrations + + # @!attribute [r] verified + # @return [true, false] + attr_reader :verified + alias verified? verified + + # @!attribute [r] friend_sync + # @return [true, false] + attr_reader :friend_sync + alias friend_sync? friend_sync + + # @!attribute [r] show_activity + # @return [true, false] + attr_reader :show_activity + alias show_activity? show_activity + + # @!attribute [r] visibility + # @return [true, false] + attr_reader :visibility + alias visible? visibility + + # @!visibility private + def update_data(data) + inte = data[:integrations] + data[:integrations] = inte.collect { |d| Guild::Integration.new(@client, d) } if inte + end + end + end +end diff --git a/lib/vox/objects/webhook.rb b/lib/vox/objects/webhook.rb new file mode 100644 index 0000000..12f1d9f --- /dev/null +++ b/lib/vox/objects/webhook.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Vox + # Webhooks are a low-effort way to post messages to channels in Discord. + class Webhook < APIObject + # @return [String] + attr_reader :id + + # @return [Integer] + attr_reader :type + + # @return [String, nil] + attr_reader :guild_id + + # @return [User] + attr_reader :user + + # @return [String] + attr_reader :token + + # @!group Modifiable Attributes + + # @!attribute [rw] channel_id + # @return [String] + modifiable :channel_id + + # @!attribute [rw] name + # @return [String, nil] + modifiable :name + + # @!attribute [rw] avatar + # @return [String, nil] + modifiable :avatar + + # @!endgroup + + def initialize(client, data) + super + + return unless (user_data = data[:user]) + + @user = @client.cache_upsert(:user, user_data[:id], User.new(client, user_data)) + end + + # Modify this webhook. + # @param name [String, nil] The new name for the webhook. + # @param avatar [String, nil] The avatar data for this webhook. `nil` to remove. + # @param channel_id [String, nil] The channel ID this webhook should post in. + def modify(name: :undef, avatar: :undef, channel_id: :undef) + data = @client.http.modify_webhook(@id, name: name, avatar: avatar, channel_id: channel_id) + update_data(data) + end + + # Delete this webhook. + def delete + if @token + @client.http.delete_webhook_with_token(@id, @token) + else + @client.http.delete_webhook(@id) + end + end + + # TODO + def execute(**hash) + @client.http.execute_webhook(@id, @token, **hash) + end + + # @!attribute [r] channel + # @return [Channel, nil] + def channel + @client.channel(@channel_id) + end + + # @!attribute [r] guild + # @return [Guild, nil] + def guild + @client.guild(@guild_id) + end + + # @!group Type Checking + + # Check if the webhook is a standard webhook. + # @return [true, false] + def incoming? + @type == 1 + end + + # Check if the webhook is a channel following webhook. + # @return [true, false] + def channel_follower? + @type == 2 + end + + # @!endgroup + end +end From e9b042e8746ebb29388f2637f8d7a2902d578e16 Mon Sep 17 00:00:00 2001 From: Matthew Carey Date: Sat, 26 Sep 2020 20:32:57 -0400 Subject: [PATCH 6/6] Add more API objects and register some gateway event propagators --- lib/vox/cache.rb | 5 + lib/vox/cache/base.rb | 20 +++- lib/vox/cache/manager.rb | 34 +++++-- lib/vox/cache/memory.rb | 12 +++ lib/vox/client.rb | 182 ++++++++++++++++++++++++++++++---- lib/vox/http/client.rb | 8 +- lib/vox/objects/api_object.rb | 16 +-- lib/vox/objects/channel.rb | 28 +++--- lib/vox/objects/emoji.rb | 3 +- lib/vox/objects/guild.rb | 4 + lib/vox/objects/user.rb | 4 +- lib/vox/objects/webhook.rb | 3 +- spec/spec_helper.rb | 3 + 13 files changed, 262 insertions(+), 60 deletions(-) create mode 100644 lib/vox/cache.rb diff --git a/lib/vox/cache.rb b/lib/vox/cache.rb new file mode 100644 index 0000000..dd4c544 --- /dev/null +++ b/lib/vox/cache.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require 'vox/cache/base' +require 'vox/cache/memory' +require 'vox/cache/manager' diff --git a/lib/vox/cache/base.rb b/lib/vox/cache/base.rb index 9f7eee8..b423161 100644 --- a/lib/vox/cache/base.rb +++ b/lib/vox/cache/base.rb @@ -1,17 +1,27 @@ # frozen_string_literal: true module Vox + # Module that contains tools for managing a cache. module Cache # Noop cache that only provides interface methods class Base - def get(key) - end + # Retrieve a value from the cache by key + # @param _key [String] This will typically be an ID. + # @return [nil] + def get(_key); end - def set(key, value) + # Set a value in the cache + # @param _key [String] + # @param value [Object] + # @return [Object] The provided value. + def set(_key, value) + value end - def delete(key) - end + # Delete a value from the cache + # @param _key [String] + # @return [nil] + def delete(_key); end end end end diff --git a/lib/vox/cache/manager.rb b/lib/vox/cache/manager.rb index 48c25a7..f71cd85 100644 --- a/lib/vox/cache/manager.rb +++ b/lib/vox/cache/manager.rb @@ -6,46 +6,66 @@ module Vox module Cache + # Manages caches, index by symbol keys. Used by {Vox::Client}. class Manager attr_accessor :default + # @yield [self] def initialize(default: Memory, **options) @caches = options @default = default yield(self) if block_given? end + # Retrieve a key from a managed cache. + # @param cache [Symbol] + # @param key [String] def get(cache, key, &block) cache_or_default(cache) @caches[cache].get(key, &block) end + # Set a key for a managed cache. + # @param cache [Symbol] + # @param key [String] + # @param value [Object] + # @return [Object] The value that was set def set(cache, key, value) cache_or_default(cache) @caches[cache].set(key, value) end + # Retrieve a cache by name. + # @param name [Symbol] + # @return [Cache::Base] def [](name) @caches[name] ||= cache_or_default(name) end + # Remove a key from a managed cache. + # @param cache [Symbol] + # @param key [String] + # @return [nil] def delete(cache, key) cache_or_default(cache) @caches[cache].delete(key) end - + private + # Returns a cache if it exists, or create a new one from a the given + # default. + # @param cache [Symbol] def cache_or_default(cache) - if @default_cache.respond_to?(:call) - @caches[cache] ||= @default.call - else - @caches[cache] ||= @default.new - end + @caches[cache] ||= if @default_cache.respond_to?(:call) + @default.call + else + @default.new + end end end end -end \ No newline at end of file +end diff --git a/lib/vox/cache/memory.rb b/lib/vox/cache/memory.rb index 10af157..48c143f 100644 --- a/lib/vox/cache/memory.rb +++ b/lib/vox/cache/memory.rb @@ -4,19 +4,31 @@ module Vox module Cache + # A cache that uses a hash as the storage method. class Memory < Base def initialize @data = {} + super end + # Retrieve a value from the cache by key + # @param key [String] This will typically be an ID. + # @return [Object] def get(key) @data[key] end + # Set a value in the cache + # @param key [String] + # @param value [Object] + # @return [Object] The provided value. def set(key, value) @data[key] = value end + # Delete a value from the cache + # @param key [String] + # @return [nil] def delete(key) @data.delete(key) end diff --git a/lib/vox/client.rb b/lib/vox/client.rb index 42f9128..2bc1e07 100644 --- a/lib/vox/client.rb +++ b/lib/vox/client.rb @@ -2,48 +2,194 @@ require 'vox/http/client' require 'vox/gateway/client' -require 'vox/cache/manager' -require 'vox/objects/user' +require 'vox/cache' +require 'vox/objects' module Vox - - - + # A client that bridges the gateway, http, and stateful object components. class Client include EventEmitter - attr_reader :http - attr_reader :gateway + attr_reader :http, :gateway - def initialize(token:, cache_manager: Cache::Manager.new, gateway_options: {}, http_options: {}, gateway: nil, http: nil) + def initialize(token:, cache_manager: Cache::Manager.new, gateway_options: {}, http_options: {}) @cache_manager = cache_manager - @http = http || HTTP::Client.new(**{ token: token }.merge(http_options)) - @gateway = gateway || create_gateway(token: token, options: gateway_options) + @http = HTTP::Client.new(**{ token: token }.merge(http_options)) + @gateway = create_gateway(token: token, options: gateway_options) + + setup_gateway_caching end - def cache(cache_key, key, cached = true, &block) - puts "caching #{cache_key} #{key}" + # Retrieve an object from the cache or set it with a provided block. + def cache(cache_key, key, cached: true) if cached - @cache_manager.get(cache_key, key) || @cache_manager.set(cache_key, key, block.call) + @cache_manager.get(cache_key, key) || @cache_manager.set(cache_key, key, yield) + else + @cache_manager.set(cache_key, key, yield) + end + end + + # @!visibility private + def cache_upsert(cache_key, key, data) + obj = @cache_manager.get(cache_key, key) + if obj + obj.update_data(data.to_hash) else - @cache_manager.set(cache_key, key, block.call) + @cache_manager.set(cache_key, key, data) + nil end end + # @return [User] def user(id, cached: true) - cache(:user, id, cached) { User.new(self, @http.get_user(id)) } + cache(:user, id.to_s, cached) { User.new(self, @http.get_user(id)) } end + # @return [Profile] def current_user(cached: true) - data = cache(:user, :@me, cached) { Profile.new(self, @http.get_current_user) } + data = cache(:user, :@me, cached: cached) { Profile.new(self, @http.get_current_user) } @cache_manager.set(:user, data.id, data) end + # @return [Guild] + def guild(id, cached: true) + cache(:guild, id.to_s, cached: cached) { Guild.new(self, @http.get_guild(id)) } + end + + # @return [Channel] + def channel(id, cached: true) + cache(:channel, id.to_s, cached: cached) { Channel.new(self, @http.get_channel(id)) } + end + + # @return [Member] + def member(guild_id, user_id, cached: true) + cache(:member, [guild_id.to_s, user_id.to_s], cached: cached) do + Member.new(self, @http.get_guild_member(guild_id, user_id), guild(guild_id, cached: cached)) + end + end + + # @return [Role, nil] + def role(role_id) + @cache_manager.get(:role, role_id.to_s) + end + + # @return [Emoji, nil] + def emoji(emoji_id) + @cache_manager.get(:emoji, emoji_id.to_s) + end + + # @return [Webhook] + def webhook(webhook_id, token: nil, cached: true) + cache(:webhook, webhook_id.to_s, cached: cached) do + data = if token + @http.get_webhook_with_token(webhook_id, token) + else + @http.get_webhook(webhook_id) + end + Webhook.new(self, data) + end + end + + # @return [Invite] + def invite(invite_code, cached: true) + cache(:invite, invite_code, cached) do + Invite.new(self, @http.get_invite(invite_code)) + end + end + + def connect(async: false) + @gateway.connect(async: async) + end + private - def create_gateway(token:, options: {}) + def create_gateway(token:, options:) options[:url] ||= @http.get_gateway_bot[:url] Vox::Gateway::Client.new(**{ token: token }.merge(**options)) end + + def setup_gateway_caching + @gateway.on(:GUILD_CREATE, &method(:handle_guild_create)) + @gateway.on(:GUILD_UPDATE, &method(:handle_guild_update)) + @gateway.on(:GUILD_DELETE, &method(:handle_guild_delete)) + @gateway.on(:GUILD_ROLE_CREATE, &method(:handle_guild_role_create)) + + @gateway.on(:CHANNEL_UPDATE, &method(:handle_channel_update)) + @gateway.on(:CHANNEL_CREATE, &method(:handle_channel_create)) + @gateway.on(:CHANNEL_DELETE, &method(:handle_channel_delete)) + end + + def handle_guild_create(data) + guild_data = Guild.new(self, data) + cache_upsert(:guild, data[:id], guild_data) + + emit(:GUILD_CREATE, guild(data[:id]) || guild_data) + end + + def handle_guild_update(data) + guild_data = Guild.new(self, data) + cache_upsert(:guild, guild_data.id, guild_data) + + emit(:GUILD_UPDATE, guild(data[:id]) || guild_data) + end + + def handle_guild_delete(data) + guild_data = @cache_manager.get(:guild, data[:id]) + + emit(:GUILD_DELETE, guild_data) + @cache_manager.delete(:guild, data[:id]) + end + + def handle_guild_role_create(data) + guild_data = guild(data[:guild_id]) + role_data = Role.new(self, data[:role]) + cache_upsert(:role, role_data[:id], role_data) + guild_data.roles << role_data if guild_data + + emit(:GUILD_ROLE_CREATE, role(data[:id]) || role_data) + end + + def handle_guild_role_update(data) + role_data = Role.new(self, data) + cache_upsert(:role, role_data) + + emit(:GUILD_ROLE_UPDATE, role(data[id]) || data) + end + + def handle_guild_role_delete(data) + role_data = role(data[:role_id]) || Role.new(self, data) + guild_data = guild(data[:guild_id]) + guild_data.roles.delete(role_data) + + emit(:GUILD_ROLE_DELETE, role_data) + end + + def handle_channel_create(data) + channel_data = Channel.new(self, data) + cache_upsert(:channel, data[:id], data) + + emit(:CHANNEL_CREATE, channel(data[:id]) || channel_data) + end + + def handle_channel_update(data) + channel_data = Channel.new(self, data) + cache_upsert(:channel, data[:id], channel_data) + + emit(:CHANNEL_UPDATE, channel(data[:id]) || channel_data) + end + + def handle_channel_delete(data) + channel_data = Channel.new(data) + @cache_manager.delete(:channel, data[:id]) + + emit(:CHANNEL_DELETE, channel_data) + end + + def handle_channel_pins_update(data) + channel_data = channel(data[:channel_id]) + channel_data.update_data({ last_pin_timestamp: data[:last_pin_timestamp] }) + + emit(:CHANNEL_PINS_UPDATE, channel_data) + end end -end \ No newline at end of file +end diff --git a/lib/vox/http/client.rb b/lib/vox/http/client.rb index 3baf079..e6f10bf 100644 --- a/lib/vox/http/client.rb +++ b/lib/vox/http/client.rb @@ -43,9 +43,11 @@ class Client 'User-Agent': "DiscordBot (https://github.com/swarley/vox, #{Vox::VERSION})" }.freeze - def initialize(token) + # @note The use of the positional token is deprecated and will be removed. + def initialize(depr_token = nil, token: nil, token_type: 'Bot') + token ||= depr_token @conn = default_connection - @conn.authorization('Bot', token.delete_prefix('Bot ')) + @conn.authorization(token_type, token.delete_prefix("#{token_type} ")) yield(@conn) if block_given? end @@ -102,8 +104,6 @@ def handle_response(resp, raw, req_id) data = raw ? resp.body : MultiJson.load(resp.body, symbolize_keys: true) case resp.status - when 204, 304 - nil when 200..300 data when 400 diff --git a/lib/vox/objects/api_object.rb b/lib/vox/objects/api_object.rb index 9660113..27d077b 100644 --- a/lib/vox/objects/api_object.rb +++ b/lib/vox/objects/api_object.rb @@ -12,16 +12,18 @@ def initialize(client, data) update_data(data) end + def difference(data) + data.reject { |key, val| instance_variable_get("@#{key}") == val } + end + # Generic update_data for objects without nesting # @api private def update_data(data) - # Used for when subclasses wrap in a synchronize - own_lock = @mutex.owned? + @mutex.synchronize do + keys = data.keys - @mutex.lock unless own_lock - keys = data.keys - keys.each { |key| instance_variable_set("@#{key}", data[key]) } - @mutex.unlock unless own_lock + keys.each { |key| instance_variable_set("@#{key}", data[key]) } + end end # Override the default inspect to not show internal instance variables. @@ -29,7 +31,7 @@ def update_data(data) def inspect relevant_ivars = instance_variables - %i[@client @__events @mutex] ivar_pairs = relevant_ivars.collect { |ivar| [ivar, instance_variable_get(ivar)] } - ivar_strings = ivar_pairs.collect { |iv| "#{iv[0]}=#{iv[1].inspect}" } + ivar_strings = ivar_pairs.collect { |name, value| "#{name}=#{value.inspect}" } "#<#{self.class} #{ivar_strings.join(' ')}>" end diff --git a/lib/vox/objects/channel.rb b/lib/vox/objects/channel.rb index 85e5b60..2657708 100644 --- a/lib/vox/objects/channel.rb +++ b/lib/vox/objects/channel.rb @@ -153,8 +153,7 @@ def initialize(**data) # @option attrs [String, Integer] parent_id # @see Vox::HTTP::Routes::Channel#modify_channel Modify channel options def modify(**attrs) - data = @client.http.modify_channel(@id, **attrs) - update_data(data) + @client.http.modify_channel(@id, **attrs) end # @!attribute [r] guild @@ -172,21 +171,22 @@ def parent # @!visibility private # @param data [Hash] The data to update the object with. def update_data(data) - @mutex.synchronize do - super - - if data.include?(:recipients) - @recipients.each do |user_data| - @client.cache_upsert(:user, user_data[:id], User.new(@client, user_data)) - end - @recipients = data[:recipients].collect { |user| @client.user(user[:id]) } - end + data.delete(:guild_hashes) - if data.include?(:last_pin_timestamp) - lpt = data[:last_pin_timestamp] - @last_pin_timestamp = lpt ? Time.iso8601(lpt) : nil + if data.include?(:recipients) + data[:recipients].map! do |user_data| + User.new(@client, user_data) unless user_data.is_a?(User) end + + data[:recipients].each { |u| @client.cache_upsert(:user, u[:id], u) } end + + if data.include?(:last_pin_timestamp) && data[:last_pin_timestamp].is_a?(String) + lpt = data[:last_pin_timestamp] + data[:last_pin_timestamp] = lpt ? Time.iso8601(lpt) : nil + end + + super end end end diff --git a/lib/vox/objects/emoji.rb b/lib/vox/objects/emoji.rb index f7e696a..1dde561 100644 --- a/lib/vox/objects/emoji.rb +++ b/lib/vox/objects/emoji.rb @@ -57,8 +57,7 @@ class Emoji < APIObject def modify(name: :undef, roles: :undef) roles = roles.is_a?(Array) ? roles.collect { |obj| obj&.id || obj } : roles - data = @client.modify_guild_emoji(guild.id, @id, name: name, role: roles) - update_data(data) + @client.modify_guild_emoji(guild.id, @id, name: name, role: roles) end # @return [Guild, nil] The guild that owns this emoji. diff --git a/lib/vox/objects/guild.rb b/lib/vox/objects/guild.rb index 1788268..851bc81 100644 --- a/lib/vox/objects/guild.rb +++ b/lib/vox/objects/guild.rb @@ -96,6 +96,10 @@ def audit_log(user_id: :undef, action_type: :undef, before: :undef, limit: :unde AuditLog.new(@client, log) end + def modify(**args) + @client.http.modify_guild(@id, **args) + end + # @!visibility private def update_data(data) id_keys = %i[afk_channel_id owner_id system_channel_id rules_channel_id public_updates_channel_id] diff --git a/lib/vox/objects/user.rb b/lib/vox/objects/user.rb index a512d17..3eed18e 100644 --- a/lib/vox/objects/user.rb +++ b/lib/vox/objects/user.rb @@ -166,7 +166,7 @@ def connections # @!endgroup def modify(username: nil, avatar: nil) - update_data(@client.http.modify_current_user(username: username, avatar: avatar)) + @client.http.modify_current_user(username: username, avatar: avatar) end # A connection a user has to a service. @@ -216,6 +216,8 @@ class Connection < APIObject def update_data(data) inte = data[:integrations] data[:integrations] = inte.collect { |d| Guild::Integration.new(@client, d) } if inte + + super end end end diff --git a/lib/vox/objects/webhook.rb b/lib/vox/objects/webhook.rb index 12f1d9f..a19898a 100644 --- a/lib/vox/objects/webhook.rb +++ b/lib/vox/objects/webhook.rb @@ -47,8 +47,7 @@ def initialize(client, data) # @param avatar [String, nil] The avatar data for this webhook. `nil` to remove. # @param channel_id [String, nil] The channel ID this webhook should post in. def modify(name: :undef, avatar: :undef, channel_id: :undef) - data = @client.http.modify_webhook(@id, name: name, avatar: avatar, channel_id: channel_id) - update_data(data) + @client.http.modify_webhook(@id, name: name, avatar: avatar, channel_id: channel_id) end # Delete this webhook. diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index db598fc..2293002 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,6 +5,9 @@ require 'bundler/setup' require 'vox' +require 'vox/client' +require 'vox/objects' +require 'vox/cache' RSpec.configure do |config| # Enable flags like --only-failures and --next-failure