diff --git a/Gemfile b/Gemfile index f009eb6c4..3bc6cd97e 100644 --- a/Gemfile +++ b/Gemfile @@ -49,6 +49,7 @@ gem 'tzinfo-data' gem 'hsluv' gem 'google-cloud-bigquery', require: "google/cloud/bigquery" gem 'google-cloud-storage', require: "google/cloud/storage" +gem 'ed25519' group :production, :staging do gem 'unicorn', :platforms => :ruby diff --git a/Gemfile.lock b/Gemfile.lock index d85a361dd..97fc8a367 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -178,6 +178,7 @@ GEM dotenv-rails (2.7.6) dotenv (= 2.7.6) railties (>= 3.2) + ed25519 (1.2.4) erubi (1.10.0) factory_bot (6.1.0) activesupport (>= 5.0.0) @@ -505,6 +506,7 @@ DEPENDENCIES diff-lcs dotenv-rails dtext_rb! + ed25519 factory_bot ffaker flamegraph diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 0ff5cdeae..3eea9a092 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -1,11 +1,16 @@ class WebhooksController < ApplicationController skip_forgery_protection only: :receive rescue_with Stripe::SignatureVerificationError, status: 400 + rescue_with DiscordSlashCommand::WebhookVerificationError, status: 401 def receive - if params[:source] == "stripe" + case params[:source] + when "stripe" UserUpgrade.receive_webhook(request) head 200 + when "discord" + json = DiscordSlashCommand.receive_webhook(request) + render json: json else head 400 end diff --git a/app/logical/discord_api_client.rb b/app/logical/discord_api_client.rb new file mode 100644 index 000000000..72af6a822 --- /dev/null +++ b/app/logical/discord_api_client.rb @@ -0,0 +1,51 @@ +class DiscordApiClient + extend Memoist + + BASE_URL = "https://discord.com/api/v8" + + attr_reader :application_id, :guild_id, :bot_token, :http + + def initialize(application_id: Danbooru.config.discord_application_client_id, guild_id: Danbooru.config.discord_guild_id, bot_token: Danbooru.config.discord_bot_token, http: Danbooru::Http.new) + @application_id = application_id + @guild_id = guild_id + @bot_token = bot_token + @http = http + end + + def register_slash_command(name:, description:, options: []) + json = { + name: name, + description: description, + options: options + } + + post("/applications/#{application_id}/guilds/#{guild_id}/commands", json) + end + + def get_channel(channel_id) + get("/channels/#{channel_id}") + end + + def me + get("/users/@me") + end + + def get(url) + client.get("#{BASE_URL}/#{url}").parse + end + + def post(url, data) + client.post("#{BASE_URL}/#{url}", json: data).parse + end + + def client + headers = { + "User-Agent": "#{Danbooru.config.canonical_app_name} (#{Danbooru.config.source_code_url}, 1.0)", + "Authorization": "Bot #{bot_token}" + } + + http.headers(headers) + end + + memoize :client +end diff --git a/app/logical/discord_slash_command.rb b/app/logical/discord_slash_command.rb new file mode 100644 index 000000000..770ebe04e --- /dev/null +++ b/app/logical/discord_slash_command.rb @@ -0,0 +1,117 @@ +class DiscordSlashCommand + class WebhookVerificationError < StandardError; end + + COMMANDS = { + count: DiscordSlashCommand::CountCommand, + posts: DiscordSlashCommand::PostsCommand, + } + + # 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#applicationcommandoptiontype + module ApplicationCommandOptionType + String = 3 + end + + 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 + + # The name of the slash command. + def name + raise NotImplementedError + end + + # A description of the slash command. + def description + raise NotImplementedError + end + + # The parameters of the slash command. + # https://discord.com/developers/docs/interactions/slash-commands#applicationcommandoption + def options + [] + end + + # Should return the response to the command. + def call + # respond_with("message") + raise NotImplementedError + end + + concerning :HelperMethods do + # The parameters passed to the command. A hash. + def params + @params ||= data.dig(:data, :options).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: 4, **options) + { + type: type, + data: { + content: content, + **options + } + } + end + + # Register the command with the Discord API (replacing it if it already exists). + # https://discord.com/developers/docs/interactions/slash-commands#registering-a-command + def register_slash_command + discord.register_slash_command(name: name, description: description, options: options) + 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: InteractionType::Ping } + when InteractionType::ApplicationCommand + name = data.dig(:data, :name) + klass = 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 diff --git a/app/logical/discord_slash_command/count_command.rb b/app/logical/discord_slash_command/count_command.rb new file mode 100644 index 000000000..6211cdcb1 --- /dev/null +++ b/app/logical/discord_slash_command/count_command.rb @@ -0,0 +1,28 @@ +class DiscordSlashCommand + class CountCommand < DiscordSlashCommand + def name + "count" + end + + def description + "Do a tag search and return the number of results" + end + + def options + [{ + name: "tags", + description: "The tags to search", + required: true, + type: ApplicationCommandOptionType::String + }] + end + + def call + tags = params[:tags] + query = PostQueryBuilder.new(tags, User.anonymous).normalized_query + count = query.fast_count(estimate_count: true, skip_cache: true) + + respond_with("`#{tags}`: #{count} posts") + end + end +end diff --git a/app/logical/discord_slash_command/posts_command.rb b/app/logical/discord_slash_command/posts_command.rb new file mode 100644 index 000000000..135035904 --- /dev/null +++ b/app/logical/discord_slash_command/posts_command.rb @@ -0,0 +1,93 @@ +class DiscordSlashCommand + class PostsCommand < DiscordSlashCommand + extend Memoist + + def name + "posts" + end + + def description + "Do a tag search" + end + + def options + [{ + name: "tags", + description: "The tags to search", + required: true, + type: ApplicationCommandOptionType::String + }] + end + + def call + tags = params[:tags] + query = PostQueryBuilder.new(tags, User.anonymous).normalized_query + + limit = query.find_metatag(:limit) || 3 + limit = limit.to_i.clamp(1, 10) + posts = query.build.paginate(1, limit: limit) + embeds = posts.map { |post| post_embed(post) } + + respond_with(embeds: embeds) + end + + def post_embed(post) + { + title: post.dtext_shortlink, + url: Routes.url_for(post), + timestamp: post.created_at.iso8601, + color: post_embed_color(post), + footer: post_embed_footer(post), + image: { + width: post.image_width, + height: post.image_height, + url: post_embed_image(post), + }, + } + end + + def post_embed_image(post, blur: 50) + if is_censored?(post) + nil + elsif post.file_ext.match?(/jpe?g|png|gif/) + post.file_url + else + post.preview_file_url + end + end + + def post_embed_color(post) + if post.is_flagged? + 0xC41C19 + elsif post.is_pending? + 0x0000FF + elsif post.parent_id.present? + 0xC0C000 + elsif post.has_active_children? + 0x00FF00 + elsif post.is_deleted? + 0xFFFFFF + else + nil + end + end + + def post_embed_footer(post) + dimensions = "#{post.image_width}x#{post.image_height}" + file_size = post.file_size.to_s(:human_size, precision: 4) + text = "Rating: #{post.rating.upcase} | #{dimensions} (#{file_size} #{post.file_ext})" + + { text: text } + end + + def is_censored?(post) + post.rating != "s" && !is_nsfw_channel? + end + + def is_nsfw_channel? + discord.get_channel(data[:channel_id]).fetch("nsfw") + end + + memoize :is_nsfw_channel? + end +end diff --git a/config/danbooru_default_config.rb b/config/danbooru_default_config.rb index 79ce504f1..f8204caf1 100644 --- a/config/danbooru_default_config.rb +++ b/config/danbooru_default_config.rb @@ -402,6 +402,29 @@ module Danbooru def discord_webhook_secret end + # Settings used for Discord slash commands. + # + # * Go to https://discord.com/developers/applications + # * Create an application. + # * Copy the client ID and public key. + # * Create a bot user. + # * Copy the bot token. + # * Go to the OAuth2 page, select the `bot` and `applications.commands` + # scopes, and the `Administrator` permission, then follow the oauth2 + # link to add the bot to the Discord server. + def discord_application_client_id + end + + def discord_application_public_key + end + + def discord_bot_token + end + + # The ID of the Discord server to register slash commands for. + def discord_guild_id + end + # you should override this def email_key "zDMSATq0W3hmA5p3rKTgD"