192 lines
6.5 KiB
Ruby
192 lines
6.5 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# The parent class for a Discord slash command. Each slash command handled by
|
|
# Danbooru is a subclass of this class. Subclasses should set the {#name},
|
|
# {#description}, and {#options} class attributes defining the slash command's
|
|
# parameters, then implement the {#call} method to return a response to the
|
|
# command.
|
|
#
|
|
# The lifecycle of a Discord slash command is this:
|
|
#
|
|
# * First we register the command with Discord with an API call ({DiscordApiClient#register_slash_command}).
|
|
# * Then whenever someone uses the command, Discord sends us a HTTP request to
|
|
# https://danbooru.donmai.us/webhooks/receive.
|
|
# * We validate the request really came from Discord, then return the
|
|
# command's response.
|
|
#
|
|
# @abstract
|
|
# @see DiscordApiClient
|
|
# @see WebhooksController#receive
|
|
# @see https://discord.com/developers/docs/interactions/slash-commands
|
|
class DiscordSlashCommand
|
|
class WebhookVerificationError < StandardError; end
|
|
|
|
# https://discord.com/developers/docs/interactions/slash-commands#interaction-interactiontype
|
|
module InteractionType
|
|
Ping = 1
|
|
ApplicationCommand = 2
|
|
end
|
|
|
|
# https://discord.com/developers/docs/interactions/slash-commands#interaction-response-interactionresponsetype
|
|
module InteractionResponseType
|
|
Pong = 1
|
|
ChannelMessageWithSource = 4
|
|
DeferredChannelMessageWithSource = 5
|
|
end
|
|
|
|
# https://discord.com/developers/docs/interactions/slash-commands#applicationcommandoptiontype
|
|
module ApplicationCommandOptionType
|
|
String = 3
|
|
Integer = 4
|
|
Boolean = 5
|
|
end
|
|
|
|
# The name of the slash command.
|
|
class_attribute :name
|
|
|
|
# A description of the slash command.
|
|
class_attribute :description
|
|
|
|
# The parameters of the slash command.
|
|
# https://discord.com/developers/docs/interactions/slash-commands#applicationcommandoption
|
|
class_attribute :options, default: []
|
|
|
|
attr_reader :data, :discord
|
|
|
|
# `data` is the the interaction data sent to us by Discord for the command.
|
|
# https://discord.com/developers/docs/interactions/slash-commands#interaction
|
|
def initialize(data: {}, discord: DiscordApiClient.new)
|
|
@data = data
|
|
@discord = discord
|
|
end
|
|
|
|
# Should return the response to the command.
|
|
def call
|
|
# respond_with("message")
|
|
raise NotImplementedError
|
|
end
|
|
|
|
concerning :HelperMethods do
|
|
# @return [Hash] The parameters passed to the slash command by the Discord user.
|
|
def params
|
|
@params ||= data.dig(:data, :options).to_a.map do |opt|
|
|
[opt[:name], opt[:value]]
|
|
end.to_h.with_indifferent_access
|
|
end
|
|
|
|
# https://discord.com/developers/docs/interactions/slash-commands#responding-to-an-interaction
|
|
# https://discord.com/developers/docs/interactions/slash-commands#interaction-response
|
|
def respond_with(content = nil, type: InteractionResponseType::ChannelMessageWithSource, posts: [], **options)
|
|
if posts.present?
|
|
embeds = posts.map { |post| DiscordSlashCommand::PostEmbed.new(post, self).to_h }
|
|
options[:embeds] = embeds
|
|
end
|
|
|
|
{
|
|
type: type,
|
|
data: {
|
|
content: content,
|
|
**options
|
|
}
|
|
}
|
|
end
|
|
|
|
# Post a response to the command later, after it's ready.
|
|
# https://discord.com/developers/docs/interactions/slash-commands#interaction-response
|
|
def respond_later(&block)
|
|
create_deferred_followup(&block)
|
|
trigger_typing_indicator
|
|
respond_with(type: InteractionResponseType::DeferredChannelMessageWithSource)
|
|
end
|
|
|
|
def create_deferred_followup(&block)
|
|
Thread.new do
|
|
params = block.call
|
|
create_followup_message(**params)
|
|
rescue StandardError => e
|
|
create_followup_message(content: "`Error: #{e.message}`")
|
|
end
|
|
end
|
|
|
|
def get_channel_messages(**options)
|
|
discord.get_channel_messages(data[:channel_id], **options)
|
|
end
|
|
|
|
def trigger_typing_indicator
|
|
discord.trigger_typing_indicator(data[:channel_id])
|
|
end
|
|
|
|
def create_followup_message(**options)
|
|
discord.create_followup_message(data[:token], **options)
|
|
end
|
|
|
|
def channel
|
|
discord.get_channel(data[:channel_id], cache: 1.minute)
|
|
end
|
|
|
|
class_methods do
|
|
# Register all commands with Discord.
|
|
def register_slash_commands!
|
|
slash_commands.values.each(&:register_slash_command!)
|
|
end
|
|
|
|
# Register the command with Discord (replacing it if it already exists).
|
|
# https://discord.com/developers/docs/interactions/slash-commands#registering-a-command
|
|
def register_slash_command!(discord: DiscordApiClient.new, guild_id: Danbooru.config.discord_guild_id)
|
|
discord.register_slash_command(name: name, description: description, options: options, guild_id: guild_id)
|
|
end
|
|
|
|
def slash_commands
|
|
{
|
|
count: DiscordSlashCommand::CountCommand,
|
|
posts: DiscordSlashCommand::PostsCommand,
|
|
random: DiscordSlashCommand::RandomCommand,
|
|
tagme: DiscordSlashCommand::TagmeCommand,
|
|
time: DiscordSlashCommand::TimeCommand,
|
|
wiki: DiscordSlashCommand::WikiCommand,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
|
|
concerning :WebhookMethods do
|
|
class_methods do
|
|
# Called when we receive a command from Discord. Instantiates a
|
|
# DiscordSlashCommand and calls the `call` method.
|
|
# https://discord.com/developers/docs/interactions/slash-commands#interaction
|
|
def receive_webhook(request)
|
|
data = verify_request!(request)
|
|
|
|
case data[:type]
|
|
when InteractionType::Ping
|
|
{ type: InteractionResponseType::Pong }
|
|
when InteractionType::ApplicationCommand
|
|
name = data.dig(:data, :name)
|
|
klass = slash_commands.fetch(name&.to_sym)
|
|
klass.new(data: data).call
|
|
else
|
|
raise NotImplementedError, "unknown Discord interaction type #{data[:type]}"
|
|
end
|
|
end
|
|
|
|
# https://discord.com/developers/docs/interactions/slash-commands#security-and-authorization
|
|
def verify_request!(request, public_key: Danbooru.config.discord_application_public_key)
|
|
timestamp = request.headers["X-Signature-Timestamp"].to_s
|
|
signature = request.headers["X-Signature-Ed25519"].to_s
|
|
signature_bytes = [signature].pack("H*")
|
|
|
|
body = request.body.read
|
|
message = timestamp + body
|
|
|
|
public_key_bytes = [public_key].pack("H*")
|
|
verify_key = Ed25519::VerifyKey.new(public_key_bytes)
|
|
|
|
verify_key.verify(signature_bytes, message)
|
|
JSON.parse(body).with_indifferent_access
|
|
rescue Ed25519::VerifyError
|
|
raise WebhookVerificationError
|
|
end
|
|
end
|
|
end
|
|
end
|