diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore index 4c49bd7..4fc61a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,22 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +sergei-*.tar + .env diff --git a/Dockerfile b/Dockerfile index 7300cd6..7ba4a3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,27 @@ -FROM python:3 +FROM elixir:alpine AS build_stage -# Install dependencies -RUN apt update -RUN apt install -y ffmpeg -RUN pip3 install discord.py python-dotenv pynacl +# Config +ENV MIX_ENV prod +WORKDIR /opt/build -WORKDIR /usr/src/app -COPY . . +# Dependendies +COPY mix.* ./ +COPY config ./config -RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp -RUN chmod a+rx /usr/local/bin/yt-dlp +RUN mix local.hex --force && \ + mix local.rebar --force && \ + mix deps.get --only prod && \ + mix deps.compile -CMD ["python3", "main.py"] +# Build project +COPY lib ./lib +RUN mix release sergei + +FROM elixir:alpine + +WORKDIR /opt/sergei +RUN apk add ffmpeg yt-dlp +COPY --from=build_stage /opt/build/_build/prod/rel/sergei /opt/sergei + +ENTRYPOINT ["/opt/sergei/bin/sergei"] +CMD ["start"] diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..1596309 --- /dev/null +++ b/Justfile @@ -0,0 +1,10 @@ +set dotenv-load + +deploy: + docker build -t godmaire/sergei:latest + docker tag godmaire/sergei:latest registry.digitalocean.com/godmaire/sergei:latest + docker push registry.digitalocean.com/godmaire/sergei:latest + +run: + mix deps.get + DISCORD_TOK=$DISCORD_TOK mix run --no-halt diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..641754a --- /dev/null +++ b/config/config.exs @@ -0,0 +1,4 @@ +import Config + +config :nostrum, + youtubedl: "/usr/bin/yt-dlp" diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..7fc2cc0 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,4 @@ +import Config + +config :nostrum, + token: System.get_env("DISCORD_TOK") diff --git a/lib/sergei.ex b/lib/sergei.ex new file mode 100644 index 0000000..28d222c --- /dev/null +++ b/lib/sergei.ex @@ -0,0 +1,5 @@ +defmodule Sergei do + @moduledoc """ + Sergei is poggers. + """ +end diff --git a/lib/sergei/application.ex b/lib/sergei/application.ex new file mode 100644 index 0000000..c021f02 --- /dev/null +++ b/lib/sergei/application.ex @@ -0,0 +1,24 @@ +defmodule Sergei.Application do + @moduledoc false + use Application + + @impl true + def start(_type, _args) do + children = [ + # Start the Consumer + Sergei.Consumer, + Sergei.Player, + Sergei.VoiceStateCache, + {Plug.Cowboy, scheme: :http, plug: Server, options: [port: 8080]} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [ + strategy: :one_for_one, + name: Sergei.Supervisor + ] + + Supervisor.start_link(children, opts) + end +end diff --git a/lib/sergei/consumer.ex b/lib/sergei/consumer.ex new file mode 100644 index 0000000..b8e6495 --- /dev/null +++ b/lib/sergei/consumer.ex @@ -0,0 +1,102 @@ +defmodule Sergei.Consumer do + use Nostrum.Consumer + + alias Nostrum.Api + + # Translate params to list of maps + opt = fn type, name, desc, opts -> + %{type: type, name: name, description: desc} + |> Map.merge(Enum.into(opts, %{})) + end + + @play_opts [ + opt.(3, "url", "URL of the audio to play", required: true) + ] + + @slash_commands [ + {"ping", "Pong", []}, + {"play", "Play some tunes", @play_opts} + ] + + def start_link do + Consumer.start_link(__MODULE__) + end + + # Bulk overwrite commands per guild. While this is efficient enough for now, moving + # to global commands in the future is probably a good idea. + @spec register_commands!(integer) :: :ok + def register_commands!(guild_id) do + commands = + Enum.map(@slash_commands, fn {name, description, options} -> + %{ + name: name, + description: description, + options: options + } + end) + + case Api.bulk_overwrite_guild_application_commands(guild_id, commands) do + {:ok, _res} -> :ok + {:error, err} -> raise err + end + end + + # Initialization of the Discord Client + def handle_event({:READY, %{guilds: guilds} = _event, _ws_state}) do + Api.update_status(:online, "some tunes", 0) + + guilds + |> Enum.map(fn guild -> guild.id end) + |> Enum.each(®ister_commands!/1) + end + + def handle_event({:VOICE_STATE_UPDATE, state, _ws_state}) do + Sergei.VoiceStateCache.update_state(state) + end + + # Handle interactions + def handle_event({:INTERACTION_CREATE, interaction, _ws_state}) do + response = + case do_command(interaction) do + {:ok, msg} -> + %{type: 4, data: %{content: msg, flags: 2 ** 6}} + + {:error, msg} -> + %{type: 4, data: %{content: msg, flags: 2 ** 6}} + end + + Api.create_interaction_response!(interaction, response) + end + + # Ignore other events + def handle_event(_event) do + :noop + end + + # /ping + def do_command(%{data: %{name: "ping"}}) do + {:ok, "Pong"} + end + + # /play + def do_command( + %{ + guild_id: guild_id, + member: %{user: %{id: invoker_id}}, + data: %{name: "play", options: opts} + } = _interaction + ) do + [%{name: "url", value: url}] = opts + + case Sergei.VoiceStateCache.get_state(invoker_id) do + %{guild_id: id} = _res when guild_id != id -> + {:error, "You're not connected to a voice channel in this server."} + + %{channel_id: channel_id} = _res -> + Sergei.Player.play(guild_id, channel_id, url) + + nil -> + {:error, "You are not in a voice channel."} + end + end +end diff --git a/lib/sergei/player.ex b/lib/sergei/player.ex new file mode 100644 index 0000000..218d481 --- /dev/null +++ b/lib/sergei/player.ex @@ -0,0 +1,87 @@ +defmodule Sergei.Player do + use GenServer + + require Logger + alias Nostrum.Voice + + def start_link(_) do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + + @impl true + def init(state) do + Process.send_after(self(), :tick, 100) + + {:ok, state} + end + + # Client + def play(guild_id, channel_id, url) do + GenServer.call(__MODULE__, {:play, guild_id, channel_id, url}) + end + + # Server + @impl true + def handle_info(:tick, state) do + state + |> Enum.each(fn {guild_id, %{url: url, paused: paused}} = _state -> + if not Voice.playing?(guild_id) and not paused do + Voice.play(guild_id, url, :ytdl) + end + end) + + Process.send_after(self(), :tick, 100) + {:noreply, state} + end + + @impl true + def handle_call({:play, guild_id, channel_id, url}, _from, state) do + res = play_music(guild_id, channel_id, url) + + state = + Map.put(state, guild_id, %{ + url: url, + paused: false + }) + + {:reply, res, state} + end + + def play_music(guild_id, channel_id, url) do + cond do + Voice.get_channel_id(guild_id) != channel_id -> + Logger.debug("Changing channels...") + + Voice.leave_channel(guild_id) + Voice.join_channel(guild_id, channel_id) + + # Wait for the client to finish joining the voice channel + # TODO: check the channel ID in a loop instead + Process.sleep(500) + + play_music(guild_id, channel_id, url) + + Voice.playing?(guild_id) -> + Logger.debug("Stopping playback...") + + Voice.stop(guild_id) + play_music(guild_id, channel_id, url) + + Voice.ready?(guild_id) -> + case Voice.play(guild_id, url, :ytdl) do + :ok -> + Logger.info("Playing #{url}") + {:ok, "Playing music..."} + + {:error, res} -> + Logger.error("Could not play music: #{res}") + {:error, "Fuck..."} + end + + true -> + Logger.debug("Waiting...") + Process.sleep(500) + play_music(guild_id, channel_id, url) + end + end +end diff --git a/lib/sergei/voice_cache.ex b/lib/sergei/voice_cache.ex new file mode 100644 index 0000000..14b57fa --- /dev/null +++ b/lib/sergei/voice_cache.ex @@ -0,0 +1,48 @@ +defmodule Sergei.VoiceStateCache do + use GenServer + + def start_link(_) do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + + @impl true + def init(cache) do + {:ok, cache} + end + + # Client + @spec update_state(Nostrum.Struct.Event.VoiceState.t()) :: :ok + def update_state(state) do + GenServer.cast(__MODULE__, {:update, state}) + end + + @spec get_state(non_neg_integer()) :: %{guild_id: integer(), channel_id: integer()} | nil + def get_state(user_id) do + GenServer.call(__MODULE__, {:get, user_id}) + end + + # Server + @impl true + def handle_cast({:update, state}, cache) do + %{ + guild_id: guild_id, + channel_id: channel_id, + member: %{ + user: %{ + id: user_id + } + } + } = state + + entry = + Map.new() + |> Map.put(user_id, %{guild_id: guild_id, channel_id: channel_id}) + + {:noreply, Map.merge(cache, entry)} + end + + @impl true + def handle_call({:get, user_id}, _from, cache) do + {:reply, Map.get(cache, user_id), cache} + end +end diff --git a/lib/server.ex b/lib/server.ex new file mode 100644 index 0000000..68bd070 --- /dev/null +++ b/lib/server.ex @@ -0,0 +1,20 @@ +defmodule Server do + use Plug.Router + + plug(:match) + plug(:dispatch) + + get "/" do + conn + |> send_resp(200, "Ok") + end + + match _ do + conn + |> send_resp(404, "Page not found") + end + + def start_link(_) do + Plug.Adapters.Cowboy.http(Server, []) + end +end diff --git a/main.py b/main.py deleted file mode 100644 index 3fe51d2..0000000 --- a/main.py +++ /dev/null @@ -1,75 +0,0 @@ -import os -import subprocess - -from dotenv import load_dotenv -from functools import partial - -import discord - -class Client(discord.Client): - def __init__(self): - intents = discord.Intents().default() - intents.members = True - super().__init__(intents=intents) - - - async def on_ready(self): - print("Logged on as {}".format(self.user)) - - - async def on_message(self, message: discord.Message): - if message.author == self.user: - return - - if message.channel.type is not discord.ChannelType.private: - return - - url = message.content - v_id = url.split("=")[1] - user = message.channel.recipient.id - - # Download file if it doesn't exist already - if not os.path.exists(v_id): - subprocess.run(["yt-dlp", "-f", "ba/b", "-o", v_id, url]) - - for chan in self.get_all_channels(): - # Check if Channel is Voice Channel - if type(chan) is not discord.VoiceChannel: - continue - - # Join the voice channel the user who DM'd Sergei is in - if user not in [i.id for i in chan.members]: - continue - - # Try to create a new voice client; grab existing one if available - try: - v_client: discord.VoiceClient = await chan.connect() - except discord.errors.ClientException: - for client in self.voice_clients: - if client.channel.id != chan.id: - continue - - v_client = client - await v_client.move_to(chan) - v_client.stop() - - self.play(v_client, v_id) - - - @staticmethod - def play(vc: discord.VoiceClient, v_id: str, err=None): - if err is not None: - print(err) - return - - if vc.is_playing(): - return - - audio = discord.FFmpegOpusAudio(v_id) - vc.play(audio, after=partial(Client.play, vc, v_id)) - - -if __name__ == "__main__": - load_dotenv() - Client().run(os.getenv("DISCORD_TOK")) - diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..439a1f0 --- /dev/null +++ b/mix.exs @@ -0,0 +1,37 @@ +defmodule Sergei.MixProject do + use Mix.Project + + def project do + [ + app: :sergei, + version: "0.1.0", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps(), + releases: [ + sergei: [ + include_executables_for: [:unix] + ] + ] + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + mod: {Sergei.Application, []}, + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + {:plug_cowboy, "~> 2.0"}, + {:cowlib, "~> 2.11", hex: :remedy_cowlib, override: true}, + {:nostrum, "~> 0.6"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..aabcc1e --- /dev/null +++ b/mix.lock @@ -0,0 +1,23 @@ +%{ + "certifi": {:hex, :certifi, "2.10.0", "a4ab316320bfca83bd0b57fd022d091555d42a30eefabb38063887158294773a", [:rebar3], [], "hexpm", "e87a9dd6e7fe9c5804887850d4cdbcd83db4da7a27f928174f11e4e06fb7902e"}, + "chacha20": {:hex, :chacha20, "1.0.4", "0359d8f9a32269271044c1b471d5cf69660c362a7c61a98f73a05ef0b5d9eb9e", [:mix], [], "hexpm", "2027f5d321ae9903f1f0da7f51b0635ad6b8819bc7fe397837930a2011bc2349"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "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, :remedy_cowlib, "2.11.1", "7abb4d0779a7d1c655f7642dc0bd0af754951e95005dfa01b500c68fe35a5961", [:rebar3], [], "hexpm", "0b613dc308e080cb6134285f1b1b55c3873e101652e70c70010fc6651c91b130"}, + "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"}, + "gen_stage": {:hex, :gen_stage, "1.2.0", "ee49244b57803f54bdab08a60a927e1b4e5bb5d635c52eca0f376a0673af3f8c", [:mix], [], "hexpm", "c3e40992c72e74d9c4eda16d7515bf32c9e7b634e827ab11091fff3290f7b503"}, + "gun": {:hex, :remedy_gun, "2.0.1", "0f0caed812ed9e4da4f144df2d5bf73b0a99481d395ecde990a3791decf321c6", [:rebar3], [{:cowlib, "~> 2.11.1", [hex: :remedy_cowlib, repo: "hexpm", optional: false]}], "hexpm", "b6685a85fbd12b757f86809be1b3d88fcef365b77605cd5aa34db003294c446e"}, + "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "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.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, + "nostrum": {:hex, :nostrum, "0.6.1", "aaad13e8e8ce8ace1f218685c6824972e7ea4f979e5ba3242a98f22bda52e605", [:mix], [{:certifi, "~> 2.8", [hex: :certifi, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.11 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:gun, "== 2.0.1", [hex: :remedy_gun, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:kcl, "~> 1.4", [hex: :kcl, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm", "27925a47e413766664928fbeb38c9887a7fd23066cdba831d5fd8241072bf76a"}, + "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.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.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [: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", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, + "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"}, +}