diff --git a/README.md b/README.md index 75d99f3..d26c379 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,34 @@ If you wish to invite this bot to your server, use [this link.](https://discord. - Plant names are given in latin with a list of possible common names - Provides links to [GBIF], [PFAF], and [POWO] for the identified plant -## Prerequisites +## Dependencies +```elixir +# mix.exs + +defp deps do + [ + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:nostrum, "~> 0.10"}, {:httpoison, "~> 2.2"}, {:image, "~> 0.55"}, {:jason, "~> 1.4"}, - {:plug_cowboy, "~> 2.7"} + {:plug_cowboy, "~> 2.7"}, + {:quantum, "~> 3.5"}, + {:tesla, "~> 1.13"} + ] + end +``` + +## Environment Variables + +Below are the environment variables that you need to set for the program to function: + +``` +DISCORD_TOKEN= client secret from the bot's application +LOGS_DISCORD_WEBHOOK_URL= webhook url for the log channel +PLANTNET_API_KEY= API key for the PlantNet service +``` ## Installation diff --git a/config/config.exs b/config/config.exs index 9a333b5..4258f8e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -11,8 +11,4 @@ config :plantid_discord_bot, PlantIdDiscordBot.Scheduler, {"@daily", {PlantIdDiscordBot.RateLimiter, :reset_counters, []}} ] -config :logger, :console, - format: "$time $metadata[$level] $message\n", - level: :info - import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index 11859a1..a2d534d 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -12,4 +12,6 @@ config :plantid_discord_bot, "https://discord.com/api/oauth2/authorize?client_id=948227126094598204&permissions=19520&scope=bot", api: Nostrum.Api -config :logger, level: :debug +config :logger, :console, + format: "$time $metadata[$level] $message\n", + level: :debug diff --git a/config/prod.exs b/config/prod.exs index dcd7c6d..144e8d4 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -13,4 +13,4 @@ config :plantid_discord_bot, config :logger, :console, format: "$time $metadata[$level] $message\n", - level: :info + level: :error diff --git a/config/runtime.exs b/config/runtime.exs index 7c0e02b..1a3b288 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -15,6 +15,14 @@ config :nostrum, token: System.get_env("DISCORD_TOKEN"), ffmpeg: nil +config :logger, + backends: [{PlantIdDiscordBot.DiscordLogger, :discord_logger}, :console] + config :logger, :console, format: "$time $metadata[$level] $message\n", level: :info + +config :logger, :discord_logger, + webhook_url: System.get_env("LOGS_DISCORD_WEBHOOK_URL"), + level: :error, + bot_token: System.get_env("DISCORD_TOKEN") diff --git a/lib/logger/discord_logger.ex b/lib/logger/discord_logger.ex new file mode 100644 index 0000000..d153895 --- /dev/null +++ b/lib/logger/discord_logger.ex @@ -0,0 +1,78 @@ +defmodule PlantIdDiscordBot.DiscordLogger do + require Logger + use Tesla + + plug(Tesla.Middleware.JSON) + + @behaviour :gen_event + + def init({__MODULE__, name}) do + {:ok, configure(name, [])} + end + + def handle_call({:configure, opts}, %{name: name} = state) do + {:ok, :ok, configure(name, opts, state)} + end + + def handle_event({_level, gl, {Logger, _, _, _}}, state) when node(gl) != node() do + {:ok, state} + end + + def handle_event({level, _gl, {Logger, msg, ts, md}}, %{} = state) do + if is_level_okay(level, state.level) do + log_to_discord(state.webhook_url, level, msg, ts, md) + end + + {:ok, state} + end + + def handle_event(:flush, state) do + {:ok, state} + end + + def handle_info(_, state) do + {:ok, state} + end + + defp is_level_okay(lvl, min_level) do + is_nil(min_level) or Logger.compare_levels(lvl, min_level) != :lt + end + + def log_to_discord(webhook_url, level, msg, ts, md) do + formatted_msg = format_message(level, msg, ts, md) + body = %{content: formatted_msg} + + post(webhook_url, body) + |> case do + {:ok, %{status: status}} when status in 200..299 -> :ok + {:error, reason} -> Logger.error("Error sending log to Discord: #{inspect(reason)}") + _any -> Logger.error("Error sending log to Discord") + end + end + + def format_message(level, msg, _ts, md) do + timestamp = DateTime.utc_now() + source = md[:application] + msg = IO.iodata_to_binary(msg) |> String.slice(0..1900) + + "[#{timestamp}] [#{source}] [#{level}] `#{msg}`" + end + + defp configure(name, opts) do + state = %{name: name, format: nil, level: nil, metadata: nil, metadata_filter: nil} + configure(name, opts, state) + end + + defp configure(name, opts, state) do + env = Application.get_env(:logger, name, []) + opts = Keyword.merge(env, opts) + Application.put_env(:logger, name, opts) + + new_state = %{ + webhook_url: Keyword.get(opts, :webhook_url, nil), + level: Keyword.get(opts, :level) + } + + Map.merge(state, new_state) + end +end diff --git a/lib/plantid_discord_bot.ex b/lib/plantid_discord_bot.ex index 89ed880..2806cd8 100644 --- a/lib/plantid_discord_bot.ex +++ b/lib/plantid_discord_bot.ex @@ -2,7 +2,6 @@ defmodule PlantIdDiscordBot.Consumer do @moduledoc """ A Discord bot that identifies plants from photos of their organs. """ - use Nostrum.Consumer alias Nostrum.Api alias PlantIdDiscordBot.Cog diff --git a/lib/plantid_discord_bot/cogs/info.ex b/lib/plantid_discord_bot/cogs/info.ex index 3aaca2e..1353b85 100644 --- a/lib/plantid_discord_bot/cogs/info.ex +++ b/lib/plantid_discord_bot/cogs/info.ex @@ -3,6 +3,8 @@ defmodule PlantIdDiscordBot.Cog.Info do import Nostrum.Struct.Embed alias PlantIdDiscordBot.Utils + require Logger + # reference to Nostrum.Api in non-test environments, reference to mock in test @api Application.compile_env(:plantid_discord_bot, :api) diff --git a/lib/plantid_discord_bot/cogs/plantnet.ex b/lib/plantid_discord_bot/cogs/plantnet.ex index 2b01f4d..0d84ab4 100644 --- a/lib/plantid_discord_bot/cogs/plantnet.ex +++ b/lib/plantid_discord_bot/cogs/plantnet.ex @@ -1,4 +1,5 @@ defmodule PlantIdDiscordBot.Cog.PlantNet do + require Logger use Nostrum.Consumer alias PlantIdDiscordBot.RateLimiter alias PlantIdDiscordBot.PlantNet.Parser @@ -50,7 +51,7 @@ defmodule PlantIdDiscordBot.Cog.PlantNet do }) end - IO.inspect(saved_images) + Logger.debug(saved_images) # TODO use actual image data image1 = @@ -73,13 +74,13 @@ defmodule PlantIdDiscordBot.Cog.PlantNet do }) {:ok, %HTTPoison.Response{status_code: 400}} -> - # TODO add logger + Logger.error("Malformed request sent to the PlantNet API") + Api.create_followup_message(interaction.application_id, interaction.token, %{ content: "Bad Request" }) {:ok, %HTTPoison.Response{status_code: 404}} -> - # TODO add logger RateLimiter.increase_counter(interaction.guild_id) Api.create_followup_message(interaction.application_id, interaction.token, %{ @@ -87,19 +88,20 @@ defmodule PlantIdDiscordBot.Cog.PlantNet do }) {:ok, %HTTPoison.Response{status_code: 429}} -> - # TODO add logger + Logger.warning("Request limit exceeded for the PlantNet API") + Api.create_followup_message(interaction.application_id, interaction.token, %{ content: "Too Many Requests" }) {_, _} -> - # TODO add logger + Logger.error("Internal server error when contacting the PlantNet API") + Api.create_followup_message(interaction.application_id, interaction.token, %{ content: "Internal Server Error" }) end - # TODO make into a task for async deletion, deal with errors Enum.map(saved_images, fn {:ok, filename} -> filename end) |> File.delete_files!() end diff --git a/lib/plantid_discord_bot/plantnet/parser.ex b/lib/plantnet/parser.ex similarity index 100% rename from lib/plantid_discord_bot/plantnet/parser.ex rename to lib/plantnet/parser.ex diff --git a/lib/plantid_discord_bot/utils/duration.ex b/lib/utils/duration.ex similarity index 100% rename from lib/plantid_discord_bot/utils/duration.ex rename to lib/utils/duration.ex diff --git a/mix.exs b/mix.exs index 03e090c..24d0bc8 100644 --- a/mix.exs +++ b/mix.exs @@ -28,7 +28,8 @@ defmodule PlantidDiscordBot.MixProject do {:image, "~> 0.55"}, {:jason, "~> 1.4"}, {:plug_cowboy, "~> 2.7"}, - {:quantum, "~> 3.5"} + {:quantum, "~> 3.5"}, + {:tesla, "~> 1.13"} ] end end diff --git a/mix.lock b/mix.lock index 509dff5..4617be3 100644 --- a/mix.lock +++ b/mix.lock @@ -33,6 +33,7 @@ "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_registry": {:hex, :telemetry_registry, "0.3.2", "701576890320be6428189bff963e865e8f23e0ff3615eade8f78662be0fc003c", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7ed191eb1d115a3034af8e1e35e4e63d5348851d556646d46ca3d1b4e16bab9"}, + "tesla": {:hex, :tesla, "1.13.2", "85afa342eb2ac0fee830cf649dbd19179b6b359bec4710d02a3d5d587f016910", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "960609848f1ef654c3cdfad68453cd84a5febecb6ed9fed9416e36cd9cd724f9"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "vix": {:hex, :vix, "0.31.1", "2b1d379393060ee8e4e1f1c9a621811c4091d8f063221c1ff24a41a4f0c97edc", [:make, :mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "766856b52bec222cb5fd301f645a7a9869b61e0ec6e87dc0789ae9657356a8ea"}, }