diff --git a/Gemfile b/Gemfile index e2c43009..c5e2aa88 100644 --- a/Gemfile +++ b/Gemfile @@ -24,6 +24,9 @@ gem "turbo-rails" # Authorization gem "action_policy", "~> 0.7.0" +# Authentication +gem "bcrypt", "~> 3.1.20" + # Other gem "bootsnap", require: false gem "puma", ">= 5.0" @@ -41,7 +44,8 @@ group :development, :test do gem "dotenv" gem "erb_lint", require: false gem "factory_bot_rails" - gem "letter_opener" + gem "faker" + gem "letter_opener", "~> 1.10" gem "pry-byebug" gem "rspec-rails" gem "rubocop-capybara", require: false @@ -55,7 +59,6 @@ end group :development do gem "annotate" - gem "faker" gem "rack-mini-profiler" gem "web-console" end @@ -64,6 +67,7 @@ group :test do gem "capybara" gem "cuprite" gem "fuubar" + gem "rails-controller-testing" gem "rspec-instafail", require: false gem "rspec-retry" end diff --git a/Gemfile.lock b/Gemfile.lock index d699a233..b845e5a0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -91,6 +91,7 @@ GEM ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) base64 (0.2.0) + bcrypt (3.1.20) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) @@ -315,6 +316,10 @@ GEM activesupport (= 7.1.3.4) bundler (>= 1.15.0) railties (= 7.1.3.4) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -485,6 +490,7 @@ GEM PLATFORMS arm64-darwin-21 + arm64-darwin-22 arm64-darwin-23 x86_64-linux @@ -492,6 +498,7 @@ DEPENDENCIES action_policy (~> 0.7.0) activerecord-enhancedsqlite3-adapter (~> 0.8.0) annotate + bcrypt (~> 3.1.20) better_errors binding_of_caller bootsnap @@ -507,13 +514,14 @@ DEPENDENCIES faker fuubar importmap-rails - letter_opener + letter_opener (~> 1.10) mission_control-jobs propshaft pry-byebug puma (>= 5.0) rack-mini-profiler rails (~> 7.1.3, >= 7.1.3.4) + rails-controller-testing rspec-instafail rspec-rails rspec-retry diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 09705d12..658bada1 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,5 @@ class ApplicationController < ActionController::Base + include Authentication + + before_action :authenticate_user! end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 00000000..1bb4e35a --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,34 @@ +module Authentication + extend ActiveSupport::Concern + + included do + helper_method :current_user, :user_signed_in? + + def authenticate_user! + redirect_to root_path, alert: t("controllers.concerns.authentication.unauthorized") unless user_signed_in? + end + + def current_user + Current.user ||= authenticate_user_from_session + end + + def authenticate_user_from_session + User.find_by(id: session[:user_id]) + end + + def user_signed_in? + current_user.present? + end + + def login(user) + Current.user = user + reset_session + session[:user_id] = user.id + end + + def logout + Current.user = nil + reset_session + end + end +end diff --git a/app/controllers/main_controller.rb b/app/controllers/main_controller.rb new file mode 100644 index 00000000..f6b17f11 --- /dev/null +++ b/app/controllers/main_controller.rb @@ -0,0 +1,5 @@ +class MainController < ApplicationController + skip_before_action :authenticate_user! + def index + end +end diff --git a/app/controllers/password_resets_controller.rb b/app/controllers/password_resets_controller.rb new file mode 100644 index 00000000..b79929ac --- /dev/null +++ b/app/controllers/password_resets_controller.rb @@ -0,0 +1,43 @@ +class PasswordResetsController < ApplicationController + skip_before_action :authenticate_user!, only: [:new, :create] + before_action :set_user_by_token, only: [:edit, :update] + + def new + end + + def edit + end + + def create + @user = User.find_by(email: params[:email]) + + if @user.present? + PasswordMailer.with( + user: @user, + token: @user.generate_token_for(:password_reset) + ).password_reset.deliver_now + end + + redirect_to root_path, notice: t("controllers.password_resets.create.notice") + end + + def update + if @user.update(password_params) + redirect_to new_session_path, notice: t("controllers.password_resets.update.notice") + else + render :edit, status: :unprocessable_entity + end + end + + private + + def set_user_by_token + @user = User.find_by_token_for(:password_reset, params[:token]) + + redirect_to new_password_reset_path alert: t("controllers.password_resets.errors.invalid_token") if @user.blank? + end + + def password_params + params.require(:user).permit(:password, :password_confirmation) + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 00000000..82969ec9 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,22 @@ +class PasswordsController < ApplicationController + def edit + end + + def update + if current_user.update(password_params) + redirect_to edit_password_path, notice: t("controllers.passwords.update.notice") + else + render :edit, status: :unprocessable_entity + end + end + + private + + def password_params + params.require(:user).permit( + :password, + :password_confirmation, + :password_challenge + ).with_defaults(password_challenge: "") + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 00000000..199ba626 --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,24 @@ +class RegistrationsController < ApplicationController + skip_before_action :authenticate_user! + + def new + @user = User.new + end + + def create + @user = User.new(registration_params) + + if @user.save + login @user + redirect_to root_path + else + render :new, status: :unprocessable_entity + end + end + + private + + def registration_params + params.require(:user).permit(:email, :password, :password_confirmation) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 00000000..ad5454d5 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,29 @@ +class SessionsController < ApplicationController + skip_before_action :authenticate_user! + + def new + end + + def create + @user = User.authenticate_by(email: session_params[:email], password: session_params[:password]) + + if @user + login @user + redirect_to root_path, notice: t("controllers.sessions.create.notice") + else + flash[:alert] = t("controllers.sessions.create.alert") + render :new, status: :unprocessable_entity + end + end + + def destroy + logout + redirect_to root_path, notice: t("controllers.sessions.destroy.notice") + end + + private + + def session_params + params.require(:user).permit(:email, :password) + end +end diff --git a/app/mailers/password_mailer.rb b/app/mailers/password_mailer.rb new file mode 100644 index 00000000..bfb7aa8d --- /dev/null +++ b/app/mailers/password_mailer.rb @@ -0,0 +1,5 @@ +class PasswordMailer < ApplicationMailer + def password_reset + mail to: params[:user].email + end +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 00000000..73a9744b --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,3 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :user +end diff --git a/app/models/user.rb b/app/models/user.rb index 6f9f0d0b..d37580ee 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -15,6 +15,8 @@ # index_users_on_email (email) UNIQUE # class User < ApplicationRecord + PASSWORD_RESET_EXPIRATION = 15.minutes + normalizes :email, with: ->(email) { email.strip.downcase } has_one :profile, as: :profileable, dependent: :destroy @@ -22,4 +24,13 @@ class User < ApplicationRecord has_and_belongs_to_many :events validates :email, presence: true, uniqueness: true + validates :password_digest, presence: true + + normalizes :email, with: ->(email) { email.strip.downcase } + + has_secure_password + + generates_token_for :password_reset, expires_in: PASSWORD_RESET_EXPIRATION do + password_salt&.last(10) + end end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 5ddef884..251755fe 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,19 +1,28 @@ - - RailsWorld - - <%= csrf_meta_tags %> - <%= csp_meta_tag %> - <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> + + RailsWorld + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> - <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> - <%= javascript_importmap_tags %> - + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + - -
- <%= yield %> -
- + +
+
<%= notice %>
+
<%= alert %>
+ <% if user_signed_in? %> + <%= link_to "Edit Password", edit_password_path %> + <%= button_to "Log out", session_path, method: :delete %> + <% else %> + <%= link_to "Sign Up", new_registration_path %> + <%= link_to "Log in", new_session_path %> + <% end %> + <%= yield %> +
+ diff --git a/app/views/main/index.html.erb b/app/views/main/index.html.erb new file mode 100644 index 00000000..bae11ff6 --- /dev/null +++ b/app/views/main/index.html.erb @@ -0,0 +1 @@ +

