diff --git a/README.md b/README.md index bea616c..75d99f3 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,11 @@ If you wish to invite this bot to your server, use [this link.](https://discord. ## Prerequisites -TODO + {:nostrum, "~> 0.10"}, + {:httpoison, "~> 2.2"}, + {:image, "~> 0.55"}, + {:jason, "~> 1.4"}, + {:plug_cowboy, "~> 2.7"} ## Installation diff --git a/config/config.exs b/config/config.exs index da98668..64bbbe6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,6 +1,7 @@ import Config config :plantid_discord_bot, - image_path: "priv/static" + image_path: "priv/static", + plantnet_api_base_url: "https://my-api.plantnet.org/v2" import_config "#{config_env()}.exs" diff --git a/config/prod.exs b/config/prod.exs index 76467f7..d34f2fe 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -4,7 +4,6 @@ config :plantid_discord_bot, guild_request_limit_per_day: 20, guild_ids: [], plantnet_api_key: System.get_env("PLANTNET_API_KEY"), - plantnet_api_base_url: "https://my-api.plantnet.org/v2", fileserver_url: "http://localhost:4321", port: 4321, source: "https://github.com/TheRealOwenRees/plantid-discord-bot", diff --git a/config/runtime.exs b/config/runtime.exs index aebc49a..cb65061 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -3,7 +3,6 @@ import Config config :plantid_discord_bot, guild_ids: [1_002_507_312_159_797_318], plantnet_api_key: System.get_env("PLANTNET_API_KEY"), - plantnet_api_base_url: "https://my-api.plantnet.org/v2", fileserver_url: "http://localhost:4321", port: 4321, source: "https://github.com/TheRealOwenRees/plantid-discord-bot", diff --git a/config/test.exs b/config/test.exs index d2446ff..c577b5f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -4,7 +4,6 @@ config :plantid_discord_bot, guild_request_limit_per_day: 20, guild_ids: [1_002_507_312_159_797_318], plantnet_api_key: System.get_env("PLANTNET_API_KEY"), - plantnet_api_base_url: "https://my-api.plantnet.org/v2", fileserver_url: "http://localhost:4321", port: 4321, source: "https://github.com/TheRealOwenRees/plantid-discord-bot", diff --git a/lib/plantid_discord_bot/cogs/plantnet.ex b/lib/plantid_discord_bot/cogs/plantnet.ex index 6c036d8..d13eaaf 100644 --- a/lib/plantid_discord_bot/cogs/plantnet.ex +++ b/lib/plantid_discord_bot/cogs/plantnet.ex @@ -5,6 +5,7 @@ defmodule PlantIdDiscordBot.Cog.PlantNet do alias PlantIdDiscordBot.FileServer.File @api Application.compile_env(:plantid_discord_bot, :api) + @plantnet_api_base_url Application.compile_env(:plantid_discord_bot, :plantnet_api_base_url) # TEMP: Mock data @plantnet_raw_response "{\"query\":{\"project\":\"all\",\"images\":[\"https://upload.wikimedia.org/wikipedia/commons/f/ff/Prunus_cerasifera_A.jpg\",\"https://le-jardin-de-pascal.com/2195113-large_default/prunus-cerasifera-atropurpurea-prunier-myrobolan-nigra.jpg\"],\"organs\":[\"auto\",\"auto\"],\"includeRelatedImages\":false,\"noReject\":false},\"language\":\"en\",\"preferedReferential\":\"k-world-flora\",\"bestMatch\":\"Prunus cerasifera Ehrh.\",\"results\":[{\"score\":0.87871,\"species\":{\"scientificNameWithoutAuthor\":\"Prunus cerasifera\",\"scientificNameAuthorship\":\"Ehrh.\",\"genus\":{\"scientificNameWithoutAuthor\":\"Prunus\",\"scientificNameAuthorship\":\"\",\"scientificName\":\"Prunus\"},\"family\":{\"scientificNameWithoutAuthor\":\"Rosaceae\",\"scientificNameAuthorship\":\"\",\"scientificName\":\"Rosaceae\"},\"commonNames\":[\"Cherry plum, myrobalan\",\"Cherry Plum\",\"Purple-leaf Plum\"],\"scientificName\":\"Prunus cerasifera Ehrh.\"},\"gbif\":{\"id\":\"3021730\"},\"powo\":{\"id\":\"729568-1\"},\"iucn\":{\"id\":\"172162\",\"category\":\"DD\"}},{\"score\":0.31668,\"species\":{\"scientificNameWithoutAuthor\":\"Prunus × cistena\",\"scientificNameAuthorship\":\"N.E.Hansen ex Koehne\",\"genus\":{\"scientificNameWithoutAuthor\":\"Prunus\",\"scientificNameAuthorship\":\"\",\"scientificName\":\"Prunus\"},\"family\":{\"scientificNameWithoutAuthor\":\"Rosaceae\",\"scientificNameAuthorship\":\"\",\"scientificName\":\"Rosaceae\"},\"commonNames\":[\"Dwarf red-leaf plum\",\"Purple-leaf sand cherry\",\"Purple-leaved sand cherry\"],\"scientificName\":\"Prunus × cistena N.E.Hansen ex Koehne\"},\"gbif\":{\"id\":\"3022465\"},\"powo\":{\"id\":\"2959315-4\"}},{\"score\":0.01801,\"species\":{\"scientificNameWithoutAuthor\":\"Prunus sargentii\",\"scientificNameAuthorship\":\"Rehder\",\"genus\":{\"scientificNameWithoutAuthor\":\"Prunus\",\"scientificNameAuthorship\":\"\",\"scientificName\":\"Prunus\"},\"family\":{\"scientificNameWithoutAuthor\":\"Rosaceae\",\"scientificNameAuthorship\":\"\",\"scientificName\":\"Rosaceae\"},\"commonNames\":[\"Sargent's cherry\",\"Northern Japanese hill cherry\",\"Sargent’s cherry\"],\"scientificName\":\"Prunus sargentii Rehder\"},\"gbif\":{\"id\":\"3020955\"},\"powo\":{\"id\":\"730239-1\"},\"iucn\":{\"id\":\"64127603\",\"category\":\"LC\"}},{\"score\":0.00896,\"species\":{\"scientificNameWithoutAuthor\":\"Prunus × yedoensis\",\"scientificNameAuthorship\":\"Matsum.\",\"genus\":{\"scientificNameWithoutAuthor\":\"Prunus\",\"scientificNameAuthorship\":\"\",\"scientificName\":\"Prunus\"},\"family\":{\"scientificNameWithoutAuthor\":\"Rosaceae\",\"scientificNameAuthorship\":\"\",\"scientificName\":\"Rosaceae\"},\"commonNames\":[\"Yoshino cherry\",\"Hybrid cherry\",\"Korean flowering cherry\"],\"scientificName\":\"Prunus × yedoensis Matsum.\"},\"gbif\":{\"id\":\"3021335\"},\"powo\":{\"id\":\"30119904-2\"}},{\"score\":0.00518,\"species\":{\"scientificNameWithoutAuthor\":\"Prunus serrulata\",\"scientificNameAuthorship\":\"Lindl.\",\"genus\":{\"scientificNameWithoutAuthor\":\"Prunus\",\"scientificNameAuthorship\":\"\",\"scientificName\":\"Prunus\"},\"family\":{\"scientificNameWithoutAuthor\":\"Rosaceae\",\"scientificNameAuthorship\":\"\",\"scientificName\":\"Rosaceae\"},\"commonNames\":[\"Japanese flowering cherry\",\"Japanese flowering cherry Kwanzan\",\"Tibetan Cherry\"],\"scientificName\":\"Prunus serrulata Lindl.\"},\"gbif\":{\"id\":\"3022609\"},\"powo\":{\"id\":\"730268-1\"},\"iucn\":{\"id\":\"217170511\",\"category\":\"LC\"}}],\"version\":\"2024-11-19 (7.3)\",\"remainingIdentificationRequests\":488}" @@ -32,23 +33,21 @@ defmodule PlantIdDiscordBot.Cog.PlantNet do attachment_urls = get_attachment_urls(interaction) original_images = get_original_images(attachment_urls) - try do - Enum.each(attachment_urls, fn url -> - binary = File.download_file!(url) - File.save_file!(binary) - end) - rescue - e in RuntimeError -> - # Logger.error("An error occurred while processing the images.") + # TODO return image filename list as a list of images to delete + saved_images = + try do + File.download_and_save_files!(attachment_urls) + rescue + e -> + @api.create_interaction_response(interaction, %{ + type: 4, + data: %{ + content: e.message + } + }) + end - @api.create_interaction_response(interaction, %{ - type: 4, - data: %{ - content: - "An error occurred while processing the images." <> "\n\nReason:\n#{e.message}" - } - }) - end + IO.inspect(saved_images) # TODO call PlantNet API and get response, with error handling @@ -60,6 +59,8 @@ defmodule PlantIdDiscordBot.Cog.PlantNet do # TODO increase on success (failed id or otherwise) # RateLimiter.increase_counter(guild_id) + # TODO delete saved images + @api.create_interaction_response(interaction, %{ type: 4, data: %{content: response_message <> "\n#{original_images}"} diff --git a/lib/plantid_discord_bot/file_server/file.ex b/lib/plantid_discord_bot/file_server/file.ex index db9950e..4b313ff 100644 --- a/lib/plantid_discord_bot/file_server/file.ex +++ b/lib/plantid_discord_bot/file_server/file.ex @@ -7,22 +7,35 @@ defmodule PlantIdDiscordBot.FileServer.File do @image_path Application.compile_env(:plantid_discord_bot, :image_path) @doc """ - Downloads a file from the given URL. Throws an error if unsuccessful. + Download and save a file from a URL. """ - @spec download_file!(String.t()) :: binary - def download_file!(url) do - HTTPoison.get!(url) - |> Map.get(:body) - end + @spec download_and_save_files!(String.t()) :: {:ok, String.t()} + def download_and_save_files!(url) do + tasks = + Enum.map(url, fn url -> + Task.async(fn -> + try do + download_file!(url) + |> save_file!() + rescue + e -> {:error, e} + end + end) + end) - @spec save_file!(binary) :: :ok - def save_file!(binary) do - File.mkdir_p!(@image_path) + results = + Enum.map(tasks, &Task.await/1) + |> Enum.filter(fn + {:ok, _} -> true + _ -> false + end) - jpg_binary = ImageConverter.to_jpg!(binary) + if Enum.empty?(results) do + raise ArgumentError, + "None of the images that you uploaded are valid for identification. Please ensure that the images are valid." + end - generate_unique_filename("jpg") - |> File.write!(binary) + results end @doc """ @@ -33,4 +46,31 @@ defmodule PlantIdDiscordBot.FileServer.File do filename = :crypto.strong_rand_bytes(8) |> Base.encode16() Path.join(@image_path, "#{filename}.#{file_extension}") end + + @doc """ + Get a file from the file server. + """ + @spec get_file(String.t()) :: {:ok, binary} | {:error, String.t()} + def get_file(filename) do + Path.join(@image_path, filename) + |> File.read() + end + + @spec download_file!(String.t()) :: binary + defp download_file!(url) do + HTTPoison.get!(url) + |> Map.get(:body) + end + + @spec save_file!(binary) :: {:ok, String.t()} + defp save_file!(binary) do + File.mkdir_p!(@image_path) + + jpg_binary = ImageConverter.to_jpg!(binary) + + filename = generate_unique_filename("jpg") + File.write!(filename, jpg_binary) + + {:ok, Path.basename(filename)} + end end diff --git a/lib/plantid_discord_bot/file_server/image_converter.ex b/lib/plantid_discord_bot/file_server/image_converter.ex index edf7984..b306215 100644 --- a/lib/plantid_discord_bot/file_server/image_converter.ex +++ b/lib/plantid_discord_bot/file_server/image_converter.ex @@ -6,9 +6,9 @@ defmodule PlantIdDiscordBot.FileServer.ImageConverter do def to_jpg!(binary) do case MimeTypes.detect_mime_type(binary) do - {:error, message} -> raise RuntimeError, message - "image/jpeg" -> binary - _ -> do_to_jpg(binary) + {:ok, "image/jpeg"} -> binary + {:ok, _} -> do_to_jpg(binary) + {:error, message} -> raise File.Error, message end end diff --git a/lib/plantid_discord_bot/file_server/mime_types.ex b/lib/plantid_discord_bot/file_server/mime_types.ex index bc3d7d0..0dc37d7 100644 --- a/lib/plantid_discord_bot/file_server/mime_types.ex +++ b/lib/plantid_discord_bot/file_server/mime_types.ex @@ -6,12 +6,12 @@ defmodule PlantIdDiscordBot.FileServer.MimeTypes do @doc """ Detect the mime type of a binary """ - @spec detect_mime_type(binary) :: String.t() - def detect_mime_type(<<0x52, 0x49, 0x46, 0x46, _::binary>>), do: "image/webp" - def detect_mime_type(<<0xFF, 0xD8, 0xFF, _::binary>>), do: "image/jpeg" - def detect_mime_type(<<0x89, 0x50, 0x4E, 0x47, _::binary>>), do: "image/png" - def detect_mime_type(<<0x47, 0x49, 0x46, 0x38, _::binary>>), do: "image/gif" - def detect_mime_type(<<0x42, 0x4D, _::binary>>), do: "image/bmp" - def detect_mime_type(<<0x49, 0x49, 0x2A, 0x00, _::binary>>), do: "image/tiff" + @spec detect_mime_type(binary) :: {:ok, String.t()} | {:error, String.t()} + def detect_mime_type(<<0x52, 0x49, 0x46, 0x46, _::binary>>), do: {:ok, "image/webp"} + def detect_mime_type(<<0xFF, 0xD8, 0xFF, _::binary>>), do: {:ok, "image/jpeg"} + def detect_mime_type(<<0x89, 0x50, 0x4E, 0x47, _::binary>>), do: {:ok, "image/png"} + def detect_mime_type(<<0x47, 0x49, 0x46, 0x38, _::binary>>), do: {:ok, "image/gif"} + def detect_mime_type(<<0x42, 0x4D, _::binary>>), do: {:ok, "image/bmp"} + def detect_mime_type(<<0x49, 0x49, 0x2A, 0x00, _::binary>>), do: {:ok, "image/tiff"} def detect_mime_type(_), do: {:error, "Unsupported file type"} end diff --git a/lib/plantid_discord_bot/file_server/router.ex b/lib/plantid_discord_bot/file_server/router.ex index 4dedf1c..909cb86 100644 --- a/lib/plantid_discord_bot/file_server/router.ex +++ b/lib/plantid_discord_bot/file_server/router.ex @@ -1,5 +1,6 @@ defmodule PlantIdDiscordBot.FileServer.Router do use Plug.Router + alias PlantIdDiscordBot.FileServer.File plug(:match) plug(:dispatch) @@ -11,5 +12,20 @@ defmodule PlantIdDiscordBot.FileServer.Router do |> send_resp(200, "OK") end + get "image/:filename" do + filename = conn.params["filename"] + + case File.get_file(filename) do + {:ok, binary} -> + conn + |> put_resp_content_type("image/jpeg") + |> send_resp(200, binary) + + {:error, _} -> + conn + |> send_resp(404, "Not Found") + end + end + match(_, do: conn |> send_resp(404, "Not Found")) end