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
|
node_modules
|
||||||
/gems
|
/gems
|
||||||
/auto_generated
|
/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"
|
get "quick-search" => "embeddings#quick_search"
|
||||||
end
|
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
|
scope module: :ai_bot, path: "/ai-bot", defaults: { format: :json } do
|
||||||
get "bot-username" => "bot#show_bot_username"
|
get "bot-username" => "bot#show_bot_username"
|
||||||
get "post/:post_id/show-debug-info" => "bot#show_debug_info"
|
get "post/:post_id/show-debug-info" => "bot#show_debug_info"
|
||||||
|
|
|
@ -431,3 +431,24 @@ discourse_ai:
|
||||||
hidden: true
|
hidden: true
|
||||||
type: list
|
type: list
|
||||||
list_type: compact
|
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 "tokenizers", "0.4.4"
|
||||||
gem "tiktoken_ruby", "0.0.9"
|
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
|
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