Homepage

diff --git a/app/views/password_mailer/password_reset.html.erb b/app/views/password_mailer/password_reset.html.erb new file mode 100644 index 00000000..45954945 --- /dev/null +++ b/app/views/password_mailer/password_reset.html.erb @@ -0,0 +1 @@ +<%= link_to "Reset your password", edit_password_reset_url(token: params[:token]) %> diff --git a/app/views/password_resets/edit.html.erb b/app/views/password_resets/edit.html.erb new file mode 100644 index 00000000..333edd06 --- /dev/null +++ b/app/views/password_resets/edit.html.erb @@ -0,0 +1,23 @@ +

Reset Your Password

+ +<%= form_with model: @user, url: password_reset_path(token: params[:token]) do |form| %> + <% if form.object.errors.any? %> + <% form.object.errors.full_messages.each do |message| %> +
<%= message %>
+ <% end %> + <% end %> + +
+ <%= form.label :password %> + <%= form.password_field :password %> +
+ +
+ <%= form.label :password_confirmation %> + <%= form.password_field :password_confirmation %> +
+ +
+ <%= form.submit "Reset Your Password" %> +
+<% end %> diff --git a/app/views/password_resets/new.html.erb b/app/views/password_resets/new.html.erb new file mode 100644 index 00000000..ea64ecff --- /dev/null +++ b/app/views/password_resets/new.html.erb @@ -0,0 +1,12 @@ +

