diff --git a/config/config.exs b/config/config.exs index bdcf328..e6974c4 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,6 +1,7 @@ import Config config :plantid_discord_bot, - start_time: DateTime.utc_now() + start_time: DateTime.utc_now(), + image_folder: "priv/static" import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index 7c4a24f..9017e77 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -1,10 +1,10 @@ import Config 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", - image_folder: "priv/static", fileserver_url: "http://localhost:4321", port: 4321, source: "https://github.com/TheRealOwenRees/plantid-discord-bot", diff --git a/config/prod.exs b/config/prod.exs index 3839f17..76467f7 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -1,10 +1,10 @@ import Config 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", - image_folder: "priv/static", 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 36fad54..aebc49a 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -4,7 +4,6 @@ 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", - image_folder: "priv/static", 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 aa05f16..d2446ff 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,10 +1,10 @@ import Config 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", - image_folder: "priv/static", fileserver_url: "http://localhost:4321", port: 4321, source: "https://github.com/TheRealOwenRees/plantid-discord-bot", diff --git a/lib/application.ex b/lib/application.ex index 4b5bf26..6bd93f8 100644 --- a/lib/application.ex +++ b/lib/application.ex @@ -6,7 +6,8 @@ defmodule PlantIdDiscordBot.Application do @impl true def start(_type, _args) do children = [ - PlantIdDiscordBot + PlantIdDiscordBot.Consumer, + PlantIdDiscordBot.RateLimiter ] opts = [strategy: :one_for_one, name: PlantIdDiscordBot.Supervisor] diff --git a/lib/plantid_discord_bot.ex b/lib/plantid_discord_bot.ex index d594a20..4c89cff 100644 --- a/lib/plantid_discord_bot.ex +++ b/lib/plantid_discord_bot.ex @@ -1,4 +1,4 @@ -defmodule PlantIdDiscordBot do +defmodule PlantIdDiscordBot.Consumer do @moduledoc """ A Discord bot that identifies plants from photos of their organs. """ @@ -30,39 +30,17 @@ defmodule PlantIdDiscordBot do Api.create_guild_application_command(1_002_507_312_159_797_318, @global_application_commands) end - # Handle /source command - def handle_event({:INTERACTION_CREATE, %{data: %{name: "source"}} = interaction, _ws_state}) do - Cog.Info.source(interaction) + # Handle application commands + 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) + "id" -> Cog.PlantNet.id(interaction) + end end - - # Handle /invite command - def handle_event({:INTERACTION_CREATE, %{data: %{name: "invite"}} = interaction, _ws_state}) do - Cog.Info.invite(interaction) - end - - # Handle /help command - def handle_event({:INTERACTION_CREATE, %{data: %{name: "help"}} = interaction, _ws_state}) do - Cog.Info.help(interaction) - end - - # Handle /info command - def handle_event({:INTERACTION_CREATE, %{data: %{name: "info"}} = interaction, _ws_state}) do - Cog.Info.info(interaction) - end - - # def handle_event({:INTERACTION_CREATE, %{data: %{name: "stats"}} = interaction, _ws_state}) do - # Cog.Info.info(interaction) - # end - - def handle_event({:INTERACTION_CREATE, %{data: %{name: "status"}} = interaction, _ws_state}) do - Cog.Info.status(interaction) - end - - # def handle_event({:INTERACTION_CREATE, %{data: %{name: "servers"}} = interaction, _ws_state}) do - # Cog.Info.info(interaction) - # end - - # def handle_event({:INTERACTION_CREATE, %{data: %{name: "id"}} = interaction, _ws_state}) do - # Cog.Info.info(interaction) - # end end diff --git a/lib/plantid_discord_bot/cogs/info.ex b/lib/plantid_discord_bot/cogs/info.ex index 8f99002..3aaca2e 100644 --- a/lib/plantid_discord_bot/cogs/info.ex +++ b/lib/plantid_discord_bot/cogs/info.ex @@ -63,15 +63,16 @@ defmodule PlantIdDiscordBot.Cog.Info do embed = %Nostrum.Struct.Embed{} |> put_title(app_info.name) - |> put_description("Stats etc.") - |> put_color(0x1AAAE5) - |> put_field("Server Count", app_info.approximate_guild_count, true) - |> put_field("Uptime", Utils.get_uptime(), true) - |> put_field("Latency", Utils.get_shard_latency(), true) + |> put_description("This bot is designed to help you identify plants") + |> put_color(0x41C03F) + |> put_field("/info", "Show this message", true) + |> put_field("/help", "Gives detailed help on the plant ID bot", true) + |> put_field("/stats", "Bot stats", true) + |> put_field("/invite", "Invite the bot to your server", true) + |> put_field("/source", "Link to the source code", true) |> put_footer("Made by #{app_info.owner.username}") - response = %{type: 4, data: %{embeds: [embed]}} - @api.create_interaction_response(interaction, response) + @api.create_interaction_response(interaction, %{type: 4, data: %{embeds: [embed]}}) end @doc """ @@ -89,4 +90,42 @@ defmodule PlantIdDiscordBot.Cog.Info do @api.create_interaction_response(interaction, %{type: 4, data: %{content: message}}) end + + @doc """ + Bot stats. + """ + def stats(interaction) do + {:ok, app_info} = @api.get_application_information() + + embed = + %Nostrum.Struct.Embed{} + |> put_title(app_info.name) + |> put_description("Stats etc.") + |> put_color(0x1AAAE5) + |> put_field("Server Count", app_info.approximate_guild_count, true) + |> put_field("Uptime", Utils.get_uptime(), true) + |> put_field("Latency", Utils.get_shard_latency(), true) + |> put_footer("Made by #{app_info.owner.username}") + + @api.create_interaction_response(interaction, %{type: 4, data: %{embeds: [embed]}}) + end + + @doc """ + List all servers the bot is connected to. + """ + def servers(interaction) do + {:ok, %{owner: owner}} = @api.get_application_information() + + guilds = PlantIdDiscordBot.Utils.get_guilds_names() + guilds_string = Enum.join(guilds, "\n") + + embed = + %Nostrum.Struct.Embed{} + |> put_title("Connected on #{length(guilds)} server(s):") + |> put_description(guilds_string) + |> put_color(0x1AAAE5) + |> put_footer("Made by #{owner.username}") + + @api.create_interaction_response(interaction, %{type: 4, data: %{embeds: [embed]}}) + end end diff --git a/lib/plantid_discord_bot/cogs/plantnet.ex b/lib/plantid_discord_bot/cogs/plantnet.ex index bb14b67..e2cafe6 100644 --- a/lib/plantid_discord_bot/cogs/plantnet.ex +++ b/lib/plantid_discord_bot/cogs/plantnet.ex @@ -1,3 +1,49 @@ defmodule PlantIdDiscordBot.Cog.PlantNet do use Nostrum.Consumer + alias PlantIdDiscordBot.RateLimiter + + @api Application.compile_env(:plantid_discord_bot, :api) + + @doc """ + ID a plant from up to 5 images of organs. + """ + def id(interaction) do + guild_id = interaction.guild_id + + case RateLimiter.check_limit(guild_id) do + {:limit_exceeded, value} -> + @api.create_interaction_response(interaction, %{ + type: 4, + data: %{ + content: + "This server has exceeded its allowed requests in 24 hours. Please try again tomorrow." + } + }) + + {:ok, _} -> + do_identification(interaction) + end + + # TODO increase on success (failed id or otherwise) + # RateLimiter.increase_counter(guild_id) + end + + defp do_identification(interaction) do + attachment_urls = get_attachment_urls(interaction) + original_images = get_original_images(attachment_urls) + + @api.create_interaction_response(interaction, %{ + type: 4, + data: %{content: "This command is not yet implemented." <> "\n#{original_images}"} + }) + end + + defp get_attachment_urls(interaction) do + interaction.data.resolved.attachments + |> Map.values() + |> Enum.map(& &1.url) + end + + @spec get_original_images([String.t()]) :: String.t() + defp get_original_images(attachment_urls), do: Enum.join(attachment_urls, "\n") end diff --git a/lib/rate_limiter.ex b/lib/rate_limiter.ex index e69de29..5350dc7 100644 --- a/lib/rate_limiter.ex +++ b/lib/rate_limiter.ex @@ -0,0 +1,52 @@ +defmodule PlantIdDiscordBot.RateLimiter do + use GenServer + + @request_limit Application.compile_env(:plantid_discord_bot, :guild_request_limit_per_day) + + def start_link(_) do + GenServer.start_link(__MODULE__, nil, name: __MODULE__) + end + + @impl GenServer + def init(_) do + :ets.new(__MODULE__, [:named_table, :public, write_concurrency: true]) + {:ok, nil} + end + + defp put(key, value) do + :ets.insert(__MODULE__, {key, value}) + end + + defp get(key) do + case :ets.lookup(__MODULE__, key) do + [{^key, value}] -> {:ok, value} + [] -> {:error, nil} + end + end + + @doc """ + Check if the number of requests for a guild exceeds the limit. + """ + @spec check_limit(String.t()) :: {:ok, integer()} | {:limit_exceeded, integer()} + def check_limit(guild_id) do + case get(guild_id) do + {:ok, value} -> + if value >= @request_limit do + {:limit_exceeded, value} + else + {:ok, value} + end + + {:error, _} -> + put(guild_id, 0) + {:ok, 0} + end + end + + @doc """ + Increase the counter for the number of requests for a guild by one. + """ + def increase_counter(guild_id) do + :ets.update_counter(__MODULE__, guild_id, 1) + end +end diff --git a/lib/utils.ex b/lib/utils.ex index baf6c7b..b01e1d2 100644 --- a/lib/utils.ex +++ b/lib/utils.ex @@ -12,4 +12,8 @@ defmodule PlantIdDiscordBot.Utils do |> to_string() |> Kernel.<>("ms") end + + def get_guilds_names() do + Nostrum.Cache.GuildCache.fold([], fn %{name: name}, acc -> [name | acc] end) + end end diff --git a/test/commands/stats_test.exs b/test/commands/stats_test.exs new file mode 100644 index 0000000..bc4b33c --- /dev/null +++ b/test/commands/stats_test.exs @@ -0,0 +1,9 @@ +defmodule PlantIdDiscordBotTest.Cog.Stats do + use ExUnit.Case + + test "/stats" do + {:ok, response} = PlantIdDiscordBot.Cog.Info.info(%{data: %{name: "stats"}}) + [embed] = response[:data][:embeds] + assert match?(%Nostrum.Struct.Embed{}, embed) + end +end