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:
parent
3432f6654f
commit
792703c942
|
@ -1,3 +1,4 @@
|
|||
node_modules
|
||||
/gems
|
||||
/auto_generated
|
||||
.env
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
@ -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: ""
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue