Feature/projects (#18)

* add up to 5 attachments

* multiclause function event handler

* fix incorrect Cog calls

* add default param as "all" so we can start differentiating the types of identifications in the future - projects / diseases

* abstract message sending

* overwrite global application commands

* pass message from projects to message sending and id functions
This commit is contained in:
2025-12-08 13:17:18 +01:00
committed by GitHub
parent 0eddf04678
commit 3e6116423a
6 changed files with 223 additions and 77 deletions
+35
View File
@@ -55,6 +55,41 @@ defmodule PlantIdDiscordBot.Consumer.Commands do
description: "Search for a project", description: "Search for a project",
required: true, required: true,
autocomplete: true autocomplete: true
},
%{
# ATTACHMENT (Image 1 - Required)
type: 11,
name: "image1",
description: "First photo of the plant",
required: true
},
%{
# ATTACHMENT (Image 2 - Optional)
type: 11,
name: "image2",
description: "Second photo (optional)",
required: false
},
%{
# ATTACHMENT (Image 3 - Optional)
type: 11,
name: "image3",
description: "Third photo (optional)",
required: false
},
%{
# ATTACHMENT (Image 4 - Optional)
type: 11,
name: "image4",
description: "Fourth photo (optional)",
required: false
},
%{
# ATTACHMENT (Image 5 - Optional)
type: 11,
name: "image5",
description: "Fifth photo (optional)",
required: false
} }
] ]
} }
+14 -13
View File
@@ -5,6 +5,7 @@ defmodule PlantIdDiscordBot.Consumer do
use Nostrum.Consumer use Nostrum.Consumer
alias Nostrum.Api alias Nostrum.Api
alias PlantIdDiscordBot.{Cog, Consumer} alias PlantIdDiscordBot.{Cog, Consumer}
alias PlantIdDiscordBot.PlantNet.Projects
@global_application_commands Consumer.Commands.global_application_commands() @global_application_commands Consumer.Commands.global_application_commands()
@@ -17,9 +18,9 @@ defmodule PlantIdDiscordBot.Consumer do
Api.create_guild_application_command(guild_id, cmd) Api.create_guild_application_command(guild_id, cmd)
end) end)
# Only register global commands in production
if Mix.env() == :prod do if Mix.env() == :prod do
Api.create_global_application_command(@global_application_commands) # Api.create_global_application_command(@global_application_commands)
Api.bulk_overwrite_global_application_commands(@global_application_commands)
end end
Api.update_status(:online, "Guess the Plant | /help") Api.update_status(:online, "Guess the Plant | /help")
@@ -30,17 +31,7 @@ defmodule PlantIdDiscordBot.Consumer do
end end
def handle_event({:INTERACTION_CREATE, %{data: %{name: command}} = interaction, _ws_state}) do def handle_event({:INTERACTION_CREATE, %{data: %{name: command}} = interaction, _ws_state}) do
case command do handle_interaction(command, interaction)
"source" -> Cog.Info.source(interaction)
"invite" -> Cog.Info.invite(interaction)
"help" -> Cog.Info.help(interaction)
"info" -> Cog.Info.info(interaction)
"stats" -> Cog.Info.stats(interaction)
"status" -> Cog.Info.status(interaction)
"servers" -> Cog.Info.servers(interaction)
"diseases" -> Cog.Diseases.diseases(interaction)
"projects" -> Cog.Projects.projects(interaction)
end
end end
def handle_event({:MESSAGE_CREATE, %{attachments: attachments} = message, _ws_state}) do def handle_event({:MESSAGE_CREATE, %{attachments: attachments} = message, _ws_state}) do
@@ -49,4 +40,14 @@ defmodule PlantIdDiscordBot.Consumer do
Cog.PlantNetMessage.id(message) Cog.PlantNetMessage.id(message)
end end
end end
defp handle_interaction("source", interaction), do: Cog.Info.source(interaction)
defp handle_interaction("invite", interaction), do: Cog.Info.invite(interaction)
defp handle_interaction("help", interaction), do: Cog.Info.help(interaction)
defp handle_interaction("info", interaction), do: Cog.Info.info(interaction)
defp handle_interaction("stats", interaction), do: Cog.Info.stats(interaction)
defp handle_interaction("status", interaction), do: Cog.Info.status(interaction)
defp handle_interaction("servers", interaction), do: Cog.Info.servers(interaction)
defp handle_interaction("diseases", interaction), do: Cog.Diseases.diseases(interaction)
defp handle_interaction("projects", interaction), do: Cog.Projects.projects(interaction)
end end
@@ -18,21 +18,21 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do
@plantnet_api_base_url Application.compile_env(:plantid_discord_bot, :plantnet_api_base_url) @plantnet_api_base_url Application.compile_env(:plantid_discord_bot, :plantnet_api_base_url)
@max_results Application.compile_env(:plantid_discord_bot, :max_results) @max_results Application.compile_env(:plantid_discord_bot, :max_results)
def id(message) do def id(message, identification_type \\ "all") do
case RateLimiter.check_limit(message.guild_id) do case RateLimiter.check_limit(message.guild_id) do
{:limit_exceeded, _requests_used, _requests_limit} -> {:limit_exceeded, _requests_used, _requests_limit} ->
Api.create_message(message.channel_id, send_message(
content: "This server has exceeded its allowed requests in 24 hours. Please try again tomorrow.",
"This server has exceeded its allowed requests in 24 hours. Please try again tomorrow.", message.channel_id,
message_reference: %{message_id: message.id} message.id
) )
{:ok, _requests_used, _requests_limit} -> {:ok, _requests_used, _requests_limit} ->
do_identification(message) do_identification(message, identification_type)
end end
end end
def do_identification(message) do def do_identification(message, identification_type) do
saved_images = saved_images =
try do try do
Enum.take(message.attachments, 5) Enum.take(message.attachments, 5)
@@ -49,13 +49,15 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do
content: "An error has occured. Please try again later." content: "An error has occured. Please try again later."
) )
# send_message("An error has occured. Please try again later.", message.channel_id)
nil nil
end end
if saved_images do if saved_images do
try do try do
prepare_images(saved_images) prepare_images(saved_images)
|> build_query_uri() |> build_query_uri(identification_type)
|> get_response(message) |> get_response(message)
rescue rescue
e -> e ->
@@ -97,12 +99,7 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do
RateLimiter.increase_counter(guild_id) RateLimiter.increase_counter(guild_id)
Metrics.increase_request_count(guild_id, guild_name) Metrics.increase_request_count(guild_id, guild_name)
# Nostrum.Api.create_message/2 is deprecated but the new function is not available in v0.10 of the library send_message(response_message, message.channel_id, message.id)
# Nostrum.Api.message/2 will be the new function
Api.create_message(message.channel_id,
content: response_message,
message_reference: %{message_id: message.id}
)
{:ok, %HTTPoison.Response{status_code: 401, body: body}} -> {:ok, %HTTPoison.Response{status_code: 401, body: body}} ->
Logger.critical("Unauthorized request to PlantNet API: #{body}", Logger.critical("Unauthorized request to PlantNet API: #{body}",
@@ -110,27 +107,18 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do
guild_name: guild_name guild_name: guild_name
) )
Api.create_message(message.channel_id, send_message("Unauthorizes requesnt to PlantNet API", message.channel_id, message.id)
content: "Unauthorized request to PlantNet API.",
message_reference: %{message_id: message.id}
)
{:ok, %HTTPoison.Response{status_code: 404}} -> {:ok, %HTTPoison.Response{status_code: 404}} ->
RateLimiter.increase_counter(guild_id) RateLimiter.increase_counter(guild_id)
Metrics.increase_request_count(guild_id, guild_name) Metrics.increase_request_count(guild_id, guild_name)
Api.create_message(message.channel_id, send_message("Species Not Found", message.channel_id, message.id)
content: "Species Not Found",
message_reference: %{message_id: message.id}
)
{:ok, %HTTPoison.Response{status_code: 429}} -> {:ok, %HTTPoison.Response{status_code: 429}} ->
Logger.warning("Request limit exceeded for the PlantNet API") Logger.warning("Request limit exceeded for the PlantNet API")
Api.create_message(message.channel_id, send_message("Too Many Requests", message.channel_id, message.id)
content: "Too Many Requests",
message_reference: %{message_id: message.id}
)
{_, _} -> {_, _} ->
Logger.error("Internal server error when contacting the PlantNet API", Logger.error("Internal server error when contacting the PlantNet API",
@@ -138,18 +126,27 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do
guild_name: guild_name guild_name: guild_name
) )
Api.create_message(message.channel_id, send_message("Internal Server Error", message.channel_id, message.id)
content: "Internal Server Error",
message_reference: %{message_id: message.id}
)
end end
end end
@spec build_query_uri([String.t()]) :: String.t() @spec build_query_uri([String.t()], String.t()) :: String.t()
defp build_query_uri(image_filenames) do def build_query_uri(image_filenames, identification_type) do
URI.parse( create_base_uri(identification_type)
"#{@plantnet_api_base_url}/identify/all?api-key=#{Application.get_env(:plantid_discord_bot, :plantnet_api_key)}" |> add_required_query_params(image_filenames)
) end
defp create_base_uri("all"), do: "#{@plantnet_api_base_url}/identify/all"
defp create_base_uri("diseases"), do: "#{@plantnet_api_base_url}/diseases/identify"
defp create_base_uri(%{name: "projects", id: id}),
do: "#{@plantnet_api_base_url}/identify/#{id}"
defp create_base_uri(_), do: "#{@plantnet_api_base_url}/identify/all"
defp add_required_query_params(base_uri, image_filenames) do
URI.parse(base_uri)
|> URI.append_query("api-key=#{Application.get_env(:plantid_discord_bot, :plantnet_api_key)}")
|> URI.append_query("images=#{Enum.join(image_filenames, "&images=")}") |> URI.append_query("images=#{Enum.join(image_filenames, "&images=")}")
|> URI.append_query("nb-results=#{@max_results}") |> URI.append_query("nb-results=#{@max_results}")
|> URI.append_query("type=kt") |> URI.append_query("type=kt")
@@ -160,4 +157,20 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do
Enum.map(saved_images, fn {:ok, filename} -> filename end) Enum.map(saved_images, fn {:ok, filename} -> filename end)
|> File.delete_files!() |> File.delete_files!()
end end
# Nostrum.Api.create_message/2 is deprecated but the new function is not available in v0.10 of the library
# Nostrum.Api.message/2 will be the new function
# look at @api.create_message and move to this if needed
defp send_message(content, channel_id, message_id \\ nil) do
case message_id do
nil ->
Api.create_message(channel_id, content: content)
_ ->
Api.create_message(channel_id,
content: content,
message_reference: %{message_id: message_id}
)
end
end
end end
+38 -26
View File
@@ -4,20 +4,20 @@ defmodule PlantIdDiscordBot.Cog.Projects do
""" """
alias PlantIdDiscordBot.PlantNet.Projects alias PlantIdDiscordBot.PlantNet.Projects
alias PlantIdDiscordBot.Cog.PlantNetMessage
@api Application.compile_env(:plantid_discord_bot, :api) @api Application.compile_env(:plantid_discord_bot, :api)
# ---------------------------------------------------------
# 1. AUTOCOMPLETE HANDLER
# ---------------------------------------------------------
def autocomplete(interaction) do def autocomplete(interaction) do
# Extract what the user is typing (may be nil) options = interaction.data.options || []
[%{value: user_input}] = interaction.data.options
user_input = user_input || "" focused_option =
Enum.find(options, fn opt -> Map.get(opt, :focused) == true end)
user_input = (focused_option && focused_option.value) || ""
# Fetch your project list
projects = Projects.retrieve_projects() projects = Projects.retrieve_projects()
# Filter based on description
suggestions = suggestions =
projects projects
|> Enum.filter(fn p -> |> Enum.filter(fn p ->
@@ -36,36 +36,48 @@ defmodule PlantIdDiscordBot.Cog.Projects do
} }
end) end)
# Respond with autocomplete choices
@api.create_interaction_response(interaction, %{ @api.create_interaction_response(interaction, %{
type: 8, type: 8,
data: %{choices: suggestions} data: %{choices: suggestions}
}) })
end end
# ---------------------------------------------------------
# 2. FINAL COMMAND EXECUTION
# ---------------------------------------------------------
def projects(interaction) do def projects(interaction) do
# Extract selected project ID
project_id = project_id =
interaction.data.options interaction.data.options
|> Enum.find(&(&1.name == "project")) |> Enum.find(&(&1.name == "project"))
|> then(&(&1 && &1.value)) |> then(&(&1 && &1.value))
@api.create_interaction_response(interaction, %{ case project_id do
type: 4, nil ->
data: %{ @api.create_interaction_response(interaction, %{
content: type: 4,
case project_id do data: %{content: "Please choose a project using autocomplete."}
nil -> })
"Please choose a project using autocomplete."
id -> id ->
# "You selected project: **#{id}**" @api.create_interaction_response(interaction, %{
"Identification by project coming soon." type: 4,
end data: %{content: "Processing images for project **#{id}**..."}
} })
})
resolved = Map.get(interaction.data, :resolved)
attachments_map = (resolved && Map.get(resolved, :attachments)) || %{}
images =
interaction.data.options
|> Enum.filter(fn opt -> String.starts_with?(opt.name, "image") end)
|> Enum.map(fn opt -> Map.get(attachments_map, opt.value) end)
|> Enum.reject(&is_nil/1)
message = %{
guild_id: interaction.guild_id,
channel_id: interaction.channel_id,
id: nil,
attachments: images
}
PlantNetMessage.id(message, %{name: "projects", id: id})
end
end end
end end
+86 -1
View File
@@ -1,5 +1,90 @@
defmodule PlantIdDiscordBotTest.Cog do defmodule PlantIdDiscordBotTest.Cog do
use ExUnit.Case use ExUnit.Case, async: true
doctest PlantIdDiscordBot.Cog.Info doctest PlantIdDiscordBot.Cog.Info
doctest PlantIdDiscordBot.Cog.PlantNetMessage doctest PlantIdDiscordBot.Cog.PlantNetMessage
alias PlantIdDiscordBot.Cog.PlantNetMessage
@plantnet_api_base_url Application.compile_env(:plantid_discord_bot, :plantnet_api_base_url)
@plantnet_api_key Application.compile_env(:plantid_discord_bot, :plantnet_api_key)
@image_filenames ["image1.jpg", "image2.jpg", "image3.jpg"]
describe "build_query_uri/2" do
test "all" do
uri = PlantNetMessage.build_query_uri(@image_filenames, "all")
test_uri =
@image_filenames
|> Enum.reduce(
URI.parse("#{@plantnet_api_base_url}/identify/all")
|> URI.append_query("api-key=#{@plantnet_api_key}"),
fn filename, acc ->
URI.append_query(acc, "images=#{filename}")
end
)
|> URI.append_query("nb-results=5")
|> URI.append_query("type=kt")
|> URI.to_string()
assert uri == test_uri
end
test "diseases" do
uri = PlantNetMessage.build_query_uri(@image_filenames, "diseases")
test_uri =
@image_filenames
|> Enum.reduce(
URI.parse("#{@plantnet_api_base_url}/diseases/identify")
|> URI.append_query("api-key=#{@plantnet_api_key}"),
fn filename, acc ->
URI.append_query(acc, "images=#{filename}")
end
)
|> URI.append_query("nb-results=5")
|> URI.append_query("type=kt")
|> URI.to_string()
assert uri == test_uri
end
test "projects" do
uri =
PlantNetMessage.build_query_uri(@image_filenames, %{name: "projects", id: "project-id"})
test_uri =
@image_filenames
|> Enum.reduce(
URI.parse("#{@plantnet_api_base_url}/identify/project-id")
|> URI.append_query("api-key=#{@plantnet_api_key}"),
fn filename, acc ->
URI.append_query(acc, "images=#{filename}")
end
)
|> URI.append_query("nb-results=5")
|> URI.append_query("type=kt")
|> URI.to_string()
assert uri == test_uri
end
test "default" do
uri = PlantNetMessage.build_query_uri(@image_filenames, "all")
test_uri =
@image_filenames
|> Enum.reduce(
URI.parse("#{@plantnet_api_base_url}/identify/all")
|> URI.append_query("api-key=#{@plantnet_api_key}"),
fn filename, acc ->
URI.append_query(acc, "images=#{filename}")
end
)
|> URI.append_query("nb-results=5")
|> URI.append_query("type=kt")
|> URI.to_string()
assert uri == test_uri
end
end
end end
+2 -2
View File
@@ -4,12 +4,12 @@ defmodule PlantIdDiscordBot.ErrorHandlingTest do
@guild Application.compile_env(:plantid_discord_bot, :guild) @guild Application.compile_env(:plantid_discord_bot, :guild)
test "do_identification/1 returns invokes logger on error" do test "do_identification/2 returns invokes logger on error" do
message = PlantNetFixtures.Message.message() message = PlantNetFixtures.Message.message()
log = log =
capture_log(fn -> capture_log(fn ->
PlantIdDiscordBot.Cog.PlantNetMessage.do_identification(message) PlantIdDiscordBot.Cog.PlantNetMessage.do_identification(message, "all")
assert_received {:create_message, 123_456, assert_received {:create_message, 123_456,
content: "An error has occured. Please try again later."} content: "An error has occured. Please try again later."}