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 '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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user