diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index d391e64..5d2d3aa 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -76,7 +76,7 @@ jobs: tar -xzf release.tar.gz # save metrics and stop the bot - bot runs as a service - current_release/bin/plantid_discord_bot rpc "PlantidDiscordBot.Metrics.backup()" + current_release/bin/plantid_discord_bot rpc "PlantIdDiscordBot.Metrics.backup()" echo "${{ secrets.SUDO_PASSWORD }}" | sudo -S sudo service plantid_discord_bot stop # switch releases diff --git a/README.md b/README.md index 330b073..68fc116 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,9 @@ PLANTID_DISCORD_BOT_TOKEN= client secret from the bot's application PLANTID_LOGS_DISCORD_WEBHOOK_URL= webhook url for the log channel PLANTNET_API_KEY= API key for the PlantNet service +# needed in development +DISCORD_DEV_GUILD_ID= Your guild ID to populate application commands immediately + # needed in production PLANTID_FILESERVER_URL URL and port(if needed) for the http file server ``` diff --git a/TODO b/TODO index 8703287..d02810d 100644 --- a/TODO +++ b/TODO @@ -3,10 +3,12 @@ ## 🚀 High Priority - [x] Update deps and run on Elixir 1.18 / otp 28 +- [ ] Pull projects on a regular basis +- [ ] identify by project +- [ ] identify by country - [ ] Run static analysis tools and fix code - [ ] Increase test coverage - [ ] Fix spec errors -- [ ] Improve error return message ## 📦 Medium Priority diff --git a/config/dev.exs b/config/dev.exs index fef4517..209902f 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -3,7 +3,8 @@ import Config config :plantid_discord_bot, api: Nostrum.Api, guild: PlantIdDiscordBot.Guild, - port: 4321 + port: 4321, + dev_guild_id: System.get_env("DISCORD_DEV_GUILD_ID") config :plantid_discord_bot, :environment, :dev diff --git a/config/runtime.exs b/config/runtime.exs index 0a780b0..776bf47 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -6,7 +6,8 @@ config :plantid_discord_bot, api: Nostrum.Api, plantnet_api_key: System.get_env("PLANTNET_API_KEY"), fileserver_url: System.get_env("PLANTID_FILESERVER_URL", "http://localhost:4321"), - metrics_webhook_url: System.get_env("PLANTID_LOGS_DISCORD_WEBHOOK_URL") + metrics_webhook_url: System.get_env("PLANTID_LOGS_DISCORD_WEBHOOK_URL"), + dev_guild_id: System.get_env("DISCORD_DEV_GUILD_ID") config :nostrum, token: System.get_env("PLANTID_DISCORD_BOT_TOKEN") diff --git a/lib/commands.ex b/lib/commands.ex index df21ed5..74112cb 100644 --- a/lib/commands.ex +++ b/lib/commands.ex @@ -38,6 +38,25 @@ defmodule PlantIdDiscordBot.Consumer.Commands do name: "servers", description: "All servers that this bot belongs to", options: [] + }, + %{ + name: "diseases", + description: "Identify diseases from photos", + options: [] + }, + %{ + name: "projects", + description: "Plants grouped by region or type, for identification", + options: [ + %{ + # STRING + type: 3, + name: "project", + description: "Search for a project", + required: true, + autocomplete: true + } + ] } ] end diff --git a/lib/plantid_discord_bot.ex b/lib/plantid_discord_bot.ex index 5665fe9..41885a7 100644 --- a/lib/plantid_discord_bot.ex +++ b/lib/plantid_discord_bot.ex @@ -9,10 +9,26 @@ defmodule PlantIdDiscordBot.Consumer do @global_application_commands Consumer.Commands.global_application_commands() def handle_event({:READY, _data, _ws_state}) do - Api.create_global_application_command(@global_application_commands) + Projects.fetch_projects() + guild_id = Application.get_env(:plantid_discord_bot, :dev_guild_id) + + # Register commands instantly in your test server + Enum.each(@global_application_commands, fn cmd -> + 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) + end + Api.update_status(:online, "Guess the Plant | /help") end + def handle_event({:INTERACTION_CREATE, %{type: 4} = interaction, _ws_state}) do + PlantIdDiscordBot.Cog.Projects.autocomplete(interaction) + end + def handle_event({:INTERACTION_CREATE, %{data: %{name: command}} = interaction, _ws_state}) do case command do "source" -> Cog.Info.source(interaction) @@ -22,6 +38,8 @@ defmodule PlantIdDiscordBot.Consumer do "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 @@ -30,11 +48,5 @@ defmodule PlantIdDiscordBot.Consumer do Api.start_typing!(message.channel_id) Cog.PlantNetMessage.id(message) end - - # if length(attachments) > 0 do - # # deprecated -> Nostrum.Api.Channel.start_typing/1 in v1.0 - # Api.start_typing!(message.channel_id) - # Cog.PlantNetMessage.id(message) - # end end end diff --git a/lib/plantid_discord_bot/cogs/diseases.ex b/lib/plantid_discord_bot/cogs/diseases.ex new file mode 100644 index 0000000..b302c6c --- /dev/null +++ b/lib/plantid_discord_bot/cogs/diseases.ex @@ -0,0 +1,16 @@ +defmodule PlantIdDiscordBot.Cog.Diseases do + @moduledoc """ + Functions for using the /diseases application command. + """ + + alias PlantIdDiscordBot.PlantNet.Projects + @api Application.compile_env(:plantid_discord_bot, :api) + + @doc """ + Sends an invite link for this bot. + """ + def diseases(interaction) do + message = "Diseases coming soon." + @api.create_interaction_response(interaction, %{type: 4, data: %{content: message}}) + end +end diff --git a/lib/plantid_discord_bot/cogs/projects.ex b/lib/plantid_discord_bot/cogs/projects.ex new file mode 100644 index 0000000..bd99087 --- /dev/null +++ b/lib/plantid_discord_bot/cogs/projects.ex @@ -0,0 +1,71 @@ +defmodule PlantIdDiscordBot.Cog.Projects do + @moduledoc """ + Functions for using the /projects application command. + """ + + alias PlantIdDiscordBot.PlantNet.Projects + @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 || "" + + # Fetch your project list + projects = Projects.retrieve_projects() + + # Filter based on description + suggestions = + projects + |> Enum.filter(fn p -> + String.contains?( + String.downcase(p["description"]), + String.downcase(user_input) + ) + end) + |> Enum.take(25) + |> Enum.map(fn p -> + %{ + # what the user sees + name: p["description"], + # what your command receives + value: p["id"] + } + 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." + + id -> + # "You selected project: **#{id}**" + "Identification by project coming soon." + end + } + }) + end +end diff --git a/lib/plantnet/parser.ex b/lib/plantnet/parser.ex index e670d16..cf9472d 100644 --- a/lib/plantnet/parser.ex +++ b/lib/plantnet/parser.ex @@ -142,21 +142,5 @@ defmodule PlantIdDiscordBot.PlantNet.Parser do "\n\nAlternatives include **#{alternatives}**." end - - # if !Enum.empty?(data) do - # alternatives = - # Enum.map(data, & &1["species"]["scientificNameWithoutAuthor"]) - # |> Enum.join(", ") - - # "\n\nAlternatives include **#{alternatives}**." - # end - - # if length(data) > 0 do - # alternatives = - # Enum.map(data, & &1["species"]["scientificNameWithoutAuthor"]) - # |> Enum.join(", ") - - # "\n\nAlternatives include **#{alternatives}**." - # end end end diff --git a/lib/plantnet/projects.ex b/lib/plantnet/projects.ex new file mode 100644 index 0000000..ae8a3db --- /dev/null +++ b/lib/plantnet/projects.ex @@ -0,0 +1,46 @@ +defmodule PlantIdDiscordBot.PlantNet.Projects do + @moduledoc """ + Functions related to PlantNet's projecs + """ + require Logger + alias HTTPoison.Response + + @plantnet_api_base_url Application.compile_env(:plantid_discord_bot, :plantnet_api_base_url) + + def fetch_projects do + try do + build_query_uri() + |> get_response() + |> parse_response() + |> save_projects() + rescue + e -> + Logger.error(Exception.format(:error, e, __STACKTRACE__)) + end + end + + defp build_query_uri do + URI.parse("#{@plantnet_api_base_url}/projects") + |> URI.append_query("lang=en") + |> URI.append_query("type=kt") + |> URI.append_query("api-key=#{Application.get_env(:plantid_discord_bot, :plantnet_api_key)}") + |> URI.to_string() + end + + defp get_response(query_uri), do: HTTPoison.get!(query_uri) + + defp parse_response(%Response{status_code: 200, body: body}), do: Jason.decode!(body) + + defp parse_response(%Response{status_code: 401, body: body}) do + Logger.critical("Unauthorized request to PlantNet API: #{body}") + end + + defp save_projects(projects), do: :persistent_term.put(:projects, projects) + + def retrieve_projects, do: :persistent_term.get(:projects) + + def find_project_by_description(description) do + retrieve_projects() + |> Enum.find(&(&1["description"] == description)) + end +end