From 3e6116423aef5fcb4c60468ffa4693e1885f1dca Mon Sep 17 00:00:00 2001 From: Owen Rees Date: Mon, 8 Dec 2025 13:17:18 +0100 Subject: [PATCH] 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 --- lib/commands.ex | 35 ++++++++ lib/plantid_discord_bot.ex | 27 +++--- .../cogs/plantnet_message.ex | 83 ++++++++++-------- lib/plantid_discord_bot/cogs/projects.ex | 64 ++++++++------ test/cogs_test.exs | 87 ++++++++++++++++++- test/error_handling_test.exs | 4 +- 6 files changed, 223 insertions(+), 77 deletions(-) diff --git a/lib/commands.ex b/lib/commands.ex index 74112cb..c12be98 100644 --- a/lib/commands.ex +++ b/lib/commands.ex @@ -55,6 +55,41 @@ defmodule PlantIdDiscordBot.Consumer.Commands do description: "Search for a project", required: 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 } ] } diff --git a/lib/plantid_discord_bot.ex b/lib/plantid_discord_bot.ex index 41885a7..9ea6a97 100644 --- a/lib/plantid_discord_bot.ex +++ b/lib/plantid_discord_bot.ex @@ -5,6 +5,7 @@ defmodule PlantIdDiscordBot.Consumer do use Nostrum.Consumer alias Nostrum.Api alias PlantIdDiscordBot.{Cog, Consumer} + alias PlantIdDiscordBot.PlantNet.Projects @global_application_commands Consumer.Commands.global_application_commands() @@ -17,9 +18,9 @@ defmodule PlantIdDiscordBot.Consumer do Api.create_guild_application_command(guild_id, cmd) end) - # Only register global commands in production 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 Api.update_status(:online, "Guess the Plant | /help") @@ -30,17 +31,7 @@ defmodule PlantIdDiscordBot.Consumer do end def handle_event({:INTERACTION_CREATE, %{data: %{name: command}} = interaction, _ws_state}) do - case command do - "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 + handle_interaction(command, interaction) end def handle_event({:MESSAGE_CREATE, %{attachments: attachments} = message, _ws_state}) do @@ -49,4 +40,14 @@ defmodule PlantIdDiscordBot.Consumer do Cog.PlantNetMessage.id(message) 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 diff --git a/lib/plantid_discord_bot/cogs/plantnet_message.ex b/lib/plantid_discord_bot/cogs/plantnet_message.ex index acf3140..c84dd98 100644 --- a/lib/plantid_discord_bot/cogs/plantnet_message.ex +++ b/lib/plantid_discord_bot/cogs/plantnet_message.ex @@ -18,21 +18,21 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do @plantnet_api_base_url Application.compile_env(:plantid_discord_bot, :plantnet_api_base_url) @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 {:limit_exceeded, _requests_used, _requests_limit} -> - Api.create_message(message.channel_id, - content: - "This server has exceeded its allowed requests in 24 hours. Please try again tomorrow.", - message_reference: %{message_id: message.id} + send_message( + "This server has exceeded its allowed requests in 24 hours. Please try again tomorrow.", + message.channel_id, + message.id ) {:ok, _requests_used, _requests_limit} -> - do_identification(message) + do_identification(message, identification_type) end end - def do_identification(message) do + def do_identification(message, identification_type) do saved_images = try do Enum.take(message.attachments, 5) @@ -49,13 +49,15 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do content: "An error has occured. Please try again later." ) + # send_message("An error has occured. Please try again later.", message.channel_id) + nil end if saved_images do try do prepare_images(saved_images) - |> build_query_uri() + |> build_query_uri(identification_type) |> get_response(message) rescue e -> @@ -97,12 +99,7 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do RateLimiter.increase_counter(guild_id) 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 - # Nostrum.Api.message/2 will be the new function - Api.create_message(message.channel_id, - content: response_message, - message_reference: %{message_id: message.id} - ) + send_message(response_message, message.channel_id, message.id) {:ok, %HTTPoison.Response{status_code: 401, body: body}} -> Logger.critical("Unauthorized request to PlantNet API: #{body}", @@ -110,27 +107,18 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do guild_name: guild_name ) - Api.create_message(message.channel_id, - content: "Unauthorized request to PlantNet API.", - message_reference: %{message_id: message.id} - ) + send_message("Unauthorizes requesnt to PlantNet API", message.channel_id, message.id) {:ok, %HTTPoison.Response{status_code: 404}} -> RateLimiter.increase_counter(guild_id) Metrics.increase_request_count(guild_id, guild_name) - Api.create_message(message.channel_id, - content: "Species Not Found", - message_reference: %{message_id: message.id} - ) + send_message("Species Not Found", message.channel_id, message.id) {:ok, %HTTPoison.Response{status_code: 429}} -> Logger.warning("Request limit exceeded for the PlantNet API") - Api.create_message(message.channel_id, - content: "Too Many Requests", - message_reference: %{message_id: message.id} - ) + send_message("Too Many Requests", message.channel_id, message.id) {_, _} -> Logger.error("Internal server error when contacting the PlantNet API", @@ -138,18 +126,27 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do guild_name: guild_name ) - Api.create_message(message.channel_id, - content: "Internal Server Error", - message_reference: %{message_id: message.id} - ) + send_message("Internal Server Error", message.channel_id, message.id) end end - @spec build_query_uri([String.t()]) :: String.t() - defp build_query_uri(image_filenames) do - URI.parse( - "#{@plantnet_api_base_url}/identify/all?api-key=#{Application.get_env(:plantid_discord_bot, :plantnet_api_key)}" - ) + @spec build_query_uri([String.t()], String.t()) :: String.t() + def build_query_uri(image_filenames, identification_type) do + create_base_uri(identification_type) + |> 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("nb-results=#{@max_results}") |> URI.append_query("type=kt") @@ -160,4 +157,20 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do Enum.map(saved_images, fn {:ok, filename} -> filename end) |> File.delete_files!() 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 diff --git a/lib/plantid_discord_bot/cogs/projects.ex b/lib/plantid_discord_bot/cogs/projects.ex index bd99087..bcd6e87 100644 --- a/lib/plantid_discord_bot/cogs/projects.ex +++ b/lib/plantid_discord_bot/cogs/projects.ex @@ -4,20 +4,20 @@ defmodule PlantIdDiscordBot.Cog.Projects do """ alias PlantIdDiscordBot.PlantNet.Projects + alias PlantIdDiscordBot.Cog.PlantNetMessage + @api Application.compile_env(:plantid_discord_bot, :api) - # --------------------------------------------------------- - # 1. AUTOCOMPLETE HANDLER - # --------------------------------------------------------- def autocomplete(interaction) do - # Extract what the user is typing (may be nil) - [%{value: user_input}] = interaction.data.options - user_input = user_input || "" + options = interaction.data.options || [] + + 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() - # Filter based on description suggestions = projects |> Enum.filter(fn p -> @@ -36,36 +36,48 @@ defmodule PlantIdDiscordBot.Cog.Projects do } end) - # Respond with autocomplete choices @api.create_interaction_response(interaction, %{ type: 8, data: %{choices: suggestions} }) end - # --------------------------------------------------------- - # 2. FINAL COMMAND EXECUTION - # --------------------------------------------------------- def projects(interaction) do - # Extract selected project ID project_id = interaction.data.options |> Enum.find(&(&1.name == "project")) |> then(&(&1 && &1.value)) - @api.create_interaction_response(interaction, %{ - type: 4, - data: %{ - content: - case project_id do - nil -> - "Please choose a project using autocomplete." + case project_id do + nil -> + @api.create_interaction_response(interaction, %{ + type: 4, + data: %{content: "Please choose a project using autocomplete."} + }) - id -> - # "You selected project: **#{id}**" - "Identification by project coming soon." - end - } - }) + id -> + @api.create_interaction_response(interaction, %{ + type: 4, + 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 diff --git a/test/cogs_test.exs b/test/cogs_test.exs index 888453d..462d18b 100644 --- a/test/cogs_test.exs +++ b/test/cogs_test.exs @@ -1,5 +1,90 @@ defmodule PlantIdDiscordBotTest.Cog do - use ExUnit.Case + use ExUnit.Case, async: true doctest PlantIdDiscordBot.Cog.Info 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 diff --git a/test/error_handling_test.exs b/test/error_handling_test.exs index ee1a0c7..c9e03c4 100644 --- a/test/error_handling_test.exs +++ b/test/error_handling_test.exs @@ -4,12 +4,12 @@ defmodule PlantIdDiscordBot.ErrorHandlingTest do @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() log = capture_log(fn -> - PlantIdDiscordBot.Cog.PlantNetMessage.do_identification(message) + PlantIdDiscordBot.Cog.PlantNetMessage.do_identification(message, "all") assert_received {:create_message, 123_456, content: "An error has occured. Please try again later."}