Reset Your Password

+ +<%= form_with model: @user, url: password_reset_path do |form| %> +
+ <%= form.label :email %> + <%= form.email_field :email %> +
+ +
+ <%= form.submit "Reset password" %> +
+<% end %> diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb new file mode 100644 index 00000000..7a30272d --- /dev/null +++ b/app/views/passwords/edit.html.erb @@ -0,0 +1,28 @@ +

Update Password

+ +<%= form_with model: current_user, url: password_path do |form| %> + <% if form.object.errors.any? %> + <% form.object.errors.full_messages.each do |message| %> +
<%= message %>
+ <% end %> + <% end %> + +
+ <%= form.label :password_challenge, "Current Password" %> + <%= form.password_field :password_challenge %> +
+ +
+ <%= form.label :password, "New Password" %> + <%= form.password_field :password %> +
+ +
+ <%= form.label :password_confirmation %> + <%= form.password_field :password_confirmation %> +
+ +
+ <%= form.submit "Update Password" %> +
+<% end %> diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb new file mode 100644 index 00000000..82160074 --- /dev/null +++ b/app/views/registrations/new.html.erb @@ -0,0 +1,28 @@ +

Sign Up

+ +<%= form_with model: @user, url: registration_path do |form| %> + <% if form.object.errors.any? %> + <% form.object.errors.full_messages.each do |message| %> +
<%= message %>
+ <% end %> + <% end %> + +
+ <%= form.label :email %> + <%= form.email_field :email %> +
+ +
+ <%= form.label :password %> + <%= form.password_field :password %> +
+ +
+ <%= form.label :password_confirmation %> + <%= form.password_field :password_confirmation %> +
+ +
+ <%= form.submit "Sign Up" %> +
+<% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 00000000..426a07a5 --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,19 @@ +

Log in

