diff --git a/lib/discordrb/api/channel.rb b/lib/discordrb/api/channel.rb index fd18f7a5da..2387f8782c 100644 --- a/lib/discordrb/api/channel.rb +++ b/lib/discordrb/api/channel.rb @@ -333,6 +333,21 @@ def start_typing(token, channel_id) ) end + # Update the status of a voice channel. + # https://discord.com/developers/docs/resources/channel#set-voice-channel-status + def set_voice_channel_status(token, channel_id, status:, reason: nil) + Discordrb::API.request( + :channels_cid_voice_status, + channel_id, + :put, + "#{Discordrb::API.api_base}/channels/#{channel_id}/voice-status", + { status: }.to_json, + content_type: :json, + Authorization: token, + 'X-Audit-Log-Reason': reason + ) + end + # Get a list of pinned messages in a channel # https://discord.com/developers/docs/resources/message#get-channel-pins def pinned_messages(token, channel_id, limit = 50, before = nil) diff --git a/lib/discordrb/bot.rb b/lib/discordrb/bot.rb index a3e66f1f83..01b69dc7fd 100644 --- a/lib/discordrb/bot.rb +++ b/lib/discordrb/bot.rb @@ -1531,6 +1531,24 @@ def handle_dispatch(type, data) event.channel.process_last_pin_timestamp(data['last_pin_timestamp']) if data.key?('last_pin_timestamp') raise_event(event) + when :VOICE_CHANNEL_STATUS_UPDATE + @channels[data['id'].to_i]&.process_voice_status(data['status']) + + event = ChannelStatusUpdateEvent.new(data, self) + raise_event(event) + when :VOICE_CHANNEL_START_TIME_UPDATE + @channels[data['id'].to_i]&.process_start_time(data['voice_start_time']) + + event = ChannelStartTimeUpdateEvent.new(data, self) + raise_event(event) + when :CHANNEL_INFO + data['channels'].each do |inner| + next unless (channel = @channels[inner['id'].to_i]) + + channel.process_voice_status(inner['status']) if inner.key?('status') + + channel.process_start_time(inner['voice_start_time']) if inner.key?('voice_start_time') + end when :GUILD_MEMBER_ADD add_guild_member(data) diff --git a/lib/discordrb/container.rb b/lib/discordrb/container.rb index 38877588c9..2a97f2ea57 100644 --- a/lib/discordrb/container.rb +++ b/lib/discordrb/container.rb @@ -290,6 +290,36 @@ def channel_recipient_remove(attributes = {}, &block) register_event(ChannelRecipientRemoveEvent, attributes, block) end + # This **event** is raised whenever the status for a voice channel is updated. + # @param attributes [Hash] The event's attributes. + # @option attributes [String, Regexp] :status Matches the new status of the voice channel. + # @option attributes [String, Integer, Server] :server Matches the server where the status was updated. + # @option attributes [String, Integer, Channel] :channel Matches the channel where the status was updated. + # @yield The block is executed when the event is raised. + # @yieldparam event [ChannelStatusUpdateEvent] The event that was raised. + # @return [ChannelStatusUpdateEventHandler] the event handler that was registered. + def channel_status_update(attributes = {}, &block) + register_event(ChannelStatusUpdateEvent, attributes, block) + end + + alias_method :voice_channel_status, :channel_status_update + + # This **event** is raised whenever the start time for a voice channel is updated. + # @param attributes [Hash] The event's attributes. + # @option attributes [Time] :after Matches a time after the start time of the voice channel. + # @option attributes [Time] :before Matches a time before the start time of the voice channel. + # @option attributes [Time, Integer] :start_time Unix timestamp of the new start time of the voice channel. + # @option attributes [String, Integer, Server] :server Matches the server where the start time was updated. + # @option attributes [String, Integer, Channel] :channel Matches the channel where the start time was updated. + # @yield The block is executed when the event is raised. + # @yieldparam event [ChannelStartTimeUpdateEvent] The event that was raised. + # @return [ChannelStartTimeUpdateEventHandler] the event handler that was registered. + def channel_start_time_update(attributes = {}, &block) + register_event(ChannelStartTimeUpdateEvent, attributes, block) + end + + alias_method :voice_channel_start_time, :channel_start_time_update + # This **event** is raised when a user's voice state changes. This includes when a user joins, leaves, or # moves between voice channels, as well as their mute and deaf status for themselves and on the server. # @param attributes [Hash] The event's attributes. diff --git a/lib/discordrb/data/audit_logs.rb b/lib/discordrb/data/audit_logs.rb index 3c51b56ab7..8884b5743f 100644 --- a/lib/discordrb/data/audit_logs.rb +++ b/lib/discordrb/data/audit_logs.rb @@ -73,7 +73,9 @@ class AuditLogs 166 => :onboarding_create, 167 => :onboarding_update, 190 => :home_settings_create, - 191 => :home_settings_update + 191 => :home_settings_update, + 192 => :voice_channel_status_update, + 193 => :voice_channel_status_delete }.freeze # @!visibility private @@ -94,6 +96,7 @@ class AuditLogs stage_instance_delete sticker_delete scheduled_event_delete thread_delete soundboard_sound_delete auto_moderation_rule_delete onboarding_prompt_delete message_unpin auto_moderation_block_message + voice_channel_status_delete ].freeze # @!visibility private @@ -105,6 +108,7 @@ class AuditLogs soundboard_sound_update auto_moderation_rule_update onboarding_prompt_update onboarding_update home_settings_update creator_monetization_terms_accepted auto_moderation_user_communication_disabled auto_moderation_quarantine_user + voice_channel_status_update ].freeze # @return [Hash User>] the users included in the audit logs. @@ -175,6 +179,9 @@ class Entry # @return [Symbol, nil] the type of the permission overwrite. attr_reader :overwrite_type + # @return [String, nil] the new status of the voice channel. + attr_reader :status + # @return [String, nil] the reason for this action occurring. attr_reader :reason @@ -215,6 +222,7 @@ def initialize(logs, server, bot, data) @overwrite_role_name = options['role_name'] @overwrite_id = options['id']&.to_i @overwrite_type = Overwrite::TYPES.key(options['type']) if options['type'] + @status = options['status'] == '' ? nil : options['status'] end # @return [Server, Channel, Member, User, Role, Invite, Webhook, Emoji, nil] the target being performed on. @@ -418,6 +426,7 @@ def self.target_type_for(action) when 163..165 then :onboarding_prompt when 166..167 then :onboarding when 190..191 then :home_settings + when 192..193 then :voice_channel_status else :unknown end diff --git a/lib/discordrb/data/channel.rb b/lib/discordrb/data/channel.rb index 0b387fb343..93489d5c4a 100644 --- a/lib/discordrb/data/channel.rb +++ b/lib/discordrb/data/channel.rb @@ -421,7 +421,7 @@ def nsfw? end # Get the time at when this channel was created at. - # @return [Time, nil] The time at when the channel was created at. + # @return [Time] The time at when the channel was created at. def creation_time return @create_timestamp if @create_timestamp @@ -991,6 +991,30 @@ def start_thread(name, auto_archive_duration, message: nil, type: 11) @bot.ensure_channel(JSON.parse(data)) end + # Fetch the status of the voice channel. + # @return [String, nil] The status of the voice channel, or `nil`. + def status + if !instance_variable_defined?(:@status) && voice? + @bot.gateway.send_request_channel_info(@server_id, %i[status voice_start_time]) + + sleep(0.01) until instance_variable_defined?(:@status) + end + + @status + end + + # Fetch the start time of the sesison for the voice channel. + # @return [Time, nil] The time at when the voice session started, or `nil`. + def start_time + if !instance_variable_defined?(:@start_time) && voice? + @bot.gateway.send_request_channel_info(@server_id, %i[status voice_start_time]) + + sleep(0.01) until instance_variable_defined?(:@start_time) + end + + @start_time + end + # Start a thread in a forum or media channel. # @param name [String] The name of the forum post to create. # @param auto_archive_duration [Integer, nil] How long before the post is automatically archived. @@ -1028,6 +1052,8 @@ def start_forum_thread(name:, auto_archive_duration: nil, rate_limit_per_user: n Message.new(response['message'].merge!('channel_id' => response['id'], 'thread' => response), @bot) end + # Get the emoji shown on posts in this forum or media channel. + # @return [Emoji, nil] The emoji shown on posts in this forum or media channel, or `nil`. def default_reaction @default_reaction.is_a?(Integer) ? server.emojis[@default_reaction] : @default_reaction end @@ -1144,6 +1170,7 @@ def remove_member(member) # @param position [Integer, nil] The new sorting position of the channel. Generally, this parameter should not be used. Please use {#sort_after} instead. # @param auto_archive_duration [Integer] The amount of minutes after which the thread will stop showing in the channel list. # @param default_thread_rate_limit_per_user [Integer] The default slowmode rate to set on threads created in the text or forum channel. + # @param status [String, nil] The status to set for the voice channel; between 1-500 characters, or `nil` to clear the existing status. # @param reason [String, nil] The reason to show in the server's audit log for modifying the channel. # @return [nil] def modify( @@ -1151,7 +1178,7 @@ def modify( user_limit: :undef, permission_overwrites: :undef, parent: :undef, voice_region: :undef, video_quality_mode: :undef, default_auto_archive_duration: :undef, flags: :undef, tags: :undef, default_reaction: :undef, default_sort_order: :undef, default_forum_layout: :undef, archived: :undef, locked: :undef, invitable: :undef, add_flags: :undef, remove_flags: :undef, - position: :undef, auto_archive_duration: :undef, default_thread_rate_limit_per_user: :undef, reason: nil + position: :undef, auto_archive_duration: :undef, default_thread_rate_limit_per_user: :undef, status: :undef, reason: nil ) data = { name: name, @@ -1179,7 +1206,7 @@ def modify( } if tags != :undef && (thread_only? || thread?) - tags = (thread? ? tags&.map(&:resolve_id) : tags&.map(&:to_h)) + tags = (thread? ? tags&.map(&:resolve_id)&.uniq : tags&.map(&:to_h)) data[thread_only? ? :available_tags : :applied_tags] = tags end @@ -1204,6 +1231,12 @@ def modify( data[:flags] = ((@flags & ~to_flags.call(remove_flags)) | to_flags.call(add_flags)) end + if status != :undef && voice? + API::Channel.set_voice_channel_status(@bot.token, @id, status: status, reason: reason) + + return unless data.any? { |_, value| value != :undef } + end + update_data(JSON.parse(API::Channel.update!(@bot.token, @id, **data, reason: reason))) nil end @@ -1214,7 +1247,7 @@ def inspect end # Set the last pin timestamp of a channel. - # @param time [String, nil] the time of the last pinned message in the channel + # @param time [String, nil] The time of the last pinned message in the channel. # @note For internal use only # @!visibility private def process_last_pin_timestamp(time) @@ -1222,16 +1255,32 @@ def process_last_pin_timestamp(time) end # Set the last message ID of a channel. - # @param id [Integer, nil] the ID of the last message in a channel + # @param id [Integer, nil] The ID of the last message in a channel. # @note For internal use only # @!visibility private def process_last_message_id(id) @last_message_id = id end + # Set the voice channel status of a channel. + # @param status [String, nil] The status of the voice channel. + # @note For internal use only + # @!visibility private + def process_voice_status(status) + @status = status&.empty? ? nil : status + end + + # Set the start time of a voice channel. + # @param time [Integer, nil] The start time of the voice channel. + # @note For internal use only + # @!visibility private + def process_start_time(time) + @start_time = time ? Time.at(time) : time + end + # Set the available tags of a channel. - # @param tag [Hash] the data for the tag to create - # @param reason [String, nil] the reason to show in the audit log + # @param tag [Hash] The data for the tag to create. + # @param reason [String, nil] The reason to show in the audit log. # @note For internal use only # @!visibility private def update_tags(tag, reason) diff --git a/lib/discordrb/data/server.rb b/lib/discordrb/data/server.rb index fa53fc26fa..6aeae2d3f9 100644 --- a/lib/discordrb/data/server.rb +++ b/lib/discordrb/data/server.rb @@ -1506,11 +1506,18 @@ def process_active_threads(threads) end def process_incident_actions(incidents) - incidents ||= {} - @raid_detected_at = incidents['raid_detected_at'] ? Time.parse(incidents['raid_detected_at']) : nil - @dms_disabled_until = incidents['dms_disabled_until'] ? Time.parse(incidents['dms_disabled_until']) : nil - @dm_spam_detected_at = incidents['dm_spam_detected_at'] ? Time.parse(incidents['dm_spam_detected_at']) : nil - @invites_disabled_until = incidents['invites_disabled_until'] ? Time.parse(incidents['invites_disabled_until']) : nil + incidents&.each do |key, value| + case key + when 'raid_detected_at' + @raid_detected_at = value ? Time.parse(value) : nil + when 'dms_disabled_until' + @dms_disabled_until = value ? Time.parse(value) : nil + when 'dm_spam_detected_at' + @dm_spam_detected_at = value ? Time.parse(value) : nil + when 'invites_disabled_until' + @invites_disabled_until = value ? Time.parse(value) : nil + end + end end def process_scheduled_events(events) diff --git a/lib/discordrb/events/channels.rb b/lib/discordrb/events/channels.rb index c47e201f47..54372a78e3 100644 --- a/lib/discordrb/events/channels.rb +++ b/lib/discordrb/events/channels.rb @@ -185,9 +185,8 @@ class ChannelPinsUpdateEvent < Event # @!visibility private def initialize(data, bot) @bot = bot - - @server = bot.server(data['guild_id']) if data['guild_id'] @channel = bot.channel(data['channel_id']) + @server = @channel.server if data['guild_id'] @last_pin_timestamp = Time.iso8601(data['last_pin_timestamp']) if data['last_pin_timestamp'] end end @@ -205,6 +204,110 @@ def matches?(event) end end + # Raised whenever the status of a voice channel is updated. + class ChannelStatusUpdateEvent < Event + # @return [String, nil] the new status of the voice channel. + attr_reader :status + + # @return [Server] the server that the voice channel is from. + attr_reader :server + + # @return [Channel] the channel whose voice status was updated. + attr_reader :channel + + # @!visibility private + def initialize(data, bot) + @bot = bot + @channel = bot.channel(data['id']) + @server = @channel.server + @status = data['status'] == '' ? nil : data['status'] + end + end + + # Event handler for ChannelStatusUpdateEvent. + class ChannelStatusUpdateEventHandler < EventHandler + # @!visibility private + def matches?(event) + # Check for the proper event type. + return false unless event.is_a?(ChannelStatusUpdateEvent) + + [ + matches_all(@attributes[:status], event.status) do |a, e| + case a + when Regexp + a.match?(e) if e + else + a == e + end + end, + + matches_all(@attributes[:server], event.server) do |a, e| + a&.resolve_id == e&.resolve_id + end, + + matches_all(@attributes[:channel], event.channel) do |a, e| + a&.resolve_id == e&.resolve_id + end + ].reduce(true, &:&) + end + end + + # Raised whenever the start time of a voice channel is updated. + class ChannelStartTimeUpdateEvent < Event + # @return [Server] the server associated with the event. + attr_reader :server + + # @return [Channel] the channel associated with the event. + attr_reader :channel + + # @return [Time, nil] the new start time of the voice channel. + attr_reader :start_time + + # @!visibility private + def initialize(data, bot) + @bot = bot + @channel = bot.channel(data['id']) + @server = @channel.server + @start_time = Time.at(data['voice_start_time']) if data['voice_start_time'] + end + end + + # Event handler for ChannelStartTimeUpdateEvent. + class ChannelStartTimeUpdateEventHandler < EventHandler + # @!visibility private + def matches?(event) + # Check for the proper event type. + return false unless event.is_a?(ChannelStartTimeUpdateEvent) + + [ + matches_all(@attributes[:server], event.server) do |a, e| + a&.resolve_id == e&.resolve_id + end, + + matches_all(@attributes[:channel], event.channel) do |a, e| + a&.resolve_id == e&.resolve_id + end, + + matches_all(@attributes[:after], event.start_time) do |a, e| + (e > a) if e + end, + + matches_all(@attributes[:before], event.start_time) do |a, e| + (e < a) if e + end, + + matches_all(@attributes[:start_time], event.start_time) do |a, e| + a == case a + when Integer + e.to_i + else + e + end + end + ].reduce(true, &:&) + end + end + # Raised when a user is added to a private channel class ChannelRecipientAddEvent < ChannelRecipientEvent; end diff --git a/lib/discordrb/gateway.rb b/lib/discordrb/gateway.rb index a1b135396b..37d9e5bc2d 100644 --- a/lib/discordrb/gateway.rb +++ b/lib/discordrb/gateway.rb @@ -85,6 +85,11 @@ module Opcodes # **Received**: Returned after a heartbeat was sent to the server. This allows clients to identify and deal with # zombie connections that don't dispatch any events anymore. HEARTBEAT_ACK = 11 + + # **Sent**: This opcode identifies packets used to retrieve ephemeral channel data for channels in a server. + # This opcode is required to fetch the start time and status of a voice channel if the client does not already + # have them cached. (Sending this is never necessary for a gateway client to behave correctly) + REQUEST_CHANNEL_INFO = 43 end # This class stores the data of an active gateway session. Note that this is different from a websocket connection - @@ -426,6 +431,17 @@ def send_request_members(server_id, query, limit) send_packet(Opcodes::REQUEST_MEMBERS, data) end + # Sends a request channel info (op 43). This will order Discord to send ephemeral channel data such as the status + # and voice start time of the channels in the server as dispatch events with type `CHANNEL_INFO`. It is necessary to + # use this method to get the start time of a voice channel's session, if the client has not already recieved it. + # @param fields [Array] The fields to include in the dispatch event. + # @param server_id [Integer] The ID of the server to fetch ephemeral channel data for. + def send_request_channel_info(server_id, fields) + data = { guild_id: server_id, fields: fields } + + send_packet(Opcodes::REQUEST_CHANNEL_INFO, data) + end + # Sends a custom packet over the connection. This can be useful to implement future yet unimplemented functionality # or for testing. You probably shouldn't use this unless you know what you're doing. # @param opcode [Integer] The opcode the packet should be sent as. Can be one of {Opcodes} or a custom value if diff --git a/lib/discordrb/permissions.rb b/lib/discordrb/permissions.rb index 3e0e019c72..681ed5766b 100644 --- a/lib/discordrb/permissions.rb +++ b/lib/discordrb/permissions.rb @@ -53,6 +53,7 @@ class Permissions 44 => :create_scheduled_events, # 17592186044416 45 => :use_external_sounds, # 35184372088832 46 => :send_voice_messages, # 70368744177664 + 48 => :set_voice_channel_status, # 281474976710656 49 => :send_polls, # 562949953421312 50 => :use_external_apps, # 1125899906842624 51 => :pin_messages, # 2251799813685248