FEATURE: Discord Bot integration (#831)

This adds support for the a Discord bot that can search in a Discourse instance when invoked via slash commands in Discord Guild channel.
This commit is contained in:
Rafael dos Santos Silva 2024-10-16 12:41:18 -03:00 committed by GitHub
parent 3432f6654f
commit 792703c942
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 390 additions and 0 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules
/gems
/auto_generated
.env

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
module DiscourseAi
module Discord
class BotController < ::ApplicationController
requires_plugin ::DiscourseAi::PLUGIN_NAME
skip_before_action :verify_authenticity_token
def interactions
# Request signature verification
begin
verify_request!
rescue Ed25519::VerifyError
return head :unauthorized
end
body = request.body.read
interaction = JSON.parse(body, object_class: OpenStruct)
if interaction.type == 1
# Respond to Discord PING request
render json: { type: 1 }
else
if !SiteSetting.ai_discord_allowed_guilds_map.include?(interaction.guild_id)
return head :forbidden
end
response = { type: 5, data: { content: "Searching..." } }
hijack { render json: response }
# Respond to Discord command
Jobs.enqueue(:stream_discord_reply, interaction: body)
end
end
private
def verify_request!
signature = request.headers["X-Signature-Ed25519"]
timestamp = request.headers["X-Signature-Timestamp"]
verify_key.verify([signature].pack("H*"), "#{timestamp}#{request.raw_post}")
end
def verify_key
Ed25519::VerifyKey.new([SiteSetting.ai_discord_app_public_key].pack("H*")).freeze
end
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Jobs
class StreamDiscordReply < ::Jobs::Base
sidekiq_options retry: false
def execute(args)
interaction = args[:interaction]
if SiteSetting.ai_discord_search_mode == "persona"
DiscourseAi::Discord::Bot::PersonaReplier.new(interaction).handle_interaction!
else
DiscourseAi::Discord::Bot::Search.new(interaction).handle_interaction!
end
end
end
end

View File

@ -15,6 +15,10 @@ DiscourseAi::Engine.routes.draw do
get "quick-search" => "embeddings#quick_search"
end
scope module: :discord, path: "/discord", defaults: { format: :json } do
post "interactions" => "bot#interactions"
end
scope module: :ai_bot, path: "/ai-bot", defaults: { format: :json } do
get "bot-username" => "bot#show_bot_username"
get "post/:post_id/show-debug-info" => "bot#show_debug_info"

View File

@ -431,3 +431,24 @@ discourse_ai:
hidden: true
type: list
list_type: compact
ai_discord_app_id:
default: ""
client: false
ai_discord_app_public_key:
default: ""
client: false
ai_discord_search_mode:
default: "search"
type: enum
choices:
- search
- persona
ai_discord_search_persona:
default: ""
type: enum
enum: "DiscourseAi::Configuration::PersonaEnumerator"
ai_discord_allowed_guilds:
type: list
list_type: compact
default: ""

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require "enum_site_setting"
module DiscourseAi
module Configuration
class PersonaEnumerator < ::EnumSiteSetting
def self.valid_value?(val)
true
end
def self.values
AiPersona.all_personas.map { |persona| { name: persona.name, value: persona.id } }
end
end
end
end

42
lib/discord/bot/base.rb Normal file
View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
module DiscourseAi
module Discord::Bot
class Base
def initialize(body)
@interaction = JSON.parse(body, object_class: OpenStruct)
@query = @interaction.data.options.first.value
@token = @interaction.token
end
def handle_interaction!
raise NotImplementedError
end
def create_reply(reply)
api_endpoint = "https://discord.com/api/webhooks/#{SiteSetting.ai_discord_app_id}/#{@token}"
conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
response =
conn.post(
api_endpoint,
{ content: reply }.to_json,
{ "Content-Type" => "application/json" },
)
@reply_response = JSON.parse(response.body, symbolize_names: true)
end
def update_reply(reply)
api_endpoint =
"https://discord.com/api/webhooks/#{SiteSetting.ai_discord_app_id}/#{@token}/messages/@original"
conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
response =
conn.patch(
api_endpoint,
{ content: reply }.to_json,
{ "Content-Type" => "application/json" },
)
@last_update_response = JSON.parse(response.body, symbolize_names: true)
end
end
end
end

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
module DiscourseAi
module Discord::Bot
class PersonaReplier < Base
def initialize(body)
@persona =
AiPersona
.all_personas
.find { |persona| persona.id == SiteSetting.ai_discord_search_persona.to_i }
.new
@bot = DiscourseAi::AiBot::Bot.as(Discourse.system_user, persona: @persona, model: nil)
super(body)
end
def handle_interaction!
last_update_sent_at = Time.now - 1
reply = +""
full_reply =
@bot.reply(
{ conversation_context: [{ type: :user, content: @query }], skip_tool_details: true },
) do |partial, _cancel, _something|
reply << partial
next if reply.blank?
if @reply_response.nil?
create_reply(wrap_links(reply.dup))
elsif @last_update_response.nil?
update_reply(wrap_links(reply.dup))
elsif Time.now - last_update_sent_at > 1
update_reply(wrap_links(reply.dup))
last_update_sent_at = Time.now
end
end
discord_reply = wrap_links(full_reply.last.first)
if @reply_response.nil?
create_reply(discord_reply)
else
update_reply(discord_reply)
end
end
def wrap_links(text)
text.gsub(%r{(?<url>https?://[^\s]+)}, "<\\k<url>>")
end
end
end
end

36
lib/discord/bot/search.rb Normal file
View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
module DiscourseAi
module Discord::Bot
class Search < Base
def initialize(body)
@search = DiscourseAi::AiBot::Tools::Search
super(body)
end
def handle_interaction!
results =
@search.new(
{ search_query: @query },
persona_options: {
"max_results" => 10,
},
bot_user: nil,
llm: nil,
).invoke(&Proc.new {})
formatted_results = results[:rows].map.with_index { |result, index| <<~RESULT }.join("\n")
#{index + 1}. [#{result[0]}](<#{Discourse.base_url}#{result[1]}>)
RESULT
reply = <<~REPLY
Here are the top search results for your query:
#{formatted_results}
REPLY
create_reply(reply)
end
end
end
end

View File

@ -10,6 +10,7 @@
gem "tokenizers", "0.4.4"
gem "tiktoken_ruby", "0.0.9"
gem "ed25519", "1.2.4" #TODO remove this as existing ssl gem should handle this
enabled_site_setting :discourse_ai_enabled

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Jobs::StreamDiscordReply, type: :job do
let(:interaction) do
{
type: 2,
data: {
options: [{ value: "test query" }],
},
token: "interaction_token",
}.to_json.to_s
end
before do
SiteSetting.ai_discord_search_mode = "persona"
SiteSetting.ai_discord_search_persona = -1
end
it "calls PersonaReplier when search mode is persona" do
expect_any_instance_of(DiscourseAi::Discord::Bot::PersonaReplier).to receive(
:handle_interaction!,
)
described_class.new.execute(interaction: interaction)
end
it "calls Search when search mode is not persona" do
SiteSetting.ai_discord_search_mode = "search"
expect_any_instance_of(DiscourseAi::Discord::Bot::Search).to receive(:handle_interaction!)
described_class.new.execute(interaction: interaction)
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe DiscourseAi::Discord::Bot::PersonaReplier do
let(:interaction_body) do
{ data: { options: [{ value: "test query" }] }, token: "interaction_token" }.to_json.to_s
end
let(:persona_replier) { described_class.new(interaction_body) }
before do
SiteSetting.ai_discord_search_persona = "-1"
allow_any_instance_of(DiscourseAi::AiBot::Bot).to receive(:reply).and_return(
"This is a reply from bot!",
)
allow(persona_replier).to receive(:create_reply)
end
describe "#handle_interaction!" do
it "creates and updates replies" do
persona_replier.handle_interaction!
expect(persona_replier).to have_received(:create_reply).at_least(:once)
end
end
end

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe DiscourseAi::Discord::Bot::Search do
let(:interaction_body) do
{ data: { options: [{ value: "test query" }] }, token: "interaction_token" }.to_json.to_s
end
let(:search) { described_class.new(interaction_body) }
before do
stub_request(:post, "https://discord.com/api/webhooks//interaction_token").with(
body:
"{\"content\":\"Here are the top search results for your query:\\n\\n1. [Title](\\u003chttp://test.localhost/link\\u003e)\\n\\n\"}",
).to_return(status: 200, body: "{}", headers: {})
# Stub the create_reply method
allow(search).to receive(:create_reply)
end
describe "#handle_interaction!" do
it "creates a reply with search results" do
allow_any_instance_of(DiscourseAi::AiBot::Tools::Search).to receive(:invoke).and_return(
{ rows: [%w[Title /link]] },
)
search.handle_interaction!
expect(search).to have_received(:create_reply).with(/Here are the top search results/)
end
end
end

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "DiscourseAi::Discord::BotController", type: :request do
let(:public_key) { "your_public_key_here" }
let(:signature) { "valid_signature" }
let(:timestamp) { Time.now.to_i.to_s }
let(:body) { { type: 1 }.to_json }
let(:headers) { { "X-Signature-Ed25519" => signature, "X-Signature-Timestamp" => timestamp } }
before do
SiteSetting.ai_discord_app_public_key = public_key
allow_any_instance_of(DiscourseAi::Discord::BotController).to receive(
:verify_request!,
).and_return(true)
end
describe "POST /discourse-ai/discord/interactions" do
context "when interaction type is 1 (PING)" do
it "responds with type 1" do
post "/discourse-ai/discord/interactions", params: body, headers: headers
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)).to eq("type" => 1)
end
end
context "when interaction type is not 1" do
let(:guild_id) { "1234567890" }
let(:interaction_body) do
{
type: 2,
guild_id: guild_id,
data: {
options: [{ value: "test query" }],
},
token: "interaction_token",
}.to_json
end
before do
allow(SiteSetting).to receive(:ai_discord_allowed_guilds_map).and_return([guild_id])
end
xit "enqueues a job to handle the interaction" do
expect {
post "/discourse-ai/discord/interactions", params: interaction_body, headers: headers
}.to have_enqueued_job(Jobs::StreamDiscordReply)
end
it "responds with a deferred message" do
post "/discourse-ai/discord/interactions", params: interaction_body, headers: headers
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)).to eq(
"type" => 5,
"data" => {
"content" => "Searching...",
},
)
end
end
end
end