diff --git a/app/services/commands/alias/add.rb b/app/services/commands/alias/add.rb
new file mode 100644
index 00000000..a5145e48
--- /dev/null
+++ b/app/services/commands/alias/add.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Commands
+ module Alias
+ class Add < Base
+ argument shortcut: 0
+ argument command: (1..)
+
+ # Determine if the argument matches this command.
+ #
+ # @return [Boolean]
+ def self.match?(arguments)
+ arguments.first == I18n.t("commands.lookup.alias.arguments.add")
+ end
+
+ private
+
+ # Returns the command, ensuring a forward slash is present.
+ #
+ # @return [String]
+ def command
+ value = parameters[:command]
+
+ unless value.start_with?("/")
+ value = "/#{value}"
+ end
+
+ value
+ end
+
+ # Return the handler for a successful command execution.
+ #
+ # @return [Success]
+ def success
+ Success.new(character: character, command: command, shortcut: parameters[:shortcut])
+ end
+
+ # Validate if the command is valid.
+ #
+ # @return [Boolean] If the alias exists or not.
+ def validate_command
+ parsed = Command::Parser.call(command)
+
+ if parsed == Commands::Unknown
+ InvalidCommand.new(command: command)
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/commands/alias/add/invalid_command.rb b/app/services/commands/alias/add/invalid_command.rb
new file mode 100644
index 00000000..687625c2
--- /dev/null
+++ b/app/services/commands/alias/add/invalid_command.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Commands
+ module Alias
+ class Add < Base
+ class InvalidCommand < Result
+ locals :command
+
+ # Initialize an alias add invalid command result.
+ #
+ # @return [void]
+ def initialize(command:)
+ @command = command
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/commands/alias/add/success.rb b/app/services/commands/alias/add/success.rb
new file mode 100644
index 00000000..64813c38
--- /dev/null
+++ b/app/services/commands/alias/add/success.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Commands
+ module Alias
+ class Add < Base
+ class Success < Result
+ locals :account, :command, :shortcut
+
+ # Initialize an alias add success result.
+ #
+ # @return [void]
+ def initialize(character:, command:, shortcut:)
+ @character = character
+ @command = command
+ @shortcut = shortcut
+ end
+
+ # Add the alias to the character's account.
+ #
+ # @return [void]
+ def call
+ account.update!(aliases: account.aliases.merge(shortcut => command))
+ end
+
+ private
+
+ attr_reader :character, :command, :shortcut
+
+ delegate :account, to: :character
+ end
+ end
+ end
+end
diff --git a/app/views/commands/alias/add/_invalid_command.html.erb b/app/views/commands/alias/add/_invalid_command.html.erb
new file mode 100644
index 00000000..a07109d7
--- /dev/null
+++ b/app/views/commands/alias/add/_invalid_command.html.erb
@@ -0,0 +1,7 @@
+<%# locals: (command:) %>
+
+ |
+
+ <%= t(".message", command: command) %>
+ |
+
diff --git a/app/views/commands/alias/add/_invalid_command.turbo_stream.erb b/app/views/commands/alias/add/_invalid_command.turbo_stream.erb
new file mode 100644
index 00000000..6c717a0c
--- /dev/null
+++ b/app/views/commands/alias/add/_invalid_command.turbo_stream.erb
@@ -0,0 +1,2 @@
+<%# locals: (command:) %>
+<%= turbo_stream.append("messages", partial: "commands/alias/add/invalid_command", locals: { command: command }) %>
diff --git a/app/views/commands/alias/add/_success.html.erb b/app/views/commands/alias/add/_success.html.erb
new file mode 100644
index 00000000..1ec6427b
--- /dev/null
+++ b/app/views/commands/alias/add/_success.html.erb
@@ -0,0 +1,9 @@
+<%# locals: (account:, command:, shortcut:) %>
+
+ |
+
+ <%= t(".message", command: command, shortcut: shortcut) %> |
+
+
+
+
diff --git a/app/views/commands/alias/add/_success.turbo_stream.erb b/app/views/commands/alias/add/_success.turbo_stream.erb
new file mode 100644
index 00000000..b930d7e4
--- /dev/null
+++ b/app/views/commands/alias/add/_success.turbo_stream.erb
@@ -0,0 +1,2 @@
+<%# locals: (account:, command:, shortcut:) %>
+<%= turbo_stream.append("messages", partial: "commands/alias/add/success", locals: { account: account, command: command, shortcut: shortcut }) %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 5f17b3ab..b7f40543 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -75,6 +75,11 @@ en:
commands:
alias:
+ add:
+ invalid_command:
+ message: "There is no \"%{command}\" command."
+ success:
+ message: "The alias \"%{shortcut}\" has been added for the \"%{command}\" command."
list:
success:
header:
@@ -147,6 +152,7 @@ en:
alias:
name: "alias"
arguments:
+ add: "add"
list: "list"
remove: "remove"
show: "show"
diff --git a/spec/features/commands/alias/add_spec.rb b/spec/features/commands/alias/add_spec.rb
new file mode 100644
index 00000000..b5e2016a
--- /dev/null
+++ b/spec/features/commands/alias/add_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe "Sending the alias add command", :js do
+ let(:character) { create(:character) }
+ let(:command) { "/alias add #{shortcut} #{name}" }
+ let(:name) { "/emote" }
+ let(:shortcut) { "/e" }
+
+ before do
+ sign_in_as_character character
+ end
+
+ it "displays the alias add command message to the sender" do
+ send_text(command)
+
+ expect(page).to have_alias_add_command_message(command: name, shortcut: shortcut)
+ end
+
+ it "does not broadcast the message to the room" do
+ using_session(:nearby_character) do
+ sign_in_as_character create(:character, room: character.room)
+ end
+
+ send_text(command)
+
+ wait_for(have_alias_add_command_message(command: name, shortcut: shortcut)) do
+ using_session(:nearby_character) do
+ expect(page).not_to have_alias_add_command_message(command: name, shortcut: shortcut)
+ end
+ end
+ end
+
+ context "with an invalid command" do
+ let(:command) { "/alias add /f #{name}" }
+ let(:name) { "/fake" }
+
+ it "displays invalid command message to the sender" do
+ send_text(command)
+
+ expect(page).to have_invalid_command_message(command: name)
+ end
+
+ it "does not broadcast the message to the room" do
+ using_session(:nearby_character) do
+ sign_in_as_character create(:character, room: character.room)
+ end
+
+ send_text(command)
+
+ wait_for(have_invalid_command_message(command: name)) do
+ using_session(:nearby_character) do
+ expect(page).not_to have_invalid_command_message(command: name)
+ end
+ end
+ end
+ end
+
+ protected
+
+ def have_alias_add_command_message(command:, shortcut:)
+ have_css(
+ "#messages .message-alias-add",
+ text: t("commands.alias.add.success.message", command: command, shortcut: shortcut)
+ )
+ end
+
+ def have_invalid_command_message(command:)
+ have_css(
+ "#messages .message-alias-add-invalid-command",
+ text: t("commands.alias.add.invalid_command.message", command: command)
+ )
+ end
+end
diff --git a/spec/services/commands/alias/add/invalid_command_spec.rb b/spec/services/commands/alias/add/invalid_command_spec.rb
new file mode 100644
index 00000000..3d50fa5d
--- /dev/null
+++ b/spec/services/commands/alias/add/invalid_command_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe Commands::Alias::Add::InvalidCommand, type: :service do
+ describe "class" do
+ it "inherits from command result" do
+ expect(described_class.superclass).to eq(Commands::Result)
+ end
+ end
+
+ describe "#locals" do
+ subject { instance.locals }
+
+ let(:command) { double }
+ let(:instance) { described_class.new(command: command) }
+
+ it { is_expected.to eq(command: command) }
+ end
+end
diff --git a/spec/services/commands/alias/add/success_spec.rb b/spec/services/commands/alias/add/success_spec.rb
new file mode 100644
index 00000000..9a38bd5e
--- /dev/null
+++ b/spec/services/commands/alias/add/success_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe Commands::Alias::Add::Success, type: :service do
+ describe "class" do
+ it "inherits from command result" do
+ expect(described_class.superclass).to eq(Commands::Result)
+ end
+ end
+
+ describe "#call" do
+ subject(:call) { instance.call }
+
+ let(:account) { character.account }
+ let(:character) { create(:character) }
+ let(:command) { "/emote" }
+ let(:instance) { described_class.new(character: character, command: command, shortcut: shortcut) }
+ let(:shortcut) { "/e" }
+
+ it "adds the alias to the character's account" do
+ call
+
+ expect(account.aliases).to have_key(shortcut)
+ end
+ end
+
+ describe "#locals" do
+ subject { instance.locals }
+
+ let(:character) { build_stubbed(:character) }
+ let(:command) { double }
+ let(:instance) { described_class.new(character: character, command: command, shortcut: shortcut) }
+ let(:shortcut) { double }
+
+ it { is_expected.to eq(account: character.account, command: command, shortcut: shortcut) }
+ end
+end
diff --git a/spec/services/commands/alias/add_spec.rb b/spec/services/commands/alias/add_spec.rb
new file mode 100644
index 00000000..1c5a8834
--- /dev/null
+++ b/spec/services/commands/alias/add_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe Commands::Alias::Add, type: :service do
+ describe ".match?" do
+ subject { described_class.match?(arguments) }
+
+ context "with no arguments" do
+ let(:arguments) { [] }
+
+ it { is_expected.to be(false) }
+ end
+
+ context "with add argument" do
+ let(:arguments) { [t("commands.lookup.alias.arguments.add")] }
+
+ it { is_expected.to be(true) }
+ end
+
+ context "with other arguments" do
+ let(:arguments) { %w(fake list) }
+
+ it { is_expected.to be(false) }
+ end
+ end
+
+ describe "#call" do
+ subject(:call) { instance.call }
+
+ let(:character) { build_stubbed(:character) }
+ let(:instance) { described_class.new("/alias add #{shortcut} #{command}", character: character) }
+
+ context "with a valid shortcut and command" do
+ let(:command) { "/emote" }
+ let(:result) { instance_double(described_class::Success) }
+ let(:shortcut) { "/e" }
+
+ before do
+ allow(result).to receive(:call)
+ allow(described_class::Success).to receive(:new)
+ .with(character: character, command: command, shortcut: shortcut)
+ .and_return(result)
+ end
+
+ it "delegates to success handler" do
+ call
+
+ expect(result).to have_received(:call).with(no_args)
+ end
+ end
+
+ context "with a valid shortcut and command, without slashes" do
+ let(:command) { "emote" }
+ let(:result) { instance_double(described_class::Success) }
+ let(:shortcut) { "e" }
+
+ before do
+ allow(result).to receive(:call)
+ allow(described_class::Success).to receive(:new)
+ .with(character: character, command: "/#{command}", shortcut: shortcut)
+ .and_return(result)
+ end
+
+ it "delegates to success handler" do
+ call
+
+ expect(result).to have_received(:call).with(no_args)
+ end
+ end
+
+ context "with an invalid command" do
+ let(:command) { "/fake" }
+ let(:result) { instance_double(described_class::InvalidCommand) }
+ let(:shortcut) { "/f" }
+
+ before do
+ allow(described_class::InvalidCommand).to receive(:new)
+ .with(command: command)
+ .and_return(result)
+ end
+
+ it "delegates to invalid command handler" do
+ allow(result).to receive(:call)
+
+ call
+
+ expect(result).to have_received(:call).with(no_args)
+ end
+ end
+ end
+end
diff --git a/spec/views/commands/alias/add/_invalid_command.html.erb_spec.rb b/spec/views/commands/alias/add/_invalid_command.html.erb_spec.rb
new file mode 100644
index 00000000..c9c83de3
--- /dev/null
+++ b/spec/views/commands/alias/add/_invalid_command.html.erb_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe "commands/alias/add/_invalid_command.html.erb" do
+ subject(:html) do
+ render partial: "commands/alias/add/invalid_command",
+ locals: { command: command }
+
+ rendered
+ end
+
+ let(:command) { "/unknown" }
+
+ it "renders the unknown alias message" do
+ expect(html).to have_message_row(
+ "td:nth-child(2)",
+ text: t("commands.alias.add.invalid_command.message", command: command)
+ )
+ end
+end
diff --git a/spec/views/commands/alias/add/_invalid_command.turbo_stream.erb_spec.rb b/spec/views/commands/alias/add/_invalid_command.turbo_stream.erb_spec.rb
new file mode 100644
index 00000000..5d309cda
--- /dev/null
+++ b/spec/views/commands/alias/add/_invalid_command.turbo_stream.erb_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe "commands/alias/add/_invalid_command.turbo_stream.erb" do
+ subject(:html) do
+ render partial: "commands/alias/add/invalid_command",
+ formats: :turbo_stream,
+ locals: { command: "/fake" }
+
+ rendered
+ end
+
+ before do
+ stub_template("commands/alias/add/_invalid_command.html.erb" => "INVALID_COMMAND_TEMPLATE")
+ end
+
+ it "appends to the messages element" do
+ expect(html).to have_turbo_stream_element(
+ action: "append",
+ target: "messages"
+ )
+ end
+
+ it "renders the HTML template" do
+ expect(html).to include("INVALID_COMMAND_TEMPLATE")
+ end
+end
diff --git a/spec/views/commands/alias/add/_success.html.erb_spec.rb b/spec/views/commands/alias/add/_success.html.erb_spec.rb
new file mode 100644
index 00000000..ad0e96e3
--- /dev/null
+++ b/spec/views/commands/alias/add/_success.html.erb_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe "commands/alias/add/success.html.erb" do
+ subject(:html) do
+ render partial: "commands/alias/add/success", locals: {
+ account: account,
+ command: command,
+ shortcut: shortcut
+ }
+
+ rendered
+ end
+
+ let(:account) { build_stubbed(:account) }
+ let(:command) { "/emote" }
+ let(:shortcut) { "/e" }
+
+ it "renders the message" do
+ expect(html).to have_css(
+ "td:nth-child(2)",
+ text: t("commands.alias.add.success.message", command: command, shortcut: shortcut)
+ )
+ end
+
+ it "overwrites the local alias cache" do
+ expect(html).to include(
+ ""
+ )
+ end
+end
diff --git a/spec/views/commands/alias/add/_success.turbo_stream.erb_spec.rb b/spec/views/commands/alias/add/_success.turbo_stream.erb_spec.rb
new file mode 100644
index 00000000..7cdbd13b
--- /dev/null
+++ b/spec/views/commands/alias/add/_success.turbo_stream.erb_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe "commands/alias/add/_success.turbo_stream.erb" do
+ subject(:html) do
+ render partial: "commands/alias/add/success",
+ formats: :turbo_stream,
+ locals: { account: account, command: "/emote", shortcut: "/e" }
+
+ rendered
+ end
+
+ let(:account) { build_stubbed(:account) }
+
+ before do
+ stub_template("commands/alias/add/_success.html.erb" => "SUCCESS_TEMPLATE")
+ end
+
+ it "appends to the messages element" do
+ expect(html).to have_turbo_stream_element(
+ action: "append",
+ target: "messages"
+ )
+ end
+
+ it "renders the HTML template" do
+ expect(html).to include("SUCCESS_TEMPLATE")
+ end
+end