+ +<%= form_with model: @user, url: session_path do |form| %> +
+ <%= form.label :email %> + <%= form.email_field :email %> +
+ +
+ <%= form.label :password %> + <%= form.password_field :password %> +
+ +
+ <%= form.submit "Log in" %> +
+<% end %> + +<%= link_to "Forgot your password?", new_password_reset_path %> diff --git a/config/environments/development.rb b/config/environments/development.rb index e01622cf..d6d3d088 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -46,6 +46,8 @@ config.action_mailer.perform_caching = false + config.action_mailer.default_url_options = {host: "localhost", port: 3000} + # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log diff --git a/config/environments/test.rb b/config/environments/test.rb index adbb4a6f..3fb3f577 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -43,6 +43,7 @@ # The :test delivery method accumulates sent emails in the # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test + config.action_mailer.default_url_options = {host: "localhost", port: 3000} # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr diff --git a/config/locales/en.yml b/config/locales/en.yml index 6c349ae5..e18f993c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -28,4 +28,23 @@ # enabled: "ON" en: - hello: "Hello world" + controllers: + concerns: + authentication: + unauthorized: "You must be logged in to do that." + password_resets: + create: + notice: "Check your email to reset your password." + update: + notice: "Your password has been reset successfully. Please login." + errors: + invalid_token: "Invalid token, please try again" + passwords: + update: + notice: "Your password has been updated successfully." + sessions: + create: + notice: "You have signed successfully." + alert: "Invalid email or password." + destroy: + notice: "You have been logged out." \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 4283b751..96a018c0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,5 +4,10 @@ # TODO: authenticate with admin user mount MissionControl::Jobs::Engine, at: "/jobs" - # root "posts#index" + root "main#index" + + resource :registration, only: [:new, :create] + resource :session, only: [:new, :create, :destroy] + resource :password, only: [:edit, :update] + resource :password_reset, only: [:new, :create, :edit, :update] end diff --git a/db/migrate/20240628211820_create_users.rb b/db/migrate/20240628211820_create_users.rb index 05b0f086..be93835d 100644 --- a/db/migrate/20240628211820_create_users.rb +++ b/db/migrate/20240628211820_create_users.rb @@ -5,6 +5,7 @@ def change t.string :role t.boolean :mail_notifications_enabled, default: true, null: false t.boolean :in_app_notifications_enabled, default: true, null: false + t.string :password_digest, null: false t.timestamps diff --git a/db/migrate/20240703041238_add_index_to_user_email.rb b/db/migrate/20240703041238_add_index_to_user_email.rb new file mode 100644 index 00000000..ff03658d --- /dev/null +++ b/db/migrate/20240703041238_add_index_to_user_email.rb @@ -0,0 +1,5 @@ +class AddIndexToUserEmail < ActiveRecord::Migration[7.1] + def change + add_index :users, :email, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index c249095a..a6e60d62 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_06_28_211903) do +ActiveRecord::Schema[7.1].define(version: 2024_07_03_041238) do create_table "conferences", force: :cascade do |t| t.string "name", null: false t.datetime "created_at", null: false @@ -101,6 +101,7 @@ t.string "role" t.boolean "mail_notifications_enabled", default: true, null: false t.boolean "in_app_notifications_enabled", default: true, null: false + t.string "password_digest", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["email"], name: "index_users_on_email", unique: true diff --git a/spec/controllers/password_resets_controller_spec.rb b/spec/controllers/password_resets_controller_spec.rb new file mode 100644 index 00000000..bb2ec1c6 --- /dev/null +++ b/spec/controllers/password_resets_controller_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PasswordResetsController, type: :controller do + let!(:user) { create(:user, password: "password") } + let(:user_token) { user.generate_token_for(:password_reset) } + + describe "GET #new" do + it "returns a success response" do + get :new + expect(response).to have_http_status(:ok) + end + end + + describe "POST #create" do + context "with valid params" do + let(:params) do + { + email: user.email + } + end + + it "sends a password reset email" do + expect { post :create, params: params }.to change { ActionMailer::Base.deliveries.count }.by(1) + expect(response).to redirect_to(root_path) + end + end + end + + describe "GET #edit" do + let(:current) { instance_double(Current) } + + before do + allow(Current).to receive(:user).and_return(user) + end + + context "with valid token" do + it "returns a success response" do + get :edit, params: {token: user_token} + expect(response).to have_http_status(:ok) + expect(assigns(:user)).to eq(user) + end + end + + context "with invalid token" do + it "redirects to new password reset path" do + get :edit, params: {token: "invalid_token"} + expect(response).to redirect_to(new_password_reset_path(alert: I18n.t("controllers.password_resets.errors.invalid_token"))) + end + end + end + + describe "PUT #update" do + let(:new_password) { "new_password" } + let(:current) { instance_double(Current) } + + before do + allow(Current).to receive(:user).and_return(user) + end + + context "with valid params" do + let(:params) do + { + user: { + password: new_password, + password_confirmation: new_password + }, + token: user_token + } + end + + it "resets user password" do + expect { put :update, params: params } + .to change { user.reload.authenticate(new_password) }.from(false).to(user) + expect(response).to redirect_to(new_session_path) + end + end + + context "with invalid params" do + let(:params) do + { + user: { + password: "password", + password_confirmation: "wrong_password" + }, + token: user_token + } + end + + it "does not update the user password" do + expect { put :update, params: params }.not_to change { user.reload.password_digest } + expect(response).to have_http_status(:unprocessable_content) + expect(response).to render_template(:edit) + end + end + + context "with invalid token" do + let(:params) do + { + user: { + password: "password", + password_confirmation: "password" + }, + token: "invalid_token" + } + end + + it "does not update the user password" do + expect { put :update, params: params }.not_to change { user.reload.password_digest } + expect(response).to redirect_to(new_password_reset_path(alert: I18n.t("controllers.password_resets.errors.invalid_token"))) + end + end + end +end diff --git a/spec/controllers/passwords_controller_spec.rb b/spec/controllers/passwords_controller_spec.rb new file mode 100644 index 00000000..9ad546b1 --- /dev/null +++ b/spec/controllers/passwords_controller_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PasswordsController, type: :controller do + let!(:user) { create(:user, password: "password") } + let(:current) { instance_double(Current) } + + before do + allow(Current).to receive(:user).and_return(user) + end + + describe "GET #edit" do + it "returns a success response" do + get :edit + expect(response).to have_http_status(:ok) + end + end + + describe "PUT #update" do + let(:new_password) { "new_password" } + + context "with valid params" do + let(:params) do + { + user: { + password: new_password, + password_confirmation: new_password, + password_challenge: "password" + } + } + end + + it "resets user password" do + expect { put :update, params: params }.to change { user.reload.authenticate(new_password) }.from(false).to(user) + expect(response).to redirect_to(edit_password_path) + end + end + + context "with invalid params" do + let(:params) do + { + user: { + password: "password", + password_confirmation: "password", + password_challenge: "wrong_password" + } + } + end + + it "does not update the user password" do + expect { put :update, params: params }.not_to change { user.reload.password_digest } + expect(response).to have_http_status(:unprocessable_content) + expect(response).to render_template(:edit) + end + end + end +end diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb new file mode 100644 index 00000000..fa0f0318 --- /dev/null +++ b/spec/controllers/registrations_controller_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe RegistrationsController, type: :controller do + describe "GET #new" do + it "returns a success response" do + get :new + expect(response).to have_http_status(:ok) + assigns(:user).should be_a_new(User) + end + end + + describe "POST #create" do + context "with valid params" do + let(:params) do + { + user: { + email: "dev@example.com", + password: "password", + password_confirmation: "password" + } + } + end + + it "creates a new User" do + expect { post :create, params: params }.to change(User, :count).by(1) + expect(session[:user_id]).to eq(User.last.id) + expect(response).to redirect_to(root_path) + end + end + + context "with invalid params" do + let(:params) { {user: {email: ""}} } + + it "does not create a new User" do + expect { post :create, params: params }.not_to change(User, :count) + expect(response).to have_http_status(:unprocessable_content) + expect(response).to render_template(:new) + end + end + end +end diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb new file mode 100644 index 00000000..31e8b614 --- /dev/null +++ b/spec/controllers/sessions_controller_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SessionsController, type: :controller do + describe "GET #new" do + it "returns a success response" do + get :new + expect(response).to have_http_status(:ok) + end + end + + describe "POST #create" do + let!(:user) { create(:user, password: "password") } + + context "with valid params" do + let(:params) do + { + user: { + email: user.email, + password: "password" + } + } + end + + it "creates a User session" do + post :create, params: params + expect(session[:user_id]).to eq(user.id) + expect(response).to redirect_to(root_path) + assigns(:user).should eq(user) + end + end + + context "with invalid params" do + let(:params) do + { + user: { + email: user.email, + password: "invalid_password" + } + } + end + + it "does not create a User session" do + expect { post :create, params: params }.not_to change(User, :count) + expect(response).to have_http_status(:unprocessable_content) + expect(response).to render_template(:new) + end + end + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 2c70c118..bebbe6aa 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -16,7 +16,8 @@ # FactoryBot.define do factory :user do - email { "dev@example.com" } + email { Faker::Internet.email } + password { "password" } trait :with_profile do profile diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 12bcb8c5..366e19e9 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -58,4 +58,5 @@ config.filter_rails_from_backtrace! # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") + config.include FactoryBot::Syntax::Methods end