diff --git a/.env.example b/.env.example index f27a541..51168fc 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,7 @@ REACT_APP_SSH_PORT=42069 REDIS_HOST=localhost REDIS_PORT=6379 + +NEW_GAME_PINGABLE_ROLE_ID=1123232 +NEW_GAME_CHANNEL_WEBHOOK=https://discordapp.com/api/webhooks/ +REMIND_MOVE_CHANNEL_WEBHOOK=https://discordapp.com/api/webhooks/ \ No newline at end of file diff --git a/config/config.exs b/config/config.exs index c19937a..e732049 100644 --- a/config/config.exs +++ b/config/config.exs @@ -14,13 +14,20 @@ config :chessh, RateLimits, player_session_message_burst_rate: 8, player_public_keys: 15, create_game_ms: 60 * 1000, - create_game_rate: 3 + create_game_rate: 3, + discord_notification_rate: 3, + discord_notification_rate_ms: 1000 config :chessh, Web, discord_oauth_login_url: "https://discord.com/api/oauth2/token", discord_user_api_url: "https://discord.com/api/users/@me", discord_scope: "identify" +config :chessh, DiscordNotifications, + game_move_notif_delay_ms: 3 * 60 * 1000, + game_created_notif_delay_ms: 30 * 1000, + reschedule_delay: 5 * 1000 + config :joken, default_signer: "secret" import_config "#{config_env()}.exs" diff --git a/config/runtime.exs b/config/runtime.exs index 5e03614..684e48e 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -3,6 +3,11 @@ import Config config :chessh, ssh_port: String.to_integer(System.get_env("SSH_PORT", "34355")) +config :chessh, DiscordNotifications, + looking_for_games_role_mention: "<@&#{System.get_env("NEW_GAME_PINGABLE_ROLE_ID")}>", + discord_game_move_notif_webhook: System.get_env("REMIND_MOVE_CHANNEL_WEBHOOK"), + discord_new_game_notif_webhook: System.get_env("NEW_GAME_CHANNEL_WEBHOOK") + config :chessh, Web, discord_client_id: System.get_env("DISCORD_CLIENT_ID"), discord_client_secret: System.get_env("DISCORD_CLIENT_SECRET"), diff --git a/lib/chessh/application.ex b/lib/chessh/application.ex index 59926cc..f92707f 100644 --- a/lib/chessh/application.ex +++ b/lib/chessh/application.ex @@ -18,6 +18,7 @@ defmodule Chessh.Application do children = [ Chessh.Repo, Chessh.SSH.Daemon, + Chessh.DiscordNotifier, Plug.Cowboy.child_spec( scheme: :http, plug: Chessh.Web.Endpoint, diff --git a/lib/chessh/discord/notifier.ex b/lib/chessh/discord/notifier.ex new file mode 100644 index 0000000..09c4ec0 --- /dev/null +++ b/lib/chessh/discord/notifier.ex @@ -0,0 +1,129 @@ +defmodule Chessh.DiscordNotifier do + use GenServer + + @name :discord_notifier + + alias Chessh.{Game, Player, Repo} + + def start_link(state \\ []) do + GenServer.start_link(__MODULE__, state, name: @name) + end + + @impl true + def init(state) do + {:ok, state} + end + + @impl true + def handle_cast(x, state), do: handle_info(x, state) + + @impl true + def handle_info({:attempt_notification, notification} = body, state) do + [discord_notification_rate, discord_notification_rate_ms] = + Application.get_env(:chessh, RateLimits) + |> Keyword.take([:discord_notification_rate, :discord_notification_rate_ms]) + |> Keyword.values() + + reschedule_delay = Application.get_env(:chessh, RateLimits)[:reschedule_delay] + + case Hammer.check_rate_inc( + :redis, + "discord-webhook-message-rate", + discord_notification_rate_ms, + discord_notification_rate, + 1 + ) do + {:allow, _count} -> + send_notification(notification) + + {:deny, _limit} -> + Process.send_after(self(), body, reschedule_delay) + end + + {:noreply, state} + end + + @impl true + def handle_info({:schedule_notification, notification, delay}, state) do + Process.send_after(self(), {:attempt_notification, notification}, delay) + {:noreply, state} + end + + defp send_notification({:move_reminder, game_id}) do + [min_delta_t, discord_game_move_notif_webhook] = + Application.get_env(:chessh, DiscordNotifications) + |> Keyword.take([:game_move_notif_delay_ms, :discord_game_move_notif_webhook]) + |> Keyword.values() + + case Repo.get(Game, game_id) do + nil -> + nil + + game -> + %Game{ + dark_player: %Player{discord_id: dark_player_discord_id}, + light_player: %Player{discord_id: light_player_discord_id}, + turn: turn, + updated_at: last_updated, + moves: move_count + } = Repo.preload(game, [:dark_player, :light_player]) + + delta_t = NaiveDateTime.diff(NaiveDateTime.utc_now(), last_updated, :millisecond) + + if delta_t >= min_delta_t do + post_discord( + discord_game_move_notif_webhook, + "<@#{if turn == :light, do: light_player_discord_id, else: dark_player_discord_id}> it is your move in Game #{game_id} (move #{move_count})." + ) + end + end + end + + defp send_notification({:game_created, game_id}) do + [pingable_mention, discord_game_created_notif_webhook] = + Application.get_env(:chessh, DiscordNotifications) + |> Keyword.take([:looking_for_games_role_mention, :discord_new_game_notif_webhook]) + |> Keyword.values() + + case Repo.get(Game, game_id) do + nil -> + nil + + game -> + %Game{ + dark_player: dark_player, + light_player: light_player + } = Repo.preload(game, [:dark_player, :light_player]) + + message = + case {is_nil(light_player), is_nil(dark_player)} do + {true, false} -> + "#{pingable_mention}, <@#{dark_player.discord_id}> is looking for an opponent to play as light in Game #{game_id}" + + {false, true} -> + "#{pingable_mention}, <@#{light_player.discord_id}> is looking for an opponent to play as dark in Game #{game_id}" + + _ -> + false + end + + if message do + post_discord(discord_game_created_notif_webhook, message) + end + end + end + + defp post_discord(webhook, message) do + :httpc.request( + :post, + { + String.to_charlist(webhook), + [], + 'application/json', + %{content: message} |> Jason.encode!() |> String.to_charlist() + }, + [], + [] + ) + end +end diff --git a/lib/chessh/ssh/client/game/game.ex b/lib/chessh/ssh/client/game/game.ex index 4a79d05..fc48d6f 100644 --- a/lib/chessh/ssh/client/game/game.ex +++ b/lib/chessh/ssh/client/game/game.ex @@ -77,7 +77,7 @@ defmodule Chessh.SSH.Client.Game do ) do {:allow, _count} -> # Starting a new game - {:ok, %Game{} = game} = + {:ok, %Game{id: game_id} = game} = Game.changeset( %Game{}, Map.merge( @@ -92,6 +92,12 @@ defmodule Chessh.SSH.Client.Game do ) |> Repo.insert() + GenServer.cast( + :discord_notifier, + {:schedule_notification, {:game_created, game_id}, + Application.get_env(:chessh, DiscordNotifications)[:game_created_notif_delay_ms]} + ) + init([ %State{ state @@ -403,6 +409,12 @@ defmodule Chessh.SSH.Client.Game do :syn.publish(:games, {:game, game_id}, {:new_move, attempted_move}) + GenServer.cast( + :discord_notifier, + {:schedule_notification, {:move_reminder, game_id}, + Application.get_env(:chessh, DiscordNotifications)[:game_move_notif_delay_ms]} + ) + _ -> nil end diff --git a/priv/repo/migrations/20221219082326_create_player.exs b/priv/repo/migrations/20221219082326_create_player.exs index 0e605c9..2b7d2b6 100644 --- a/priv/repo/migrations/20221219082326_create_player.exs +++ b/priv/repo/migrations/20221219082326_create_player.exs @@ -2,11 +2,9 @@ defmodule Chessh.Repo.Migrations.CreatePlayer do use Ecto.Migration def change do - execute("CREATE EXTENSION IF NOT EXISTS citext", "") - create table(:players) do add(:discord_id, :string, null: false) - add(:username, :citext, null: false) + add(:username, :string, null: false) add(:hashed_password, :string, null: true) timestamps() end diff --git a/test/auth/password_test.exs b/test/auth/password_test.exs index 4072293..86c3758 100644 --- a/test/auth/password_test.exs +++ b/test/auth/password_test.exs @@ -2,7 +2,7 @@ defmodule Chessh.Auth.PasswordAuthenticatorTest do use ExUnit.Case alias Chessh.{Player, Repo} - @valid_user %{username: "logan", password: "password", discord_id: "1"} + @valid_user %{username: "lizzy#0003", password: "password", discord_id: "1"} setup_all do Ecto.Adapters.SQL.Sandbox.checkout(Repo) @@ -26,7 +26,7 @@ defmodule Chessh.Auth.PasswordAuthenticatorTest do end test "Password can authenticate a user instance" do - player = Repo.get_by(Player, username: "logan") + player = Repo.get_by(Player, username: "lizzy#0003") assert Chessh.Auth.PasswordAuthenticator.authenticate( player, diff --git a/test/auth/pubkey_test.exs b/test/auth/pubkey_test.exs index 690dfdf..97a1695 100644 --- a/test/auth/pubkey_test.exs +++ b/test/auth/pubkey_test.exs @@ -2,7 +2,7 @@ defmodule Chessh.Auth.PublicKeyAuthenticatorTest do use ExUnit.Case alias Chessh.{Key, Repo, Player} - @valid_user %{username: "logan", password: "password", discord_id: "2"} + @valid_user %{username: "lizzy#0003", password: "password", discord_id: "2"} @valid_key %{ name: "The Gamer Machine", key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ/2LOJGGEd/dhFgRxJ5MMv0jJw4s4pA8qmMbZyulN44" diff --git a/test/schema/key_test.exs b/test/schema/key_test.exs index 6dbb574..4b62d7f 100644 --- a/test/schema/key_test.exs +++ b/test/schema/key_test.exs @@ -4,17 +4,17 @@ defmodule Chessh.Schema.KeyTest do alias Chessh.Key @valid_attrs %{ - name: "Logan's Key", + name: "Lizzy's Key", key: {{{:ECPoint, <<159, 246, 44, 226, 70, 24, 71, 127, 118, 17, 96, 71, 18, 121, 48, 203, 244, 140, 156, 56, 179, 138, 64, 242, 169, 140, 109, 156, 174, 148, 222, 56>>}, - {:namedCurve, {1, 3, 101, 112}}}, [comment: 'logan@yagami']} + {:namedCurve, {1, 3, 101, 112}}}, [comment: 'lizzy@yagami']} } @valid_key_attrs %{ name: "asdf key", key: - "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBC7Mpf2QIL32MmKxcrXAoZM3l7/hBy+8d+WqTRMun+tC/XYNiXSIDuZv01an3D1d22fmSpZiprFQzjB4yEz23qw= logan@yagami" + "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBC7Mpf2QIL32MmKxcrXAoZM3l7/hBy+8d+WqTRMun+tC/XYNiXSIDuZv01an3D1d22fmSpZiprFQzjB4yEz23qw= lizzy@yagami" } @invalid_key_attrs %{ name: "An Invalid Key", diff --git a/test/schema/register_test.exs b/test/schema/register_test.exs index 6d32769..40dc210 100644 --- a/test/schema/register_test.exs +++ b/test/schema/register_test.exs @@ -3,10 +3,10 @@ defmodule Chessh.Auth.UserRegistrationTest do use ExUnit.Case alias Chessh.{Player, Repo} - @valid_user %{username: "logan", password: "password", discord_id: "4"} + @valid_user %{username: "lizzy#0003", password: "password", discord_id: "4"} @invalid_username %{username: "a", password: "password", discord_id: "7"} - @invalid_password %{username: "aasdf", password: "pass", discord_id: "6"} - @repeated_username %{username: "LoGan", password: "password", discord_id: "5"} + @invalid_password %{username: "bruh#0003", password: "pass", discord_id: "6"} + @repeated_username %{username: "lizzy#0003", password: "password", discord_id: "6"} test "Password must be at least 8 characters and username must be at least 2" do refute Player.registration_changeset(%Player{}, @invalid_password).valid? @@ -40,7 +40,7 @@ defmodule Chessh.Auth.UserRegistrationTest do refute changeset.changes.hashed_password == @valid_user.password end - test "Username is uniquely case insensitive" do + test "Username is uniquely case sensitive" do assert Repo.insert(Player.registration_changeset(%Player{}, @valid_user)) assert {:error, diff --git a/test/ssh/ssh_auth_test.exs b/test/ssh/ssh_auth_test.exs index 024a54f..ab6f827 100644 --- a/test/ssh/ssh_auth_test.exs +++ b/test/ssh/ssh_auth_test.exs @@ -5,7 +5,7 @@ defmodule Chessh.SSH.AuthTest do @localhost '127.0.0.1' @localhost_inet {{127, 0, 0, 1}, 1} @key_name "The Gamer Machine" - @valid_user %{username: "logan", password: "password", discord_id: "3"} + @valid_user %{username: "lizzy#0003", password: "password", discord_id: "3"} @client_test_keys_dir Path.join(Application.compile_env!(:chessh, :key_dir), "client_keys") @client_pub_key 'id_ed25519.pub'