Rewrite Sergei in Elixir

This commit is contained in:
Roman 2023-06-06 17:01:17 -04:00
parent 62958752a6
commit b8e7c0f87b
15 changed files with 412 additions and 85 deletions

4
.formatter.exs Normal file
View file

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

21
.gitignore vendored
View file

@ -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

View file

@ -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"]

10
Justfile Normal file
View file

@ -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

4
config/config.exs Normal file
View file

@ -0,0 +1,4 @@
import Config
config :nostrum,
youtubedl: "/usr/bin/yt-dlp"

4
config/runtime.exs Normal file
View file

@ -0,0 +1,4 @@
import Config
config :nostrum,
token: System.get_env("DISCORD_TOK")

5
lib/sergei.ex Normal file
View file

@ -0,0 +1,5 @@
defmodule Sergei do
@moduledoc """
Sergei is poggers.
"""
end

24
lib/sergei/application.ex Normal file
View file

@ -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

102
lib/sergei/consumer.ex Normal file
View file

@ -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(&register_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 <url>
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

87
lib/sergei/player.ex Normal file
View file

@ -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

48
lib/sergei/voice_cache.ex Normal file
View file

@ -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

20
lib/server.ex Normal file
View file

@ -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

75
main.py
View file

@ -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"))

37
mix.exs Normal file
View file

@ -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

23
mix.lock Normal file
View file

@ -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"},
}