diff --git a/README.md b/README.md index 1ac22c2..cf0a0df 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,45 @@ class Telegram::WebhookController < Telegram::Bot::UpdatesController end ``` +It's usual to support chain of messages like BotFather: after receiving command +it asks you for additional argument. There is `MessageContext` for this: + +```ruby +class Telegram::WebhookController < Telegram::Bot::UpdatesController + include Telegram::Bot::UpdatesController::MessageContext + + def rename(*) + # set context for the next message + save_context :rename + reply_with :message, text: 'What name do you like?' + end + + # register context handlers to handle this context + context_handler :rename do |message| + update_name message[:text] + reply_with :message, text: 'Renamed!' + end + + # You can do it in other way: + def rename(name = nil, *) + if name + update_name message[:text] + reply_with :message, text: 'Renamed!' + else + save_context :rename + reply_with :message, text: 'What name do you like?' + end + end + + # This will call #rename like if it is called with message '/rename %text%' + context_handler :rename + + # If you have a lot of such methods you can use + context_to_action! + # It'll use context value as action name for all contexts which miss handlers. +end +``` + ### Routes Use `telegram_webhooks` helper to add routes. It will create routes for bots diff --git a/lib/telegram/bot/updates_controller.rb b/lib/telegram/bot/updates_controller.rb index eb8f748..9f745af 100644 --- a/lib/telegram/bot/updates_controller.rb +++ b/lib/telegram/bot/updates_controller.rb @@ -10,6 +10,7 @@ module Telegram require 'telegram/bot/updates_controller/session' require 'telegram/bot/updates_controller/log_subscriber' require 'telegram/bot/updates_controller/instrumentation' + autoload :MessageContext, 'telegram/bot/updates_controller/message_context' include AbstractController::Callbacks # Redefine callbacks with default terminator. @@ -59,7 +60,7 @@ module Telegram match = text.match CMD_REGEX return unless match return if match[3] && username != true && match[3] != username - [match[1], text.split(' ').drop(1)] + [match[1], text.split.drop(1)] end end diff --git a/lib/telegram/bot/updates_controller/message_context.rb b/lib/telegram/bot/updates_controller/message_context.rb new file mode 100644 index 0000000..bf001f9 --- /dev/null +++ b/lib/telegram/bot/updates_controller/message_context.rb @@ -0,0 +1,102 @@ +module Telegram + module Bot + class UpdatesController + # Allows to store context in session and treat next message according to this context. + module MessageContext + extend ActiveSupport::Concern + + include Session + + included do + # As we use before_action context is cleared anyway, + # no matter we used it or not. + before_action :fetch_context + singleton_class.send :attr_reader, :context_handlers, :context_to_action + @context_handlers = {} + end + + module ClassMethods + # Registers handler for context. + # + # context_handler :rename do |message| + # resource.update!(name: message['text']) + # end + # + # # To run other action with all the callbacks: + # context_handler :rename do |message| + # process(:rename, *m['text'].split) + # end + # + # # Or just + # context_handler :rename, :your_action_to_call + # context_handler :rename # to call :rename + # + # # For messages without context use this instead of `message` method: + # context_handle do |message| + # end + # + def context_handler(context = nil, action = nil, &block) + context &&= context.to_sym + context_handlers[context] = block || action || context + end + + # Use it to use context value as action name for all contexts + # which miss handlers. + # For security reasons it supports only action methods and will + # raise AbstractController::ActionNotFound if context is invalid. + def context_to_action! + @context_to_action = true + end + end + + # Finds handler for current context and processes message with it. + def message(message) + handler = handler_for_context + return unless handler + if handler.respond_to?(:call) + instance_exec(message, &handler) + else + process(handler, *message['text'].split) + end + end + + # Action to clear context. + def cancel + # Context is already cleared in before_action + end + + private + + # Context is read from the session to treat messages + # according to previous request. + attr_reader :context + + # Fetches and removes context from session. + def fetch_context + val = session.delete(:context) + @context = val && val.to_sym + true # TODO: remove in Rails 5.0 + end + + # Save context for the next request. + def save_context(context) + session[:context] = context + end + + def handler_for_context + self.class.context_handlers[context] || self.class.context_to_action && begin + action_name = context.to_s + unless action_method?(action_name) + raise AbstractController::ActionNotFound, + "The action '#{action_name}' could not be set from context " \ + "for #{self.class.name}. " \ + 'context_to_action! supports only action methods for security reasons. ' \ + 'If you need to call this action use context_handler for it.' + end + action_name + end + end + end + end + end +end diff --git a/spec/telegram/bot/updates_controller/message_context_spec.rb b/spec/telegram/bot/updates_controller/message_context_spec.rb new file mode 100644 index 0000000..1fad69a --- /dev/null +++ b/spec/telegram/bot/updates_controller/message_context_spec.rb @@ -0,0 +1,109 @@ +RSpec.describe Telegram::Bot::UpdatesController::MessageContext do + include_context 'telegram/bot/updates_controller' + let(:controller_class) do + described_class = self.described_class + Class.new(Telegram::Bot::UpdatesController) do + include described_class + + attr_accessor :filter_done + before_action only: :redirect do + self.filter_done = true + end + + context_handler do |*args| + [:no_context, *args] + end + + context_handler :block do |*args| + [:block_result, *args] + end + + context_handler :redirect + context_handler :other_redirect, :redirect + + def redirect(*args) + [:method_result, *args] + end + + def action(*args) + [:action_result, *args] + end + + private + + def not_action + raise 'Should not be called!' + end + end + end + + describe '#message' do + subject { -> { dispatch } } + let(:payload_type) { 'message' } + let(:payload) { {'text' => text} } + let(:text) { 'asd qwe zxc' } + + context 'when context is not set' do + its(:call) { should eq [:no_context, payload] } + it { should_not change(controller, :filter_done) } + it { should_not change { session[:context] } } + end + + context 'when context is handled by block' do + before { session[:context] = :block } + its(:call) { should eq [:block_result, payload] } + it { should_not change(controller, :filter_done) } + it { should change { session[:context] }.to nil } + end + + context 'when context is handled by short redirect' do + before { session[:context] = :redirect } + its(:call) { should eq [:method_result, *text.split] } + it { should change(controller, :filter_done).to true } + it { should change { session[:context] }.to nil } + end + + context 'when context is handled by custom redirect' do + before { session[:context] = :other_redirect } + its(:call) { should eq [:method_result, *text.split] } + it { should change(controller, :filter_done).to true } + it { should change { session[:context] }.to nil } + end + + context 'when context is action`s name but not mapped' do + before { session[:context] = :action } + its(:call) { should eq nil } + it { should_not change(controller, :filter_done) } + it { should change { session[:context] }.to nil } + end + + context 'when context_to_action is true' do + before { controller_class.context_to_action! } + + context 'when context is action`s name but not mapped' do + before { session[:context] = :action } + its(:call) { should eq [:action_result, *text.split] } + it { should_not change(controller, :filter_done) } + it { should change { session[:context] }.to nil } + end + + context 'when context is invalid' do + before { session[:context] = :invalid } + it 'raises error and clears context' do + expect do + should raise_error AbstractController::ActionNotFound + end.to change { session[:context] }.to nil + end + end + + context 'when context is private method`s name' do + before { session[:context] = :not_action } + it 'raises error and clears context' do + expect do + should raise_error AbstractController::ActionNotFound + end.to change { session[:context] }.to nil + end + end + end + end +end