Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 792703c

Browse files
authored
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.
1 parent 3432f66 commit 792703c

File tree

14 files changed

+390
-0
lines changed

14 files changed

+390
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
node_modules
22
/gems
33
/auto_generated
4+
.env
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module Discord
5+
class BotController < ::ApplicationController
6+
requires_plugin ::DiscourseAi::PLUGIN_NAME
7+
8+
skip_before_action :verify_authenticity_token
9+
10+
def interactions
11+
# Request signature verification
12+
begin
13+
verify_request!
14+
rescue Ed25519::VerifyError
15+
return head :unauthorized
16+
end
17+
18+
body = request.body.read
19+
interaction = JSON.parse(body, object_class: OpenStruct)
20+
21+
if interaction.type == 1
22+
# Respond to Discord PING request
23+
render json: { type: 1 }
24+
else
25+
if !SiteSetting.ai_discord_allowed_guilds_map.include?(interaction.guild_id)
26+
return head :forbidden
27+
end
28+
29+
response = { type: 5, data: { content: "Searching..." } }
30+
hijack { render json: response }
31+
32+
# Respond to Discord command
33+
Jobs.enqueue(:stream_discord_reply, interaction: body)
34+
end
35+
end
36+
37+
private
38+
39+
def verify_request!
40+
signature = request.headers["X-Signature-Ed25519"]
41+
timestamp = request.headers["X-Signature-Timestamp"]
42+
verify_key.verify([signature].pack("H*"), "#{timestamp}#{request.raw_post}")
43+
end
44+
45+
def verify_key
46+
Ed25519::VerifyKey.new([SiteSetting.ai_discord_app_public_key].pack("H*")).freeze
47+
end
48+
end
49+
end
50+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class StreamDiscordReply < ::Jobs::Base
5+
sidekiq_options retry: false
6+
7+
def execute(args)
8+
interaction = args[:interaction]
9+
10+
if SiteSetting.ai_discord_search_mode == "persona"
11+
DiscourseAi::Discord::Bot::PersonaReplier.new(interaction).handle_interaction!
12+
else
13+
DiscourseAi::Discord::Bot::Search.new(interaction).handle_interaction!
14+
end
15+
end
16+
end
17+
end

config/routes.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
get "quick-search" => "embeddings#quick_search"
1616
end
1717

18+
scope module: :discord, path: "/discord", defaults: { format: :json } do
19+
post "interactions" => "bot#interactions"
20+
end
21+
1822
scope module: :ai_bot, path: "/ai-bot", defaults: { format: :json } do
1923
get "bot-username" => "bot#show_bot_username"
2024
get "post/:post_id/show-debug-info" => "bot#show_debug_info"

config/settings.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,24 @@ discourse_ai:
431431
hidden: true
432432
type: list
433433
list_type: compact
434+
435+
ai_discord_app_id:
436+
default: ""
437+
client: false
438+
ai_discord_app_public_key:
439+
default: ""
440+
client: false
441+
ai_discord_search_mode:
442+
default: "search"
443+
type: enum
444+
choices:
445+
- search
446+
- persona
447+
ai_discord_search_persona:
448+
default: ""
449+
type: enum
450+
enum: "DiscourseAi::Configuration::PersonaEnumerator"
451+
ai_discord_allowed_guilds:
452+
type: list
453+
list_type: compact
454+
default: ""
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
require "enum_site_setting"
4+
5+
module DiscourseAi
6+
module Configuration
7+
class PersonaEnumerator < ::EnumSiteSetting
8+
def self.valid_value?(val)
9+
true
10+
end
11+
12+
def self.values
13+
AiPersona.all_personas.map { |persona| { name: persona.name, value: persona.id } }
14+
end
15+
end
16+
end
17+
end

