From 792703c94205e95f7547a54c35fc6473c478d2cd Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Wed, 16 Oct 2024 12:41:18 -0300 Subject: [PATCH] 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. --- .gitignore | 1 + .../discourse_ai/discord/bot_controller.rb | 50 +++++++++++++++ app/jobs/regular/stream_discord_reply.rb | 17 +++++ config/routes.rb | 4 ++ config/settings.yml | 21 +++++++ lib/configuration/persona_enumerator.rb | 17 +++++ lib/discord/bot/base.rb | 42 +++++++++++++ lib/discord/bot/persona_replier.rb | 50 +++++++++++++++ lib/discord/bot/search.rb | 36 +++++++++++ plugin.rb | 1 + .../jobs/regular/stream_discord_reply_spec.rb | 33 ++++++++++ spec/lib/discord/bot/persona_replier_spec.rb | 25 ++++++++ spec/lib/discord/bot/search_spec.rb | 30 +++++++++ spec/requests/discord/bot_controller_spec.rb | 63 +++++++++++++++++++ 14 files changed, 390 insertions(+) create mode 100644 app/controllers/discourse_ai/discord/bot_controller.rb create mode 100644 app/jobs/regular/stream_discord_reply.rb create mode 100644 lib/configuration/persona_enumerator.rb create mode 100644 lib/discord/bot/base.rb create mode 100644 lib/discord/bot/persona_replier.rb create mode 100644 lib/discord/bot/search.rb create mode 100644 spec/jobs/regular/stream_discord_reply_spec.rb create mode 100644 spec/lib/discord/bot/persona_replier_spec.rb create mode 100644 spec/lib/discord/bot/search_spec.rb create mode 100644 spec/requests/discord/bot_controller_spec.rb diff --git a/.gitignore b/.gitignore index 74cad9db..3b519490 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules /gems /auto_generated +.env diff --git a/app/controllers/discourse_ai/discord/bot_controller.rb b/app/controllers/discourse_ai/discord/bot_controller.rb new file mode 100644 index 00000000..d553aa27 --- /dev/null +++ b/app/controllers/discourse_ai/discord/bot_controller.rb @@ -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 diff --git a/app/jobs/regular/stream_discord_reply.rb b/app/jobs/regular/stream_discord_reply.rb new file mode 100644 index 00000000..9aca1ef5 --- /dev/null +++ b/app/jobs/regular/stream_discord_reply.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 3ad50caa..461e8b4b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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" diff --git a/config/settings.yml b/config/settings.yml index 98110530..2a3f5ad0 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -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: "" diff --git a/lib/configuration/persona_enumerator.rb b/lib/configuration/persona_enumerator.rb new file mode 100644 index 00000000..c44dd77d --- /dev/null +++ b/lib/configuration/persona_enumerator.rb @@ -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 diff --git a/lib/discord/bot/base.rb b/lib/discord/bot/base.rb new file mode 100644 index 00000000..abb87ac1 --- /dev/null +++ b/lib/discord/bot/base.rb @@ -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 diff --git a/lib/discord/bot/persona_replier.rb b/lib/discord/bot/persona_replier.rb new file mode 100644 index 00000000..66fbe725 --- /dev/null +++ b/lib/discord/bot/persona_replier.rb @@ -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{(?https?://[^\s]+)}, "<\\k>") + end + end + end +end diff --git a/lib/discord/bot/search.rb b/lib/discord/bot/search.rb new file mode 100644 index 00000000..051639ef --- /dev/null +++ b/lib/discord/bot/search.rb @@ -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 diff --git a/plugin.rb b/plugin.rb index 3289a10d..be0434ba 100644 --- a/plugin.rb +++ b/plugin.rb @@ -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 diff --git a/spec/jobs/regular/stream_discord_reply_spec.rb b/spec/jobs/regular/stream_discord_reply_spec.rb new file mode 100644 index 00000000..aa468b75 --- /dev/null +++ b/spec/jobs/regular/stream_discord_reply_spec.rb @@ -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 diff --git a/spec/lib/discord/bot/persona_replier_spec.rb b/spec/lib/discord/bot/persona_replier_spec.rb new file mode 100644 index 00000000..53050cf7 --- /dev/null +++ b/spec/lib/discord/bot/persona_replier_spec.rb @@ -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 diff --git a/spec/lib/discord/bot/search_spec.rb b/spec/lib/discord/bot/search_spec.rb new file mode 100644 index 00000000..7d4cebff --- /dev/null +++ b/spec/lib/discord/bot/search_spec.rb @@ -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 diff --git a/spec/requests/discord/bot_controller_spec.rb b/spec/requests/discord/bot_controller_spec.rb new file mode 100644 index 00000000..f791f25c --- /dev/null +++ b/spec/requests/discord/bot_controller_spec.rb @@ -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