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:
1
Gemfile
1
Gemfile
@@ -49,6 +49,7 @@ gem 'tzinfo-data'
|
|||||||
gem 'hsluv'
|
gem 'hsluv'
|
||||||
gem 'google-cloud-bigquery', require: "google/cloud/bigquery"
|
gem 'google-cloud-bigquery', require: "google/cloud/bigquery"
|
||||||
gem 'google-cloud-storage', require: "google/cloud/storage"
|
gem 'google-cloud-storage', require: "google/cloud/storage"
|
||||||
|
gem 'ed25519'
|
||||||
|
|
||||||
group :production, :staging do
|
group :production, :staging do
|
||||||
gem 'unicorn', :platforms => :ruby
|
gem 'unicorn', :platforms => :ruby
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ GEM
|
|||||||
dotenv-rails (2.7.6)
|
dotenv-rails (2.7.6)
|
||||||
dotenv (= 2.7.6)
|
dotenv (= 2.7.6)
|
||||||
railties (>= 3.2)
|
railties (>= 3.2)
|
||||||
|
ed25519 (1.2.4)
|
||||||
erubi (1.10.0)
|
erubi (1.10.0)
|
||||||
factory_bot (6.1.0)
|
factory_bot (6.1.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
@@ -505,6 +506,7 @@ DEPENDENCIES
|
|||||||
diff-lcs
|
diff-lcs
|
||||||
dotenv-rails
|
dotenv-rails
|
||||||
dtext_rb!
|
dtext_rb!
|
||||||
|
ed25519
|
||||||
factory_bot
|
factory_bot
|
||||||
ffaker
|
ffaker
|
||||||
flamegraph
|
flamegraph
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
class WebhooksController < ApplicationController
|
class WebhooksController < ApplicationController
|
||||||
skip_forgery_protection only: :receive
|
skip_forgery_protection only: :receive
|
||||||
rescue_with Stripe::SignatureVerificationError, status: 400
|
rescue_with Stripe::SignatureVerificationError, status: 400
|
||||||
|
rescue_with DiscordSlashCommand::WebhookVerificationError, status: 401
|
||||||
|
|
||||||
def receive
|
def receive
|
||||||
if params[:source] == "stripe"
|
case params[:source]
|
||||||
|
when "stripe"
|
||||||
UserUpgrade.receive_webhook(request)
|
UserUpgrade.receive_webhook(request)
|
||||||
head 200
|
head 200
|
||||||
|
when "discord"
|
||||||
|
json = DiscordSlashCommand.receive_webhook(request)
|
||||||
|
render json: json
|
||||||
else
|
else
|
||||||
head 400
|
head 400
|
||||||
end
|
end
|
||||||
|
|||||||
51
app/logical/discord_api_client.rb
Normal file
51
app/logical/discord_api_client.rb
Normal 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
|
||||||
117
app/logical/discord_slash_command.rb
Normal file
117
app/logical/discord_slash_command.rb
Normal 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
|
||||||
28
app/logical/discord_slash_command/count_command.rb
Normal file
28
app/logical/discord_slash_command/count_command.rb
Normal 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
|
||||||
93
app/logical/discord_slash_command/posts_command.rb
Normal file
93
app/logical/discord_slash_command/posts_command.rb
Normal 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
|
||||||
@@ -402,6 +402,29 @@ module Danbooru
|
|||||||
def discord_webhook_secret
|
def discord_webhook_secret
|
||||||
end
|
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
|
# you should override this
|
||||||
def email_key
|
def email_key
|
||||||
"zDMSATq0W3hmA5p3rKTgD"
|
"zDMSATq0W3hmA5p3rKTgD"
|
||||||
|
|||||||
Reference in New Issue
Block a user