lib/discord/bot/base.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module Discord::Bot
5+
class Base
6+
def initialize(body)
7+
@interaction = JSON.parse(body, object_class: OpenStruct)
8+
@query = @interaction.data.options.first.value
9+
@token = @interaction.token
10+
end
11+
12+
def handle_interaction!
13+
raise NotImplementedError
14+
end
15+
16+
def create_reply(reply)
17+
api_endpoint = "https://discord.com/api/webhooks/#{SiteSetting.ai_discord_app_id}/#{@token}"
18+
conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
19+
response =
20+
conn.post(
21+
api_endpoint,
22+
{ content: reply }.to_json,
23+
{ "Content-Type" => "application/json" },
24+
)
25+
@reply_response = JSON.parse(response.body, symbolize_names: true)
26+
end
27+
28+
def update_reply(reply)
29+
api_endpoint =
30+
"https://discord.com/api/webhooks/#{SiteSetting.ai_discord_app_id}/#{@token}/messages/@original"
31+
conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
32+
response =
33+
conn.patch(
34+
api_endpoint,
35+
{ content: reply }.to_json,
36+
{ "Content-Type" => "application/json" },
37+
)
38+
@last_update_response = JSON.parse(response.body, symbolize_names: true)
39+
end
40+
end
41+
end
42+
end

lib/discord/bot/persona_replier.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module Discord::Bot
5+
class PersonaReplier < Base
6+
def initialize(body)
7+
@persona =
8+
AiPersona
9+
.all_personas
10+
.find { |persona| persona.id == SiteSetting.ai_discord_search_persona.to_i }
11+
.new
12+
@bot = DiscourseAi::AiBot::Bot.as(Discourse.system_user, persona: @persona, model: nil)
13+
super(body)
14+
end
15+
16+
def handle_interaction!
17+
last_update_sent_at = Time.now - 1
18+
reply = +""
19+
full_reply =
20+
@bot.reply(
21+
{ conversation_context: [{ type: :user, content: @query }], skip_tool_details: true },
22+
) do |partial, _cancel, _something|
23+
reply << partial
24+
next if reply.blank?
25+
26+
if @reply_response.nil?
27+
create_reply(wrap_links(reply.dup))
28+
elsif @last_update_response.nil?
29+
update_reply(wrap_links(reply.dup))
30+
elsif Time.now - last_update_sent_at > 1
31+
update_reply(wrap_links(reply.dup))
32+
last_update_sent_at = Time.now
33+
end
34+
end
35+
36+
discord_reply = wrap_links(full_reply.last.first)
37+
38+
if @reply_response.nil?
39+
create_reply(discord_reply)
40+
else
41+
update_reply(discord_reply)
42+
end
43+
end
44+
45+
def wrap_links(text)
46+
text.gsub(%r{(?<url>https?://[^\s]+)}, "<\\k<url>>")
47+
end
48+
end
49+
end
50+
end

lib/discord/bot/search.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module Discord::Bot
5+
class Search < Base
6+
def initialize(body)
7+
@search = DiscourseAi::AiBot::Tools::Search
8+
super(body)
9+
end
10+
11+
def handle_interaction!
12+
results =
13+
@search.new(
14+
{ search_query: @query },
15+
persona_options: {
16+
"max_results" => 10,
17+
},
18+
bot_user: nil,
19+
llm: nil,
20+
).invoke(&Proc.new {})
21+
22+
formatted_results = results[:rows].map.with_index { |result, index| <<~RESULT }.join("\n")
23+
#{index + 1}. [#{result[0]}](<#{Discourse.base_url}#{result[1]}>)
24+
RESULT
25+
26+
reply = <<~REPLY
27+
Here are the top search results for your query:
28+
29+
#{formatted_results}
30+
REPLY
31+
32+
create_reply(reply)
33+
end
34+
end
35+
end
36+
end

plugin.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
gem "tokenizers", "0.4.4"
1212
gem "tiktoken_ruby", "0.0.9"
13+
gem "ed25519", "1.2.4" #TODO remove this as existing ssl gem should handle this
1314

1415
enabled_site_setting :discourse_ai_enabled
1516

0 commit comments

Comments
 (0)