diff --git a/.gitignore b/.gitignore index 0d78984..4c096da 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,6 @@ cassian-*.tar /.elixir_ls # For certificates -/priv \ No newline at end of file +/priv + +.asdf-vars \ No newline at end of file diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..546395a --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.16.0 +erlang 26.2.1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3377e97 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,78 @@ +# Stage to build the correct version of dependecies: + +# * Elixir +# * Erlang + +FROM alpine:3.19.0 AS builder + +RUN apk update && \ + apk upgrade && \ + apk add curl git unzip \ + gcc autoconf automake ncurses-dev \ + bash make g++ openssl-dev + +RUN adduser -gD builder --disabled-password + +# Building Erlang/Elixir + +WORKDIR /build + +COPY .tool-versions . + +RUN chown -R builder:builder /build + +USER builder + +RUN git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.13.1; + +RUN /bin/bash -c 'echo -e "\n\n## Configure ASDF \n. $HOME/.asdf/asdf.sh" >> ~/.bashrc' + +RUN /bin/bash -c 'echo -e "\n\n## ASDF Bash Completion: \n. $HOME/.asdf/completions/asdf.bash" >> ~/.bashrc'; + +ENV PATH="$PATH:/home/builder/.asdf/bin:/home/builder/.asdf/shims" + +RUN asdf plugin add elixir + +RUN asdf plugin add erlang + +RUN asdf install + +# Building the application + +RUN mix local.hex --force + +COPY mix.exs . + +COPY mix.lock . + +COPY config ./config + +COPY lib ./lib + +COPY .formatter.exs . + +RUN mix deps.get + +RUN mix deps.compile + +RUN mix release + +# Actual app stage + +FROM alpine:3.19.0 AS runtime + +RUN adduser -D runner --disabled-password + +RUN apk update + +RUN apk upgrade + +RUN apk add ffmpeg youtube-dl + +USER runner + +WORKDIR /app + +COPY --from=builder /build/_build/dev/rel/cassian /app/cassian + +CMD ["/app/cassian/bin/cassian", "start"] \ No newline at end of file diff --git a/README.md b/README.md index 4a5ddc9..b8775ba 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ A Discord bot for music written in Elixir. ## Table of contents - [About](#about) -- [Note regarding Nostrum](#note-regarding-nostrum) - [Note regarding the web part](#note-regarding-the-web-part) - [System requirements](#system-requirements) - [Configuration](#configuration) @@ -23,10 +22,6 @@ The bot is written in Elixir, a functional programming language based upon Erlan The Discord library is [Nostrum](https://github.com/Kraigie/nostrum), written in Elixir and supporting everything from events to voice channels. -## Note regarding Nostrum - -As of now the Hex release of Nostrum `v0.4.6` is a bit buggy so the current version of this bot uses the nightly version of Nostrum. - ## Note regarding the web part This is deprecated. It will be removed once a web app has been created because I don't want to add web functionality to the bot. @@ -39,18 +34,20 @@ This bot needs `youtube-dl` and `ffmpeg` in order to play music. The path of the ### General bot requirements -Of course as this is written in Elixir, you will need Elixir installed. This can be easily done on Debian-based distros: +Of course as this is written in Elixir, you will need Elixir installed. This can be easily done with [asdf](https://asdf-vm.com/). After following the asdf [instructions](https://asdf-vm.com/guide/getting-started.html) one can start adding the required plugins for the runtimes: ```bash -sudo apt-get install elixir +asdf plugin add elixir +asdf plugin add erlang +asdf install ``` -After installing Elixir you should be able to see the version: +After that you should be able to run `elixir -v` ```bash -Erlang/OTP 23 [erts-11.1.7] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe] +Erlang/OTP 26 [erts-14.2.1] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns] -Elixir 1.11.2 (compiled with Erlang/OTP 23) +Elixir 1.16.0 (compiled with Erlang/OTP 24) ``` Try to keep the Elixir version `1.11.2` and up. @@ -62,10 +59,6 @@ To set the bot up-and-running you will need to set a couple of variables. Here's the basic configuration: ```elixir -use Mix.Config -config :porcelain, - driver: Porcelain.Driver.Basic - config :nostrum, ffmpeg: System.get_env("FFMPEG_PATH") || "/usr/bin/ffmpeg", youtubedl: System.get_env("YTDL_PATH") || "/usr/bin/youtube-dl", diff --git a/config/config.exs b/config/config.exs index e306779..8609780 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,11 +1,6 @@ -use Mix.Config - -config :porcelain, - driver: Porcelain.Driver.Basic +import Config config :nostrum, - ffmpeg: System.get_env("FFMPEG_PATH") || "/usr/bin/ffmpeg", - youtubedl: System.get_env("YTDL_PATH") || "/usr/bin/youtube-dl", token: System.get_env("DISCORD_BOT_TOKEN"), num_shards: :auto diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..df059f7 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,10 @@ +import Config + +config :nostrum, + token: System.fetch_env!("DISCORD_BOT_TOKEN"), + num_shards: :auto + +config :cassian, + web_enabled: System.fetch_env!("WEB_ENABLED"), + port: "4000", + force_ssl: System.fetch_env!("FORCE_SSL") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1bc48c5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.0' + +services: + bot: + build: + context: . + dockerfile: Dockerfile + name: cassian-bot + restart: always + environment: + DISCORD_BOT_TOKEN: $DISCORD_BOT_TOKEN + WEB_ENABLED: false + FORCE_SSL: false + diff --git a/lib/cassian.ex b/lib/cassian.ex index f67dfe0..0f4e0cb 100644 --- a/lib/cassian.ex +++ b/lib/cassian.ex @@ -6,8 +6,6 @@ defmodule Cassian do alias Nostrum.Cache.Me - @default_command_prefix Application.get_env(:cassian, :prefix) - @doc """ Get the avatar of the bot itself. """ @@ -15,18 +13,6 @@ defmodule Cassian do def get_own_avatar, do: Cassian.Utils.user_avatar(Me.get()) - @doc """ - Get the command prefix of the bot from a server. if no server - is specific then the default prefix is taken. - - Currently this works only with the preset one as database has not yet - been added. - """ - @spec command_prefix!(server_id :: Nostrum.Struct.Guild.id() | nil) :: binary - def command_prefix!(_server_id \\ nil) do - @default_command_prefix - end - @doc """ Get the user name of bot account. """ diff --git a/lib/cassian/application.ex b/lib/cassian/application.ex index 2c68d5f..4dab79e 100644 --- a/lib/cassian/application.ex +++ b/lib/cassian/application.ex @@ -12,15 +12,9 @@ defmodule Cassian.Application do @doc false def add_children() do - alias Cassian.Consumer - children = [ - %{ - id: Consumer, - start: {Consumer, :start_link, []} - }, - Cassian.Servers.SoundCloudToken + Cassian.Consumer, ] ++ web_child!() children diff --git a/lib/cassian/behaviours/command.ex b/lib/cassian/behaviours/command.ex index eb2beae..0f97e7f 100644 --- a/lib/cassian/behaviours/command.ex +++ b/lib/cassian/behaviours/command.ex @@ -7,12 +7,18 @@ defmodule Cassian.Behaviours.Command do @doc """ The function which is called that does all of the stuff needed in the command. """ - @callback execute(message :: Nostrum.Struct.Message.t(), args :: List.t()) :: :ok + @callback execute(interaction :: Nostrum.Struct.Interaction.t()) :: map() + + @doc """ + The definition as a Discord application command. + """ + @callback application_command_definition() :: map() defmacro __using__(_) do quote do - defmacro handle_command(message, args), do: execute(message, args) @behaviour Cassian.Behaviours.Command + import Bitwise + alias Cassian.Utils.Embed, as: EmbedUtils end end end diff --git a/lib/cassian/behaviours/source_service.ex b/lib/cassian/behaviours/source_service.ex new file mode 100644 index 0000000..1c3148d --- /dev/null +++ b/lib/cassian/behaviours/source_service.ex @@ -0,0 +1,17 @@ +defmodule Cassian.Behaviours.SourceService do + @moduledoc """ + The general service for a source of audio. + """ + + @doc """ + Try to get metadata for a specific song if it's supported. + """ + @callback song_metadata(url :: String.t()) :: {:ok, %Cassian.Structs.Metadata{}} | {:error, :not_found} + + defmacro __using__(_) do + quote do + @behaviour Cassian.Behaviours.SourceService + alias Cassian.Structs.Metadata + end + end +end diff --git a/lib/cassian/commands/bot/help.ex b/lib/cassian/commands/bot/help.ex index dc9af39..df81866 100644 --- a/lib/cassian/commands/bot/help.ex +++ b/lib/cassian/commands/bot/help.ex @@ -1,31 +1,30 @@ defmodule Cassian.Commands.Bot.Help do use Cassian.Behaviours.Command - alias Cassian.Managers.MessageManager - @moduledoc """ The help command. Shows a menu of commands. """ + + def application_command_definition() do + %{ + name: "help", + description: "Get the list of all commands from Cassian" + } + end - @doc false - def execute(message, _args) do - generate_help_embed!() - |> MessageManager.send_embed(message.channel_id) - - :ok + def execute(_interaction) do + %{type: 4, data: %{embeds: [generate_help_embed!()], flags: 1 <<< 6}} end import Cassian.Utils.Embed alias Nostrum.Struct.Embed - def generate_help_embed!() do + defp generate_help_embed!() do create_empty_embed!() |> Embed.put_title("Yo! Thanks for adding me to the server!") |> Embed.put_description( "I currently don't have a propper help command as I'm a WIP. Though once it has been created it will be linked here so " <> - "you can see all of my commands. Although you can start playing music with `#{ - Cassian.command_prefix!() - }play`." + "you can see all of my commands. Although you can start playing music with `/play`." ) |> Embed.put_thumbnail(Cassian.get_own_avatar()) end diff --git a/lib/cassian/commands/bot/ping.ex b/lib/cassian/commands/bot/ping.ex deleted file mode 100644 index d8e5441..0000000 --- a/lib/cassian/commands/bot/ping.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule Cassian.Commands.Bot.Ping do - use Cassian.Behaviours.Command - - alias Cassian.Managers.MessageManager - - @moduledoc """ - The ping command. Shows how many ms was needed to perform the request. - """ - - def call, do: "ping" - - @doc false - def execute(message, _args) do - {:ok, request_time, _} = - message.timestamp - |> DateTime.from_iso8601() - - recieved_time = DateTime.utc_now() - - request_recieved_diff = - recieved_time - |> DateTime.diff(request_time, :millisecond) - - generate_ping_embed!(request_recieved_diff) - |> MessageManager.send_embed(message.channel_id) - - :ok - end - - import Cassian.Utils.Embed - alias Nostrum.Struct.Embed - - def generate_ping_embed!(diff) do - create_empty_embed!() - |> Embed.put_title("Pong!") - |> Embed.put_description("Command took `#{diff}ms` to execute!") - end -end diff --git a/lib/cassian/commands/playback/backward.ex b/lib/cassian/commands/playback/backward.ex index cf64611..143823c 100644 --- a/lib/cassian/commands/playback/backward.ex +++ b/lib/cassian/commands/playback/backward.ex @@ -6,8 +6,17 @@ defmodule Cassian.Commands.Playback.Backward do @doc """ Play the playlist backwards. """ + + def application_command_definition() do + %{ + name: "backward", + description: "Play the playlist backwards." + } + end - def execute(message, _args) do - PlayManager.change_direction_with_notification(message, true) + def execute(interaction) do + {embed, flags} = PlayManager.change_direction_with_notification(interaction, true) + + %{type: 4, data: %{embeds: [embed], flags: flags}} end end diff --git a/lib/cassian/commands/playback/forward.ex b/lib/cassian/commands/playback/forward.ex index 0cff4d3..9ace1b5 100644 --- a/lib/cassian/commands/playback/forward.ex +++ b/lib/cassian/commands/playback/forward.ex @@ -6,8 +6,17 @@ defmodule Cassian.Commands.Playback.Forward do @doc """ Play the playlist forward. """ + + def application_command_definition() do + %{ + name: "forward", + description: "Play the playlist forward." + } + end - def execute(message, _args) do - PlayManager.change_direction_with_notification(message, false) + def execute(interaction) do + {embed, flags} = PlayManager.change_direction_with_notification(interaction, false) + + %{type: 4, data: %{embeds: [embed], flags: flags}} end end diff --git a/lib/cassian/commands/playback/next.ex b/lib/cassian/commands/playback/next.ex index 27ca971..40ac5a6 100644 --- a/lib/cassian/commands/playback/next.ex +++ b/lib/cassian/commands/playback/next.ex @@ -2,12 +2,31 @@ defmodule Cassian.Commands.Playback.Next do use Cassian.Behaviours.Command alias Cassian.Managers.PlayManager + + alias Nostrum.Struct.Embed + + def application_command_definition() do + %{ + name: "next", + description: "Play the next song." + } + end @doc """ Play the next song in the playlist. """ - def execute(message, _args) do - PlayManager.switch_song_with_notification(message, true) + def execute(interaction) do + PlayManager.switch_song_with_notification(interaction, true) + + {embed, flags} = + { + EmbedUtils.create_empty_embed!() + |> Embed.put_title("Changing to next song.") + |> Embed.put_description("It'll start playing soon."), + 1 <<< 6 + } + + %{type: 4, data: %{embeds: [embed], flags: flags}} end end diff --git a/lib/cassian/commands/playback/play.ex b/lib/cassian/commands/playback/play.ex index 84a8ef1..7f2346f 100644 --- a/lib/cassian/commands/playback/play.ex +++ b/lib/cassian/commands/playback/play.ex @@ -1,102 +1,109 @@ defmodule Cassian.Commands.Playback.Play do + require Logger + alias Cassian.Utils + alias Nostrum.Struct.{Embed, ApplicationCommandInteractionDataOption} use Cassian.Behaviours.Command import Cassian.Utils - alias Cassian.Utils.Embed, as: EmbedUtils alias Cassian.Utils.Voice, as: VoiceUtils - alias Cassian.Managers.{MessageManager, PlayManager} + alias Cassian.Managers.PlayManager # Main logic pipe - - def execute(message, args) do - handle_request(message, args) - end - - @doc """ - Handle the request. Continues with the rest of the logic pipe or - sends an embed message that the user isn't connected to channel. - """ - def handle_request(message, args) do - case VoiceUtils.get_sender_voice_id(message) do - {:ok, {_guild_id, voice_id}} -> - handle_connect_possibility(message, voice_id, args) - - # THe user is not in a voice channel... - {:error, :noop} -> - no_channel_error(message) - end - end - - @doc """ - Determine whether you have the possiblity to connect. Continues - with the rest of the logic pipe or sends an embed message - that the bot doesn't have permission to connect. - """ - def handle_connect_possibility(message, voice_id, args) do - if VoiceUtils.can_connect?(message.guild_id, voice_id), - do: handle_metadata(message, voice_id, args), - else: no_permissions_error(message) + + def application_command_definition() do + %{ + name: "play", + description: "Play a song or queue it.", + options: [ + %{ + type: 3, + name: "query", + required: true, + description: "Name of the song of URL for it." + } + ] + } end - @doc """ - It is determined that the caller user is in a voice channel and that the bot has permissions - to connect. Awesome. Now check if the link metadata is correct. If it is correct, continue with the - logic pipe or send an embed that the link is not correct. - """ - def handle_metadata(message, voice_id, args) do - case song_metadata(Enum.fetch!(args, 0)) do - {true, metadata} -> - handle_connect(message, voice_id, metadata) - - {false, :noop} -> - invalid_link_error(message) - end + def execute(interaction) do + Utils.notify_longer_response(interaction) + + {embed, flags} = + with {:ok, {_guild_id, voice_id}} <- VoiceUtils.sender_voice_id(interaction), + {:ok, query} <- fetch_query(interaction.data.options), + {:ok, metadata} <- song_metadata(query), + {:ok, _} <- VoiceUtils.join_or_switch_voice(interaction.guild_id, voice_id), + :ok <- PlayManager.insert!(interaction.guild_id, interaction.channel_id, metadata) do + PlayManager.play_if_needed(interaction.guild_id) + { + EmbedUtils.create_empty_embed!() + |> Embed.put_title("Enqueued the song") + |> Embed.put_description("It'll start playing soon..."), + 1 <<< 6 + } + else + {:error, :not_in_voice} -> + no_channel_error() + {:error, :no_metadata} -> + invalid_link_error() + {:error, :failed_to_join} -> + no_permissions_error() + {:error, :failed_to_get_stream} + invalid_link_error() + end + + %{type: 4, data: %{embeds: [embed], flags: flags}, edit: true} end - - @doc """ - Everything is A-ok. You can connect and the link is valid. Connect to the voice channel and - cache the voice state. Continue wth the pipeline. - """ - def handle_connect(message, voice_id, metadata) do - VoiceUtils.join_or_switch_voice(message.guild_id, voice_id) - PlayManager.insert!(message.guild_id, message.channel_id, metadata) - PlayManager.play_if_needed(message.guild_id) - MessageManager.disable_embed(message) + + # Pattern-matches per-element if the head doesn't have this and it'll try to find the first + # element which has "query" as the name. + defp fetch_query([%ApplicationCommandInteractionDataOption{name: "query", value: value} | _]) do + {:ok, value} end + + defp fetch_query([_ | tail]), do: fetch_query(tail) + + defp fetch_query(_), do: {:error, :no_metadata} # Error handlers @doc """ Generate and send the embed for when a user isn't in a voice channel. """ - def no_channel_error(message) do - EmbedUtils.generate_error_embed( - "Hey you... You're not in a voice channel.", - "I can't play any music if you're not a voice channel. Join one first." - ) - |> MessageManager.send_dissapearing_embed(message.channel_id) + def no_channel_error() do + { + EmbedUtils.generate_error_embed( + "Hey you... You're not in a voice channel.", + "I can't play any music if you're not a voice channel. Join one first." + ), + 1 <<< 6 + } end @doc """ Generate and send the embed for when the bot doesn't have permissions to view, connect or speak in a channel. """ - def no_permissions_error(message) do - EmbedUtils.generate_error_embed( - "And how do you think that's possible?", - "I don't have the permissions to play music there... Fix it up first." - ) - |> MessageManager.send_dissapearing_embed(message.channel_id) + def no_permissions_error() do + { + EmbedUtils.generate_error_embed( + "And how do you think that's possible?", + "I don't have the permissions to play music there or something else really messed me up." + ), + 1 <<< 6 + } end @doc """ Tell the user that the link is not valid. """ - def invalid_link_error(message) do - EmbedUtils.generate_error_embed( - "Yeah, that won't work.", - "The link you tried to provide me isn't working. Recheck it." - ) - |> MessageManager.send_dissapearing_embed(message.channel_id) + def invalid_link_error() do + { + EmbedUtils.generate_error_embed( + "Yeah, that won't work.", + "The link you tried to provide me isn't working. Recheck it." + ), + 1 <<< 6 + } end end diff --git a/lib/cassian/commands/playback/playlist.ex b/lib/cassian/commands/playback/playlist.ex index e2fe1c0..4d9b91a 100644 --- a/lib/cassian/commands/playback/playlist.ex +++ b/lib/cassian/commands/playback/playlist.ex @@ -3,31 +3,39 @@ defmodule Cassian.Commands.Playback.Playlist do alias Cassian.Structs.Playlist alias Cassian.Utils.Embed, as: EmbedUtils alias Nostrum.Struct.Embed - alias Cassian.Managers.MessageManager + + def application_command_definition() do + %{ + name: "playlist", + description: "Show the playlist." + } + end @doc """ Show info about the playlist. """ - def execute(message, _args) do - case Playlist.show(message.guild_id) do + def execute(interaction) do + {embed, flags} = + case Playlist.show(interaction.guild_id) do {:ok, playlist} -> - send_metadata(message, playlist) + {create_embed(playlist), 0} {:error, :noop} -> - send_not_playing(message) + {send_not_playing(), 0} end + + %{type: 4, data: %{embeds: [embed], flags: flags}} end - def send_not_playing(message) do + def send_not_playing() do EmbedUtils.create_empty_embed!() |> EmbedUtils.put_error_color_on_embed() |> Embed.put_title("I don't have any songs in the playlist.") |> Embed.put_description("I just don't have them. Give me songs first.") - |> MessageManager.send_dissapearing_embed(message.channel_id) end - def send_metadata(message, playlist) do + def create_embed(playlist) do {index, sorted} = playlist |> Playlist.order_playlist() @@ -61,10 +69,9 @@ defmodule Cassian.Commands.Playback.Playlist do |> Enum.with_index() |> Enum.reduce([], &filter(&1, &2, index)) |> Enum.join("\n") - + embed |> Embed.put_description(description) - |> MessageManager.send_embed(message.channel_id) end defp filter({metadata, current_index}, acc, index) do diff --git a/lib/cassian/commands/playback/previous.ex b/lib/cassian/commands/playback/previous.ex index 18ef569..b10002c 100644 --- a/lib/cassian/commands/playback/previous.ex +++ b/lib/cassian/commands/playback/previous.ex @@ -1,13 +1,31 @@ defmodule Cassian.Commands.Playback.Previous do use Cassian.Behaviours.Command - + alias Cassian.Managers.PlayManager + alias Nostrum.Struct.Embed\ + + def application_command_definition() do + %{ + name: "previous", + description: "Play the previous song." + } + end @doc """ Play the previous song. """ - def execute(message, _args) do - PlayManager.switch_song_with_notification(message, false) + def execute(interaction) do + PlayManager.switch_song_with_notification(interaction, false) + + {embed, flags} = + { + EmbedUtils.create_empty_embed!() + |> Embed.put_title("Changing to previous song.") + |> Embed.put_description("It'll start playing soon."), + 1 <<< 6 + } + + %{type: 4, data: %{embeds: [embed], flags: flags}} end end diff --git a/lib/cassian/consumer.ex b/lib/cassian/consumer.ex index db16950..2bff41d 100644 --- a/lib/cassian/consumer.ex +++ b/lib/cassian/consumer.ex @@ -8,21 +8,17 @@ defmodule Cassian.Consumer do alias Cassian.Consumers.{Command, VoiceEvent} - def start_link do - Consumer.start_link(__MODULE__) - end - @doc false - def handle_event({:MESSAGE_CREATE, message, _ws_state}) do - if !message.author.bot and is_cassian_command?(message), - do: Command.handle_message(message) + def handle_event({:INTERACTION_CREATE, interaction, _ws_state}) when is_nil(interaction.user.bot) do + Command.handle_interaction(interaction) + :ok end - @dialyzer {:no_return, {:update_status, 3}} - @doc false - def handle_event({:READY, _, _}) do - Nostrum.Api.update_status("", "music 🎶", 2) + def handle_event({:READY, user_data, _}) do + Enum.each(user_data.guilds, &Command.generate_commands/1) + Nostrum.Api.update_status(:online, "music 🎶", 2) + :ok end @doc false @@ -39,15 +35,4 @@ defmodule Cassian.Consumer do def handle_event(_) do :noop end - - @doc """ - Checks whether the command is for this bot. Returns a boolean. - """ - @spec is_cassian_command?(message :: Nostrum.Struct.Message) :: boolean() - def is_cassian_command?(message) do - message.content - |> String.trim_leading() - |> String.downcase() - |> String.starts_with?(Cassian.command_prefix!()) - end end diff --git a/lib/cassian/consumers/command.ex b/lib/cassian/consumers/command.ex index 8842e2e..0750e08 100644 --- a/lib/cassian/consumers/command.ex +++ b/lib/cassian/consumers/command.ex @@ -2,36 +2,65 @@ defmodule Cassian.Consumers.Command do @moduledoc """ Main consumer for the command event of the bot. Redirects it to other commands. """ + + require Logger + alias Nostrum.Api + + alias Cassian.Commands.{Bot, Playback} @doc """ - Handle the mssage. A message has been filtered which is for the bot. - Dynamically find which module should be used for the command and continue on with that. + Handle the user interaction. """ - @spec handle_message(message :: Nostrum.Struct.Message) :: :ok | :noop - def handle_message(message) do - {command, args} = - message.content - |> String.trim_leading() - |> String.split(" ") - |> List.pop_at(0) - |> filter_command() - - case associated_module(command) do + @spec handle_interaction(interaction :: Nostrum.Struct.Interaction.t()) :: :ok | :noop + def handle_interaction(interaction) do + case associated_module(interaction.data.name) do nil -> :noop module -> - module.execute(message, args) - :ok + api_response = + interaction + |> module.execute() + |> (&send_response(interaction, &1)).() + + Logger.debug("API's response is: #{inspect(api_response)}.") end end - - # Filter the prefix from the command in the tuple. - @spec filter_command({command :: String.t(), args :: list(String.t())}) :: - {command :: String.t(), args :: list(String.t())} - defp filter_command({command, args}), - do: - {String.replace_leading(command, Cassian.command_prefix!(), "") |> String.downcase(), args} + + # I know it looks ugly when called in the pipeline *but* it's consitent with + # the library that itneraction should be first and then the response should be + # something additional. + defp send_response(interaction, response = %{edit: true}) do + new_response = %{ + embeds: response.data.embeds, + type: response.type, + flags: response.data.flags + } + + Logger.debug("Sending edit response: #{inspect(new_response)}") + + Api.edit_interaction_response(interaction, new_response) + end + + defp send_response(interaction, response) do + Logger.debug("Sending standard response: #{inspect(response)}") + + Api.create_interaction_response(interaction, response) + end + + @doc """ + Generate the Discord interaction commands for each guild. + """ + @spec generate_commands(Nostrum.Struct.Guild.UnavailableGuild.t()) :: no_return() + def generate_commands(%Nostrum.Struct.Guild.UnavailableGuild{id: guild_id}) do + Nostrum.Api.create_guild_application_command(guild_id, Bot.Help.application_command_definition()) + Nostrum.Api.create_guild_application_command(guild_id, Playback.Backward.application_command_definition()) + Nostrum.Api.create_guild_application_command(guild_id, Playback.Forward.application_command_definition()) + Nostrum.Api.create_guild_application_command(guild_id, Playback.Play.application_command_definition()) + Nostrum.Api.create_guild_application_command(guild_id, Playback.Previous.application_command_definition()) + Nostrum.Api.create_guild_application_command(guild_id, Playback.Next.application_command_definition()) + Nostrum.Api.create_guild_application_command(guild_id, Playback.Playlist.application_command_definition()) + end defp associated_module(command) do alias Cassian.Commands.{Bot, Playback} @@ -40,9 +69,6 @@ defmodule Cassian.Consumers.Command do "help" -> Bot.Help - "ping" -> - Bot.Ping - "backward" -> Playback.Backward diff --git a/lib/cassian/managers/message_manager.ex b/lib/cassian/managers/message_manager.ex index 1c49e1c..52c29aa 100644 --- a/lib/cassian/managers/message_manager.ex +++ b/lib/cassian/managers/message_manager.ex @@ -20,8 +20,8 @@ defmodule Cassian.Managers.MessageManager do :ok {:error, error} -> - Logger.warn(error |> Poison.encode!()) - :nnop + Logger.warning("Failed to edit message: #{inspect(message)} with error: #{inspect(error)}!") + :noop end end diff --git a/lib/cassian/managers/play_manager.ex b/lib/cassian/managers/play_manager.ex index 9a3c885..c083063 100644 --- a/lib/cassian/managers/play_manager.ex +++ b/lib/cassian/managers/play_manager.ex @@ -3,15 +3,20 @@ defmodule Cassian.Managers.PlayManager do Manager for queues. """ + alias Nostrum.Struct.Interaction + alias Nostrum.Snowflake alias Cassian.Structs.{VoiceState, Playlist} alias Cassian.Utils.Voice alias Cassian.Utils.Embed, as: EmbedUtils alias Cassian.Managers.MessageManager alias Nostrum.Struct.{Message, Embed} + + import Bitwise @doc """ Add a song to the playlist. """ + @spec insert!(guild_id :: Snowflake.t(), channel_id :: Snowflake.t(), metadata :: Metadata.t()) :: :ok def insert!(guild_id, channel_id, metadata) do Playlist.insert!(guild_id, metadata) @@ -21,7 +26,13 @@ defmodule Cassian.Managers.PlayManager do |> VoiceState.put() end + @spec alter_index(any()) :: nil | no_return() @doc """ + Alter the new index in the playlist. It will either increment or decrement it. + + * `no repeat`, keep the new index + * `one`, repeat the song + * `all`, alter the index but loop it around (keep it in bounds) """ def alter_index(guild_id) do case Playlist.show(guild_id) do @@ -37,7 +48,7 @@ defmodule Cassian.Managers.PlayManager do playlist.index :all -> - keep_in_bounds(index, playlist.elements) + keep_in_bounds!(index, playlist.elements) end playlist @@ -49,12 +60,12 @@ defmodule Cassian.Managers.PlayManager do end end - def in_bound?(index, ordered) do + defp in_bound?(index, ordered) do index >= 0 and index < length(ordered) end # Keep the index in bounds, loops around. - def keep_in_bounds(index, ordered) do + defp keep_in_bounds!(index, ordered) do size = length(ordered) index = if index >= size, do: 0, else: index if index < 0, do: size - 1, else: index @@ -73,6 +84,8 @@ defmodule Cassian.Managers.PlayManager do """ def play_if_needed(guild_id) do state = VoiceState.get!(guild_id) + + playing_state = false if state.status == :noop and Playlist.exists?(guild_id) do case Playlist.show(guild_id) do @@ -91,23 +104,31 @@ defmodule Cassian.Managers.PlayManager do index = old_index if should_play? do - index = keep_in_bounds(index, ordered) + index = keep_in_bounds!(index, ordered) metadata = Enum.at(ordered, index) - Voice.play_when_ready!(metadata, guild_id) - - notifiy_playing(state.channel_id, metadata) - - state - |> Map.put(:status, :playing) - |> VoiceState.put() + case Voice.play_when_ready(metadata, guild_id, 10) do + {:ok, data} -> + notifiy_playing(state.channel_id, metadata) + + state + |> Map.put(:status, :playing) + |> VoiceState.put() + + playing_state = true + + _ -> + nil + end end _ -> nil end end + + if playing_state, do: {:ok, :will_play}, else: {:error, :wont_play} end @doc """ @@ -198,24 +219,30 @@ defmodule Cassian.Managers.PlayManager do @doc """ Chaange the direction of the playlist and send a notification. """ - @spec change_direction_with_notification(message :: %Message{}, reverse :: boolean()) :: - :ok | :noop - def change_direction_with_notification(message, reverse) do + @spec change_direction_with_notification(interaction :: Interaction.t(), reverse :: boolean()) :: + {embed :: Embed.t(), flags :: integer()} + def change_direction_with_notification(interaction, reverse) do title_part = if reverse, do: "in reverse", else: "normally" - case change_direction(message.guild_id, reverse) do - :ok -> - EmbedUtils.create_empty_embed!() - |> Embed.put_title("Playing #{title_part}.") - |> Embed.put_description("The current playlist will play #{title_part}.") - - :noop -> - EmbedUtils.generate_error_embed( - "There is no playlist.", - "I can't change the direction of the playlist if it doesn't exist." - ) - end - |> MessageManager.send_dissapearing_embed(message.channel_id) + {embed, flags} = + case change_direction(interaction.guild_id, true) do + :ok -> + { + EmbedUtils.create_empty_embed!() + |> Embed.put_title("Playing in reverse.") + |> Embed.put_description("The current playlist will play #{title_part}."), + 0 + } + + :noop -> + { + EmbedUtils.generate_error_embed( + "There is no playlist.", + "I can't change the direction of the playlist if it doesn't exist." + ), + 1 <<< 6 + } + end end @doc """ @@ -297,22 +324,22 @@ defmodule Cassian.Managers.PlayManager do @doc """ Switch to the next or previous song. Also send notification to the channel. """ - @spec switch_song_with_notification(message :: %Message{}, next :: boolean()) :: :ok | :noop - def switch_song_with_notification(message, next) do + @spec switch_song_with_notification(message :: Interaction.t(), next :: boolean()) :: :ok | :noop + def switch_song_with_notification(interaction, next) do title_part = if next, do: "next", else: "previous" - case switch_song(message.guild_id, next) do + case switch_song(interaction.guild_id, next) do :ok -> EmbedUtils.create_empty_embed!() |> Embed.put_title("Playing #{title_part} song.") - :noop -> + _ -> EmbedUtils.generate_error_embed( "There is no playlist.", "You can't change songs if there is no playlist." ) end - |> MessageManager.send_dissapearing_embed(message.channel_id) + |> MessageManager.send_dissapearing_embed(interaction.channel_id) end @doc """ diff --git a/lib/cassian/servers/playlist.ex b/lib/cassian/servers/playlist.ex index 26c3edb..51f047c 100644 --- a/lib/cassian/servers/playlist.ex +++ b/lib/cassian/servers/playlist.ex @@ -5,6 +5,7 @@ defmodule Cassian.Servers.Playlist do use GenServer + require Logger alias Cassian.Structs.{Metadata, Playlist} @doc """ @@ -42,6 +43,7 @@ defmodule Cassian.Servers.Playlist do """ @spec delete(guild_id :: Snowflake.t()) :: :ok def delete(guild_id) do + Logger.info("Disconnected from voice chat. Deleting playlist for guild_id: #{inspect(guild_id)}") if exists?(guild_id), do: GenServer.stop(from_guild_id(guild_id)) :ok end diff --git a/lib/cassian/servers/sound_cloud_token.ex b/lib/cassian/servers/sound_cloud_token.ex deleted file mode 100644 index 4fc66c4..0000000 --- a/lib/cassian/servers/sound_cloud_token.ex +++ /dev/null @@ -1,58 +0,0 @@ -defmodule Cassian.Servers.SoundCloudToken do - @moduledoc """ - Server which stores the SoundCloud `client_id`, automatically refreshes it every 15 minutes. - """ - - use GenServer - require Logger - - # 15 minutes~ - @timeout 900_000 - - # API - - @doc """ - Get the current SoundCloud client ID. - """ - @spec client_id() :: Sting.t() - def client_id do - GenServer.call(__MODULE__, :client_id) - end - - # Server-side - - @impl true - def handle_call(:client_id, _from, state) do - {:reply, state, state} - end - - @impl true - def handle_info(:timeout, state) do - Logger.debug("Got timeout with state #{state}") - state = acquire_new_client_id!() - Logger.debug("New client id is: #{state}") - {:noreply, state, @timeout} - end - - def start_link(_) do - GenServer.start_link(__MODULE__, [nil], name: __MODULE__) - end - - @impl true - def init(_), - do: {:ok, acquire_new_client_id!(), @timeout} - - defp acquire_new_client_id!() do - scripts = - HTTPoison.get!("https://soundcloud.com/discover") - |> Map.get(:body) - |> Floki.parse_document!() - |> Floki.find("script") - - {"script", [_head | [{"src", source}]], _} = Enum.at(scripts, length(scripts) - 2) - - body = HTTPoison.get!(source).body - - Regex.run(~r/(?<=,client_id:\")(.*)(?=\",env:)/, body) |> List.first() - end -end diff --git a/lib/cassian/servers/voice_state.ex b/lib/cassian/servers/voice_state.ex index 9d57dd4..b0ba29f 100644 --- a/lib/cassian/servers/voice_state.ex +++ b/lib/cassian/servers/voice_state.ex @@ -71,19 +71,20 @@ defmodule Cassian.Servers.VoiceState do @doc false def handle_cast({:put, new}, _state) do - Logger.debug("Putting new value #{Poison.encode!(new)}") + Logger.debug("Putting new voice state: #{inspect(new)}") {:noreply, new, @timeout} end @doc false def handle_info(:timeout, %VoiceState{status: :noop} = state) do - Logger.debug("Matched #{Poison.encode!(state)}") + Logger.info("Matched noop voice state.") + Logger.info("Noop voice state on timeout: #{inspect(state)}") {:stop, {:shutdown, :afk}, state} end @doc false def handle_info(:timeout, state) do - Logger.debug("Not matched #{Poison.encode!(state)}") + Logger.debug("Did not match noop voice state: #{inspect(state)}") {:noreply, state, @timeout} end @@ -93,7 +94,8 @@ defmodule Cassian.Servers.VoiceState do end def terminate({:shutdown, :afk}, state) do - Logger.debug("Clossing #{Poison.encode!(state)}") + Logger.info("Shutdown state due to afk on guild_id: #{state.guild_id}.") + Logger.debug("Clossing voice state due to afk: #{inspect(state)}.") Cassian.Utils.Voice.join_or_switch_voice(state.guild_id, nil) {:shutdown, :afk} end diff --git a/lib/cassian/services/sound_cloud.ex b/lib/cassian/services/sound_cloud.ex new file mode 100644 index 0000000..06641c2 --- /dev/null +++ b/lib/cassian/services/sound_cloud.ex @@ -0,0 +1,39 @@ +defmodule Cassian.Services.SoundCloud do + @moduledoc """ + Service module for SoundCloud songs. + """ + + use Cassian.Behaviours.SourceService + + require Logger + + def song_metadata(url) do + with {json_body, 0} <- System.cmd("youtube-dl", [url, "--dump-json"]), + {:ok, body = %{"extractor" => "soundcloud"}} <- Poison.decode(json_body) do + Logger.debug(inspect(body)) + + metadata = + %Metadata{ + title: body["title"], + author: body["uploader"], + provider: "soundcloud", + link: body["webpage_url"], + color: "ff9033", + thumbnail_url: body["thumbnail"], + stream_link: url, + stream_method: :ytdl + } + + {:ok, metadata} + else + {output, code} when is_integer(code) -> + Logger.critical("youtube-dl failed with code: #{inspect(code)} and output: #{inspect(output)}!") + {:error, :no_youtube_dl} + {:error, _} -> + Logger.critical("SoundCloud body failed to decode.") + {:error, :json_decode} + {:ok, _} -> + Logger.debug("URL is not a soundcloud one: #{inspect(url)}") + end + end +end diff --git a/lib/cassian/services/sound_cloud_service.ex b/lib/cassian/services/sound_cloud_service.ex deleted file mode 100644 index 41604a1..0000000 --- a/lib/cassian/services/sound_cloud_service.ex +++ /dev/null @@ -1,145 +0,0 @@ -defmodule Cassian.Services.SoundCloudService do - @moduledoc """ - Service module which does most of the calls for the SoundCloud API. - """ - - alias Cassian.Structs.Metadata - - defdelegate client_id(), to: Cassian.Servers.SoundCloudToken - - @doc """ - Get the metadata for the song from the oembed... embed... - """ - @spec oembed_song_data(url :: String.t()) :: {:ok, %Metadata{}} | {:error, any()} - def oembed_song_data(url) do - link = "https://soundcloud.com/oembed" - - headers = [ - "User-agent": "#{Cassian.username!()} #{Cassian.version!()}", - Accept: "Application/json; Charset=utf-8" - ] - - params = %{ - url: url, - format: :json - } - - case HTTPoison.get(link, headers, params: params) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - body = - body - |> Poison.decode!(keys: :atoms) - - body = %Metadata{ - title: body.title, - author: body.author_name, - provider: "soundcloud", - link: url, - color: "ff9033", - thumbnail_url: body.thumbnail_url, - stream_link: nil, - stream_method: :url - } - - {:ok, body} - - {_, %HTTPoison.Response{status_code: code}} -> - {:error, code} - end - end - - @doc """ - Get the SoundCloud raw stream from a SoundCloud url. - """ - @spec stream_from_url(url :: String.t()) :: {:ok, String.t()} | {:error, any()} - def stream_from_url(url) do - case acquire_track_id(url) do - {:ok, track_id} -> - case get_progressive_link(track_id) do - {:ok, progressive_transcoding} -> - stream_url(progressive_transcoding) - - {:error, _} -> - nil - end - - _ -> - {:error, nil} - end - end - - @doc """ - Get the SoundCloud track id from a SoundCloud url. - """ - @spec acquire_track_id(url :: String.t()) :: {:ok, String.t()} | {:error, any()} - def acquire_track_id(url) do - headers = [ - # I honestly have no clue what is the content type... - {"Content-Type", "*/*"} - ] - - params = %{ - url: url, - format: "json" - } - - case HTTPoison.get("https://soundcloud.com/oembed", headers, - params: params, - follow_redirect: true - ) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - { - :ok, - # Woohoo Regex magic - Regex.run(~r/(?<=tracks%2F)(.*)(?=&show_artwork)/, body) |> List.first() - } - - _ -> - {:error, nil} - end - end - - @spec get_progressive_link(track_id :: String.t()) :: {:ok, String.t()} | {:error, any()} - def get_progressive_link(track_id) do - url = "https://api-v2.soundcloud.com/tracks/#{track_id}" - - params = %{ - client_id: client_id() - } - - # Some information is here - case HTTPoison.get(url, %{}, params: params) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - # Doesn't look safe but as long as SoundCloud IS giving an OK response the JSON is okay... - url = - Poison.decode!(body, %{keys: :atoms}).media.transcodings - |> Enum.filter(fn transcoding -> transcoding.format.protocol == "progressive" end) - |> List.first() - |> Map.get(:url) - - {:ok, url} - - _ -> - {:error, nil} - end - end - - @doc """ - Get the stream URL from the transcoded-progressive url. - """ - @spec stream_url(progressive_transcoding_url :: String.t()) :: - {:ok, String.t()} | {:error, any()} - def stream_url(progressive_transcoding_url) do - params = %{ - client_id: client_id() - } - - case HTTPoison.get(progressive_transcoding_url, %{}, params: params) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - {:ok, body |> Poison.decode!(keys: :atoms) |> Map.get(:url)} - - _ -> - {:error, nil} - end - end -end diff --git a/lib/cassian/services/youtube.ex b/lib/cassian/services/youtube.ex new file mode 100644 index 0000000..65e0984 --- /dev/null +++ b/lib/cassian/services/youtube.ex @@ -0,0 +1,43 @@ +defmodule Cassian.Services.Youtube do + @moduledoc """ + Service module for Youtube songs. + """ + + use Cassian.Behaviours.SourceService + + require Logger + + def song_metadata(url) do + with {json_body, 0} <- System.cmd("youtube-dl", [url, "--dump-json", "--no-playlist", "--quiet"]), + {:ok, body = %{"extractor" => "youtube"}} <- Poison.decode(json_body) do + + Logger.debug("Got Youtube JSON from youtube-dl: #{inspect(body)}.") + + metadata = + %Metadata{ + title: body["title"], + author: body["uploader"], + provider: "youtube", + link: body["webpage_url"], + color: "ff3333", + thumbnail_url: body["thumbnail"], + stream_link: url, + stream_method: :ytdl + } + + Logger.debug("Giving metadata: #{inspect(metadata)}.") + + {:ok, metadata} + else + {:ok, json} when is_map(json) -> + Logger.debug("The link is not a youtube one: #{inspect(json)}!") + {:error, :no_youtube_dl} + {output, code} when is_integer(code) -> + Logger.critical("youtube-dl failed with code: #{inspect(code)} and output: #{inspect(output)}!") + {:error, :no_youtube_dl} + {:error, _} -> + Logger.critical("Youtube body failed to decode.") + {:error, :json_decode} + end + end +end diff --git a/lib/cassian/services/youtube_service.ex b/lib/cassian/services/youtube_service.ex deleted file mode 100644 index 4f4bd60..0000000 --- a/lib/cassian/services/youtube_service.ex +++ /dev/null @@ -1,126 +0,0 @@ -defmodule Cassian.Services.YoutubeService do - @moduledoc """ - Service module which corresponds to YouTube calls. - """ - - alias Cassian.Structs.Metadata - - @doc """ - Create metadata from a youtube song. - """ - @spec oembed_song_data(url :: String.t()) :: {:ok, %Metadata{}} | {:error, Integer.t()} - def oembed_song_data(url) do - link = "https://www.youtube.com/oembed" - - headers = [ - "User-agent": "#{Cassian.username!()} #{Cassian.version!()}", - Accept: "Application/json; Charset=utf-8" - ] - - params = %{ - url: url, - format: :json - } - - case HTTPoison.get(link, headers, params: params) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - body = - body - |> Poison.decode!(keys: :atoms) - - # Doing some sanitization for the link so that we get a singleton song... - url = "https://www.youtube.com/watch?v=#{ - Regex.run(~r/(?<=\/vi\/)(.*)(?=\/)/, body.thumbnail_url) - }" - - body = %Metadata{ - title: body.title, - author: body.author_name, - provider: "youtube", - link: url, - color: "ff3333", - thumbnail_url: body.thumbnail_url, - stream_link: url, - stream_method: :ytdl - } - - {:ok, body} - - {_, %HTTPoison.Response{status_code: code}} -> - {:error, code} - end - end - - @doc """ - Get a list of metadatas from a youtube playlist. - """ - @spec playlist_information(link :: String.t()) :: - {:ok, list(%Metadata{})} - | {:error, :invalid} - | {:error, :broken} - | {:error, :no_playlist} - def playlist_information(link) do - %URI{query: query} = URI.parse(link) - - case URI.decode_query(query || "") |> Map.get("list") do - nil -> - {:error, :invalid} - - list -> - url = "https://www.youtube.com/embed/videoseries" - - params = %{ - list: list - } - - headers = [ - "User-agent": "#{Cassian.username!()} #{Cassian.version!()}" - ] - - case HTTPoison.get(url, headers, params: params) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - response = - Regex.scan(~r/(?<=ytcfg\.set\().*?(?=\);)/, body) - |> Poison.decode!(keys: :atoms) - |> Map.get(:PLAYER_VARS) - |> Map.get(:embedded_player_response) - - case response |> Poison.decode() do - {:ok, json} -> - songs = - json - |> Map.get("embedPreview") - |> Map.get("thumbnailPreviewRenderer") - |> Map.get("playlist") - |> Map.get("playlistPanelRenderer") - |> Map.get("contents") - |> Enum.map(&playlist_element_to_meta/1) - - {:ok, songs} - - {:error, _} -> - {:error, :no_playlist} - end - - _ -> - {:error, :broken} - end - end - end - - # Transform to meta... - defp playlist_element_to_meta(element) do - element = element["playlistPanelVideoRenderer"] - - %Metadata{ - title: element["title"]["runs"] |> List.first() |> Map.get("text"), - author: element["longBylineText"]["runs"] |> List.first() |> Map.get("text"), - provider: "youtube", - link: "https://www.youtube.com/watch?v=#{element["videoId"]}", - color: "ff3333", - thumbnail_url: element["thumbnail"]["thumbnails"] |> List.last() |> Map.get("url"), - stream_link: "https://www.youtube.com/watch?v=#{element["videoId"]}", - stream_method: :ytdl - } - end -end diff --git a/lib/cassian/structs/voice_permissions.ex b/lib/cassian/structs/voice_permissions.ex deleted file mode 100644 index 57980b5..0000000 --- a/lib/cassian/structs/voice_permissions.ex +++ /dev/null @@ -1,77 +0,0 @@ -defmodule Cassian.Structs.VoicePermissions do - @moduledoc """ - Struct for the permissions of the bot. Generally this should be - used per channel... - """ - - defstruct [ - :administrator, - :view_channel, - :connect, - :speak - ] - - @type t() :: %__MODULE__{ - administrator: boolean(), - view_channel: boolean(), - connect: boolean(), - speak: boolean() - } - - @typedoc "Permission whether the bot is an admin." - @type administrator :: boolean() - - @typedoc "Permission whether the bot can view the channel." - @type view_channel :: boolean() - - @typedoc "Permission whether the bot can connect to the channel." - @type connect :: boolean() - - @typedoc "Permission whether the bot can speak in the channel" - @type speak :: boolean() - - alias Cassian.Utils.Permissions, as: Util - - @doc """ - Generate the struct from the perm number. - """ - @spec from_number(value :: integer()) :: __MODULE__.t() - def from_number(value) do - %__MODULE__{ - administrator: Util.admin?(value), - view_channel: Util.view_channel?(value), - connect: Util.connect?(value), - speak: Util.speak?(value) - } - end - - @doc """ - Get the base server permissions. - """ - @spec base_server_permissions(guild_id :: Snowflake.t()) :: __MODULE__.t() - def base_server_permissions(guild_id) do - Util.server_permissions(guild_id) - |> from_number() - end - - @doc """ - Get the permission for a channel in a guild. - """ - @spec channel_permission( - user :: Nostrum.Struct.User, - guild_id :: Snowflake.t(), - channel_id :: Snowflake.t() - ) :: __MODULE__.t() - def channel_permission(user, guild_id, channel_id) do - Util.channel_permissions(user, guild_id, channel_id) - |> from_number() - end - - @doc """ - Get the permission for a channel in a guild for this bot. - """ - @spec my_channel_permissions(guild_id :: Snowflake.t(), channel_id :: Snowflake.t()) :: - __MODULE__.t() - def my_channel_permissions(guild_id, channel_id), - do: channel_permission(Nostrum.Cache.Me.get(), guild_id, channel_id) -end diff --git a/lib/cassian/utils.ex b/lib/cassian/utils.ex index 89b6f4b..7a2ded7 100644 --- a/lib/cassian/utils.ex +++ b/lib/cassian/utils.ex @@ -3,7 +3,8 @@ defmodule Cassian.Utils do Module for general utils... """ - alias Cassian.Services.{YoutubeService, SoundCloudService} + alias Cassian.Structs.Metadata + alias Cassian.Services.{Youtube, SoundCloud} @doc """ Get the user avatar url. @@ -14,23 +15,30 @@ defmodule Cassian.Utils do end @doc """ - Check whether a link is a YouTube one. + Get the song metadata if it's from a valid provider """ - @spec song_metadata(link :: String.t()) :: {true, metadata :: Hash} | {false, :noop} + @spec song_metadata(link :: String.t()) :: {:ok, metadata :: Metadata.t()} | {:error, :no_metadata} def song_metadata(link) do - - case YoutubeService.oembed_song_data(link) do - {:ok, metadata} -> - {true, metadata} + [Youtube, SoundCloud] + |> Enum.find_value({:error, :no_metadata}, &check_for_data(&1, link)) + end + + @doc """ + Create an interaction response for events which can take a little bit longer. This will keep it like that + for roughly 15 minutes. + """ + @spec notify_longer_response(interaction :: Nostrum.Struct.Interaction.t(), flags :: integer()) :: no_return() + def notify_longer_response(interaction, flags \\ 64) do + Nostrum.Api.create_interaction_response(interaction, %{type: 5, data: %{flags: flags}}) + end + + defp check_for_data(module, link) do + case module.song_metadata(link) do + {:ok, data} -> + {:ok, data} _ -> - case SoundCloudService.oembed_song_data(link) do - {:ok, metadata} -> - {true, metadata} - - _ -> - {false, :noop} - end + nil end end end diff --git a/lib/cassian/utils/embed.ex b/lib/cassian/utils/embed.ex index aa38ad6..1c4a59b 100644 --- a/lib/cassian/utils/embed.ex +++ b/lib/cassian/utils/embed.ex @@ -7,7 +7,7 @@ defmodule Cassian.Utils.Embed do Add a color on an embed. The `color` params ia a hex string value of the color. It will be automaically converted to something Discord can use. """ - @spec put_color_on_embed(embed :: Embed, color :: String.t()) :: Embed + @spec put_color_on_embed(embed :: Embed.t(), color :: String.t()) :: Embed.t() def put_color_on_embed(embed, color \\ "#6996ff") do {color, _} = color @@ -24,7 +24,7 @@ defmodule Cassian.Utils.Embed do @doc """ Create an empty embed. It has the default color of the bot. """ - @spec create_empty_embed!() :: Embed + @spec create_empty_embed!() :: Embed.t() def create_empty_embed!() do %Nostrum.Struct.Embed{} |> put_color_on_embed() diff --git a/lib/cassian/utils/permissions.ex b/lib/cassian/utils/permissions.ex deleted file mode 100644 index 6eaf48c..0000000 --- a/lib/cassian/utils/permissions.ex +++ /dev/null @@ -1,109 +0,0 @@ -defmodule Cassian.Utils.Permissions do - alias Nostrum.Cache.{GuildCache, ChannelCache} - use Bitwise - - @moduledoc """ - Util module for building permissions for the bot. - """ - - @doc """ - Get the channel permission for the bot. Doesn't give a struct but rather a flag-number - which can be later used to extract the permissions bits from (is a bit really because it's hexadecimal but okay) - """ - @spec channel_permissions( - user :: Nostrum.Struct.User, - guild_id :: Snowflake.t(), - channel_id :: Snowflake.t() - ) :: integer() - def channel_permissions(user, guild_id, channel_id) do - user_id = user.id - roles = GuildCache.get!(guild_id).members[user_id].roles ++ [guild_role(guild_id).id] - - overs = - ChannelCache.get!(channel_id) - |> Map.get(:permission_overwrites) - |> Enum.filter(&connected?(&1, roles, user_id)) - |> Enum.reduce(%{allows: [], denies: []}, &accumulate_overs/2) - - perms = - Enum.reduce(overs.denies, server_permissions(guild_id), fn deny, perms -> - perms &&& ~~~deny - end) - - Enum.reduce(overs.allows, perms, fn allow, perms -> perms ||| allow end) - end - - defp accumulate_overs(over, acc) do - %{allows: acc.allows ++ [over.allow], denies: acc.denies ++ [over.deny]} - end - - defp connected?(overwrite, roles, user_id) do - case overwrite.type do - :role -> - overwrite.id in roles - - :member -> - overwrite.id == user_id - end - end - - @doc """ - Get the guild role based upon the name. - """ - @spec guild_role(guild_id :: Snowflake.t(), name :: String.t()) :: Nostrum.Struct.Guild.Role.t() - def guild_role(guild_id, name \\ "@everyone") do - GuildCache.get!(guild_id).roles - |> Enum.reduce([], fn {_, role}, acc -> acc ++ [role] end) - |> Enum.filter(fn role -> role.name == name end) - |> List.first() - end - - @doc """ - Get the permissoin of the server for the role name - """ - @spec server_permissions(guild_id :: Snowflake.t(), name :: String.t()) :: integer() - def server_permissions(guild_id, name \\ "@everyone") do - guild_role(guild_id, name) - |> Map.get(:permissions) - end - - @doc """ - Check whether the integer has a permission. - """ - @spec has_perm(value :: integer(), perm :: integer()) :: boolean() - def has_perm(value, perm) do - (value &&& perm) == perm - end - - @doc """ - Check whether the value has the `connect` permission. - """ - @spec connect?(value :: integer()) :: boolean() - def connect?(value) do - has_perm(value, 0x00100000) - end - - @doc """ - Check whether the value has the `admin` permission. - """ - @spec admin?(value :: integer()) :: boolean() - def admin?(value) do - has_perm(value, 0x00000008) - end - - @doc """ - Check whether the value has the `view_channel` permission. - """ - @spec view_channel?(value :: integer()) :: boolean() - def view_channel?(value) do - has_perm(value, 0x00000400) - end - - @doc """ - Check whether the value has the `view_channel` permission. - """ - @spec view_channel?(value :: integer()) :: boolean() - def speak?(value) do - has_perm(value, 0x00200000) - end -end diff --git a/lib/cassian/utils/voice.ex b/lib/cassian/utils/voice.ex index ecf6a62..a236602 100644 --- a/lib/cassian/utils/voice.ex +++ b/lib/cassian/utils/voice.ex @@ -1,17 +1,35 @@ defmodule Cassian.Utils.Voice do - alias Nostrum.Api - alias Cassian.Structs.VoicePermissions + alias Nostrum.{Snowflake, Api, ConsumerGroup} alias Nostrum.Cache.GuildCache alias Cassian.Structs.Metadata + alias Nostrum.Struct.Guild + + require Logger @doc """ Join or switch from the voice channel. Set the channel to nil to - leave it. + leave it. It can take up to one second to get the correct event from discord + or fail with `{:error, :failed_to_join}` tuple. """ - @spec join_or_switch_voice(guild_id :: Snowflake.t(), channel_id :: Snowflake.t()) :: :ok + @spec join_or_switch_voice(guild_id :: Snowflake.t(), channel_id :: Snowflake.t() | nil) :: {:ok, :joined} | {:ok, :present} | {:error, :failed_to_join} def join_or_switch_voice(guild_id, channel_id) do guild_id |> Api.update_voice_state(channel_id, false, true) + + if Nostrum.Voice.ready?(guild_id) do + {:ok, :present} + else + ConsumerGroup.join() + receive do + {:event, {:VOICE_STATE_UPDATE, %Nostrum.Struct.Event.VoiceState{}, _socket}} -> + Logger.info("Joined voice chat on guild_id: #{inspect(guild_id)}") + {:ok, :joined} + after + 5_000 -> + Logger.error("Failed to join voice chat on guild: #{guild_id}.") + {:error, :failed_to_join} + end + end end @doc """ @@ -23,89 +41,53 @@ defmodule Cassian.Utils.Voice do |> Api.update_voice_state(nil) end - @doc """ - Check if the bot can connect to a specific voice channel. - """ - @spec can_connect?(guild_id :: Snowflake.t(), voice_id :: Snowflake.t()) :: boolean() - def can_connect?(guild_id, voice_id) do - perms = VoicePermissions.my_channel_permissions(guild_id, voice_id) - - perms.administrator || perms.connect - end - defguard positive_integer(value) when is_integer(value) and value > 0 @doc """ Play the music with a max retry amount. If the retry amount is less than zero it will just fail automatically. - Every retry approx lasts for approx. `100ms`. + Every retry approx lasts for approx one second. """ @spec play_when_ready(metadata :: %Metadata{}, guild_id :: Snowflake.t(), max_retries :: integer()) :: - {:ok, :ok | any()} | {:error, :failed_max} + {:ok, :ok | any()} | {:error, :failed_max} | {:error, :failed_to_get_stream} def play_when_ready(metadata, guild_id, max_retries) when is_integer(max_retries) and max_retries > 0 do + + Logger.info("play_when_ready/3 on guild_id: #{inspect(guild_id)} failed. Trying #{inspect(max_retries)} more tries.") + Logger.debug("Trying to play song from metadata: #{inspect(metadata)} in guild id: #{guild_id}. Remaining retries: #{max_retries}.") + if Nostrum.Voice.ready?(guild_id) do - {:ok, Nostrum.Voice.play(guild_id, stream_url!(metadata), metadata.stream_method)} + Logger.info("Joined voice chat on guild_id: #{inspect(guild_id)}.") + Logger.debug("Voice is ready. Streaming audiosource: #{inspect(metadata.stream_link)}") + {:ok, Nostrum.Voice.play(guild_id, metadata.stream_link, metadata.stream_method)} else - :timer.sleep(100) + :timer.sleep(1000) play_when_ready(metadata, guild_id, max_retries - 1) end end - def play_when_ready(_, _, _) do - {:ok, :failed_max} - end - - defp stream_url!(metadata) do - case metadata.provider do - "soundcloud" -> - case Cassian.Services.SoundCloudService.stream_from_url(metadata.link) do - {:ok, stream} -> - stream - - _ -> - nil - end - - _ -> - metadata.stream_link - end - end - - @doc """ - Play the music without a max retry. This in theory can infinitely loop if the bot is never ready to play music. - - See `play_when_ready/3` as a safe way to play this. - """ - def play_when_ready!(metadata, guild_id) do - if Nostrum.Voice.ready?(guild_id) do - Nostrum.Voice.play(guild_id, stream_url!(metadata), metadata.stream_method) - else - :timer.sleep(100) - play_when_ready!(metadata, guild_id) - end + def play_when_ready(_, guild_id, _) do + Logger.error("Failed to join voice chat on guild_id: #{inspect(guild_id)}.") + {:error, :failed_max} end @doc """ Safely the current voice id in which the user is. Also returns the guild id. """ - @spec get_sender_voice_id(message :: Nostrum.Struct.Channel) :: - {:ok, {guild_id :: String.t(), channel_id :: String.t()}} | {:error, :noop} - def get_sender_voice_id(message) do - voice_id = - GuildCache.get!(message.guild_id) - |> Map.fetch!(:voice_states) - |> Enum.filter(fn state -> state.user_id == message.author.id end) - |> List.first() - |> extract_id() + @spec sender_voice_id(interaction :: Nostrum.Struct.Interaction.t()) :: + {:ok, {guild_id :: Snowflake.t(), channel_id :: Snowflake.t()}} | {:error, :not_in_voice} + def sender_voice_id(interaction = %Nostrum.Struct.Interaction{user: user, guild_id: guild_id}) do + Logger.debug("Checking if user is in voice: #{inspect(interaction)}") - if voice_id do - {:ok, {message.guild_id, voice_id}} + with {:ok, %Guild{voice_states: voice_states}} <- GuildCache.get(guild_id), + new_filter <- Enum.filter(voice_states, fn state -> state.user_id == user.id end), + voice_channel <- List.first(new_filter), + false <- is_nil(voice_channel) do + Logger.debug("User is present in voice chat: #{inspect(voice_channel)}") + {:ok, {guild_id, voice_channel.channel_id}} else - {:error, :noop} + _ -> + Logger.debug("User is not present in voice channel!") + {:error, :not_in_voice} end end - - defp extract_id(channel) do - channel[:channel_id] - end end diff --git a/mix.exs b/mix.exs index e03e393..9ed7f4d 100644 --- a/mix.exs +++ b/mix.exs @@ -22,11 +22,10 @@ defmodule Cassian.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:nostrum, git: "https://github.com/Kraigie/nostrum.git"}, + {:nostrum, github: "Kraigie/nostrum"}, + {:poison, "~> 5.0"}, {:cowlib, "~> 2.9.1", override: true}, - {:plug_cowboy, "~> 2.4"}, - {:poison, "~> 4.0", override: true}, - {:floki, "~> 0.31.0"} + {:plug_cowboy, "~> 2.4"} ] end end diff --git a/mix.lock b/mix.lock index 01a61f3..ff9e4a2 100644 --- a/mix.lock +++ b/mix.lock @@ -1,34 +1,25 @@ %{ - "certifi": {:hex, :certifi, "2.5.3", "70bdd7e7188c804f3a30ee0e7c99655bc35d8ac41c23e12325f36ab449b70651", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "ed516acb3929b101208a9d700062d520f3953da3b6b918d866106ffa980e1c10"}, - "chacha20": {:hex, :chacha20, "1.0.2", "73c5e96eba5e94917603a43c5c7c6b049436a5d71d9d3182781c345d87d28c8b", [:mix], [], "hexpm", "549b89314cbffa0893ef923d999d625c227cab5f1301133598793225f02a3963"}, - "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, - "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"}, + "castle": {:hex, :castle, "0.3.0", "47b1a550b2348a6d7e60e43ded1df19dca601ed21ef6f267c3dbb1b3a301fbf5", [:mix], [{:forecastle, "~> 0.1.0", [hex: :forecastle, repo: "hexpm", optional: false]}], "hexpm", "dbdc1c171520c4591101938a3d342dec70d36b7f5b102a5c138098581e35fcef"}, + "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, + "chacha20": {:hex, :chacha20, "1.0.4", "0359d8f9a32269271044c1b471d5cf69660c362a7c61a98f73a05ef0b5d9eb9e", [:mix], [], "hexpm", "2027f5d321ae9903f1f0da7f51b0635ad6b8819bc7fe397837930a2011bc2349"}, + "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, - "curve25519": {:hex, :curve25519, "1.0.4", "e570561b832c29b3ce4fd8b9fcd9c9546916188860568f1c1fc9428d7cb00894", [:mix], [], "hexpm", "1a068bf9646e7067bf6aa5bf802b404002734e09bb5300f098109df03e31f9f5"}, - "ed25519": {:hex, :ed25519, "1.3.2", "e3a2d4badf57f0799279cf09925bd761ec38df6df3696e266585626280b5c0ad", [:mix], [], "hexpm", "2290e46e0e23717adbe20632c6dd29aa71a46ca6e153ef7ba41fe1204f66f859"}, - "equivalex": {:hex, :equivalex, "1.0.2", "b9a9aaf79f2556288f514218653beaddb15afa2af407bfec37c5c4906e39f514", [:mix], [], "hexpm", "f7f8127c59be715ee6288f8c59fa8fc40e6428fb5c9bd2a001de2c9b1ff3f1c2"}, - "floki": {:hex, :floki, "0.31.0", "f05ee8a8e6a3ced4e62beeb2c79a63bc8e12ab98fbaaf6e6a3d9b76b1278e23f", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "b05afa372f5c345a5bf240ac25ea1f0f3d5fcfd7490ac0beeb4a203f9444891e"}, - "gen_stage": {:hex, :gen_stage, "1.1.0", "dd0c0f8d2f3b993fdbd3d58e94abbe65380f4e78bdee3fa93d5618d7d14abe60", [:mix], [], "hexpm", "7f2b36a6d02f7ef2ba410733b540ec423af65ec9c99f3d1083da508aca3b9305"}, - "gun": {:hex, :gun, "1.3.3", "cf8b51beb36c22b9c8df1921e3f2bc4d2b1f68b49ad4fbc64e91875aa14e16b4", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "3106ce167f9c9723f849e4fb54ea4a4d814e3996ae243a1c828b256e749041e0"}, - "hackney": {:hex, :hackney, "1.17.0", "717ea195fd2f898d9fe9f1ce0afcc2621a41ecfe137fae57e7fe6e9484b9aa99", [:rebar3], [{:certifi, "~>2.5", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "64c22225f1ea8855f584720c0e5b3cd14095703af1c9fbc845ba042811dc671c"}, - "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, - "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "kcl": {:hex, :kcl, "1.3.2", "40ca3e6c6421781b6db8ba2e5354e64c975f19a3073aed62f632e6edcb714148", [:mix], [{:curve25519, ">= 1.0.4", [hex: :curve25519, repo: "hexpm", optional: false]}, {:ed25519, "~> 1.3", [hex: :ed25519, repo: "hexpm", optional: false]}, {:poly1305, "~> 1.0", [hex: :poly1305, repo: "hexpm", optional: false]}, {:salsa20, "~> 1.0", [hex: :salsa20, repo: "hexpm", optional: false]}], "hexpm", "f66d34ef4c9be59fa439e8ca861daed074e6542306a81dabe8e3ab2be9dc78fd"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "nostrum": {:git, "https://github.com/Kraigie/nostrum.git", "4f05b3582117ac61861125da68482662649b7a02", []}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "plug": {:hex, :plug, "1.11.1", "f2992bac66fdae679453c9e86134a4201f6f43a687d8ff1cd1b2862d53c80259", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "23524e4fefbb587c11f0833b3910bfb414bf2e2534d61928e920f54e3a1b881f"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.1", "5c854427528bf61d159855cedddffc0625e2228b5f30eff76d5a4de42d896ef4", [:mix], [], "hexpm", "6961c0e17febd9d0bfa89632d391d2545d2e0eb73768f5f50305a23961d8782c"}, - "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, - "poly1305": {:hex, :poly1305, "1.0.2", "c6bb74cbe79747cc12aa1580791c2bd8e0f062bc8faaf117b756e675cfaea03d", [:mix], [{:chacha20, "~> 1.0", [hex: :chacha20, repo: "hexpm", optional: false]}, {:equivalex, "~> 1.0", [hex: :equivalex, repo: "hexpm", optional: false]}], "hexpm", "d0a0f8be324e7bfdd61e8e52215024a025816f3a7f665c274ad5bea154480c2b"}, - "porcelain": {:hex, :porcelain, "2.0.3", "2d77b17d1f21fed875b8c5ecba72a01533db2013bd2e5e62c6d286c029150fdc", [:mix], [], "hexpm", "dc996ab8fadbc09912c787c7ab8673065e50ea1a6245177b0c24569013d23620"}, - "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, - "salsa20": {:hex, :salsa20, "1.0.2", "558fddb4169b1f90b1748d42e9221ba8d1354c414a03361308cc7bc0fd2f45c7", [:mix], [], "hexpm", "8d0d394c67da8d44073cbf2c030c4e0e04e7bed92967761be60419d8d46df18d"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "curve25519": {:hex, :curve25519, "1.0.5", "f801179424e4012049fcfcfcda74ac04f65d0ffceeb80e7ef1d3352deb09f5bb", [:mix], [], "hexpm", "0fba3ad55bf1154d4d5fc3ae5fb91b912b77b13f0def6ccb3a5d58168ff4192d"}, + "ed25519": {:hex, :ed25519, "1.4.1", "479fb83c3e31987c9cad780e6aeb8f2015fb5a482618cdf2a825c9aff809afc4", [:mix], [], "hexpm", "0dacb84f3faa3d8148e81019ca35f9d8dcee13232c32c9db5c2fb8ff48c80ec7"}, + "equivalex": {:hex, :equivalex, "1.0.3", "170d9a82ae066e0020dfe1cf7811381669565922eb3359f6c91d7e9a1124ff74", [:mix], [], "hexpm", "46fa311adb855117d36e461b9c0ad2598f72110ad17ad73d7533c78020e045fc"}, + "forecastle": {:hex, :forecastle, "0.1.2", "f8dab08962c7a33010ebd39182513129f17b8814aa16fa453ddd536040882daf", [:mix], [], "hexpm", "8efaeb2e7d0fa24c605605e42562e2dbb0ffd11dc1dd99ef77d78884536ce501"}, + "gun": {:hex, :gun, "2.0.1", "160a9a5394800fcba41bc7e6d421295cf9a7894c2252c0678244948e3336ad73", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "a10bc8d6096b9502205022334f719cc9a08d9adcfbfc0dbee9ef31b56274a20b"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "kcl": {:hex, :kcl, "1.4.2", "8b73a55a14899dc172fcb05a13a754ac171c8165c14f65043382d567922f44ab", [:mix], [{:curve25519, ">= 1.0.4", [hex: :curve25519, repo: "hexpm", optional: false]}, {:ed25519, "~> 1.3", [hex: :ed25519, repo: "hexpm", optional: false]}, {:poly1305, "~> 1.0", [hex: :poly1305, repo: "hexpm", optional: false]}, {:salsa20, "~> 1.0", [hex: :salsa20, repo: "hexpm", optional: false]}], "hexpm", "9f083dd3844d902df6834b258564a82b21a15eb9f6acdc98e8df0c10feeabf05"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "nostrum": {:git, "https://github.com/Kraigie/nostrum.git", "d2daf4941927bc4452a4e79acbef4a574ce32f57", []}, + "plug": {:hex, :plug, "1.15.2", "94cf1fa375526f30ff8770837cb804798e0045fd97185f0bb9e5fcd858c792a3", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02731fa0c2dcb03d8d21a1d941bdbbe99c2946c0db098eee31008e04c6283615"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, + "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, + "poison": {:hex, :poison, "5.0.0", "d2b54589ab4157bbb82ec2050757779bfed724463a544b6e20d79855a9e43b24", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "11dc6117c501b80c62a7594f941d043982a1bd05a1184280c0d9166eb4d8d3fc"}, + "poly1305": {:hex, :poly1305, "1.0.4", "7cdc8961a0a6e00a764835918cdb8ade868044026df8ef5d718708ea6cc06611", [:mix], [{:chacha20, "~> 1.0", [hex: :chacha20, repo: "hexpm", optional: false]}, {:equivalex, "~> 1.0", [hex: :equivalex, repo: "hexpm", optional: false]}], "hexpm", "e14e684661a5195e149b3139db4a1693579d4659d65bba115a307529c47dbc3b"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "salsa20": {:hex, :salsa20, "1.0.4", "404cbea1fa8e68a41bcc834c0a2571ac175580fec01cc38cc70c0fb9ffc87e9b", [:mix], [], "hexpm", "745ddcd8cfa563ddb0fd61e7ce48d5146279a2cf7834e1da8441b369fdc58ac6"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, }