discord: add initial slash command integration.

Add initial support for the `/count <tags>` and `/posts <tags>` slash commands.

Slash commands are basically like webhooks; we register a command, and
when anybody types that command in Discord, Discord sends us a HTTP
request that we respond to.

* https://discord.com/developers/docs/interactions/slash-commands
* https://support.discord.com/hc/en-us/articles/1500000368501-Slash-Commands-FAQ
This commit is contained in:
evazion
2021-03-11 02:55:50 -06:00
parent 1c2f3abe56
commit 2c8c7ff80a
8 changed files with 321 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"