rubotnik 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,77 @@
1
+ require 'rubotnik/helpers'
2
+ require 'rubotnik/commands'
3
+
4
+ module Rubotnik
5
+ # Routing for messages
6
+ class MessageDispatch
7
+ include Rubotnik::Helpers
8
+ include Commands
9
+
10
+ attr_reader :message, :user
11
+
12
+ def initialize(message)
13
+ @message = message
14
+ p @message.class
15
+ p @message
16
+ @user = UserStore.instance.find_or_create_user(@message.sender['id'])
17
+ end
18
+
19
+ def route(&block)
20
+ if @user.current_command
21
+ command = @user.current_command
22
+ execute(command)
23
+ puts "Command #{command} is executed for user #{@user.id}" # log
24
+ else
25
+ bind_commands(&block)
26
+ end
27
+ rescue StandardError => error
28
+ raise unless ENV["DEBUG"] == "true"
29
+ stop_thread
30
+ say "There was an error: #{error}"
31
+ end
32
+
33
+ private
34
+
35
+ def bind_commands(&block)
36
+ @matched = false
37
+ instance_eval(&block)
38
+ end
39
+
40
+ def bind(*regex_strings, all: false, to: nil, reply_with: {})
41
+ regexps = regex_strings.map { |rs| /\b#{rs}/i }
42
+ proceed = regexps.any? { |regex| @message.text =~ regex }
43
+ proceed = regexps.all? { |regex| @message.text =~ regex } if all
44
+ return unless proceed
45
+ @matched = true
46
+ if block_given?
47
+ yield
48
+ return
49
+ end
50
+ handle_command(to, reply_with)
51
+ end
52
+
53
+ def handle_command(to, reply_with)
54
+ if reply_with.empty?
55
+ puts "Command #{to} is executed for user #{@user.id}"
56
+ execute(to)
57
+ @user.reset_command
58
+ puts "Command is reset for user #{@user.id}"
59
+ else
60
+ say(reply_with[:text], quick_replies: reply_with[:quick_replies])
61
+ @user.assign_command(to)
62
+ puts "Command #{to} is set for user #{@user.id}"
63
+ end
64
+ end
65
+
66
+ def default
67
+ return if @matched
68
+ puts 'None of the commands were recognized' # log
69
+ yield
70
+ @user.reset_command
71
+ end
72
+
73
+ def execute(command)
74
+ method(command).call
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,64 @@
1
+ require 'rubotnik/helpers'
2
+ require 'rubotnik/commands'
3
+
4
+ module Rubotnik
5
+ # Routing for postbacks
6
+ class PostbackDispatch
7
+ include Rubotnik::Helpers
8
+ include Commands
9
+
10
+ attr_reader :postback, :user
11
+
12
+ def initialize(postback)
13
+ @postback = postback
14
+ p @postback.class
15
+ p @postback
16
+ @user = UserStore.instance.find_or_create_user(@postback.sender['id'])
17
+ end
18
+
19
+ def route(&block)
20
+ @matched = false
21
+ instance_eval(&block)
22
+ rescue StandardError => error
23
+ raise unless ENV["DEBUG"] == "true"
24
+ stop_thread
25
+ say "There was an error: #{error}"
26
+ end
27
+
28
+ private
29
+
30
+ def bind(regex_string, to: nil, reply_with: {})
31
+ return unless @postback.payload == regex_string.upcase
32
+ clear_user_state
33
+ @matched = true
34
+ puts "Matched #{regex_string} to #{to.nil? ? 'block' : to}"
35
+ if block_given?
36
+ yield
37
+ return
38
+ end
39
+ handle_commands(to, reply_with)
40
+ end
41
+
42
+ def handle_commands(to, reply_with)
43
+ if reply_with.empty?
44
+ execute(to)
45
+ puts "Command #{to} is executed for user #{@user.id}"
46
+ @user.reset_command
47
+ puts "Command is reset for user #{@user.id}"
48
+ else
49
+ say(reply_with[:message], quick_replies: reply_with[:quick_replies])
50
+ @user.assign_command(to)
51
+ puts "Command #{to} is set for user #{@user.id}"
52
+ end
53
+ end
54
+
55
+ def clear_user_state
56
+ @user.reset_command # Stop any current interaction
57
+ @user.session = {} # Reset whatever you stored in the user
58
+ end
59
+
60
+ def execute(command)
61
+ method(command).call
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,22 @@
1
+ class User
2
+ attr_reader :id
3
+ attr_accessor :session
4
+
5
+ def initialize(id)
6
+ @id = id
7
+ @commands = []
8
+ @session = {}
9
+ end
10
+
11
+ def current_command
12
+ @commands.last
13
+ end
14
+
15
+ def assign_command(command)
16
+ @commands << command
17
+ end
18
+
19
+ def reset_command
20
+ @commands = []
21
+ end
22
+ end
@@ -0,0 +1,34 @@
1
+ require 'singleton'
2
+ require_relative 'user'
3
+ # In-memory storage for users
4
+ class UserStore
5
+ include Singleton
6
+ attr_reader :users
7
+
8
+ def initialize
9
+ @users = []
10
+ end
11
+
12
+ def find_or_create_user(id)
13
+ find(id) || add(User.new(id))
14
+ end
15
+
16
+ def add(user)
17
+ @users << user
18
+ user = @users.last
19
+ if user
20
+ p "user #{user.inspect} added to store"
21
+ p "we got #{@users.count} users: #{@users}"
22
+ else
23
+ p 'user not found in store yet'
24
+ end
25
+ user
26
+ end
27
+
28
+ def find(id)
29
+ user = @users.find { |u| u.id == id }
30
+ p "user #{user} found in store" if user
31
+ p "we got #{@users.count} users: #{@users}" if user
32
+ user
33
+ end
34
+ end
@@ -0,0 +1,3 @@
1
+ module Rubotnik
2
+ VERSION = "0.1.1"
3
+ end
data/lib/rubotnik.rb ADDED
@@ -0,0 +1,39 @@
1
+ require 'rubotnik/version'
2
+ require 'rubotnik/message_dispatch'
3
+ require 'rubotnik/postback_dispatch'
4
+ require 'rubotnik/user_store'
5
+ require 'rubotnik/user'
6
+ require 'rubotnik/cli'
7
+ require 'rubotnik/generator'
8
+ require 'rubotnik/autoloader'
9
+ require 'ui/fb_button_template'
10
+ require 'ui/fb_carousel'
11
+ require 'ui/image_attachment'
12
+ require 'ui/quick_replies'
13
+ require 'sinatra'
14
+ require 'facebook/messenger'
15
+ include Facebook::Messenger
16
+
17
+ module Rubotnik
18
+ def self.subscribe(token)
19
+ Facebook::Messenger::Subscriptions.subscribe(access_token: token)
20
+ end
21
+
22
+ def self.route(event, &block)
23
+ Bot.on(event, &block) unless [:message, :postback].include?(event)
24
+ Bot.on event do |e|
25
+ case e
26
+ when Facebook::Messenger::Incoming::Message
27
+ Rubotnik::MessageDispatch.new(e).route(&block)
28
+ when Facebook::Messenger::Incoming::Postback
29
+ Rubotnik::PostbackDispatch.new(e).route(&block)
30
+ end
31
+ end
32
+ end
33
+
34
+ def self.set_profile(*payloads)
35
+ payloads.each do |payload|
36
+ Facebook::Messenger::Profile.set(payload, access_token: ENV['ACCESS_TOKEN'])
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,47 @@
1
+ # rubocop:disable Metrics/MethodLength
2
+
3
+ module UI
4
+ ########################### BUTTON TEMPLATE #############################
5
+ # https://developers.facebook.com/docs/messenger-platform/send-api-reference/button-template
6
+ class FBButtonTemplate
7
+ def initialize(text, buttons)
8
+ @template = {
9
+ recipient: {
10
+ id: nil
11
+ },
12
+ message: {
13
+ attachment: {
14
+ type: 'template',
15
+ payload: {
16
+ template_type: 'button',
17
+ text: text,
18
+ buttons: parse_buttons(buttons)
19
+ }
20
+ }
21
+ }
22
+ }
23
+ end
24
+
25
+ # Sends the valid JSON to Messenger API
26
+ def send(user)
27
+ formed = build(user)
28
+ Bot.deliver(formed, access_token: ENV['ACCESS_TOKEN'])
29
+ end
30
+
31
+ # Use this method to return a valid hash and save it for later
32
+ def build(user)
33
+ @template[:recipient][:id] = user.id
34
+ @template
35
+ end
36
+
37
+ private
38
+
39
+ def parse_buttons(buttons)
40
+ return [] if buttons.nil? || buttons.empty?
41
+ buttons.map do |button|
42
+ button[:type] = button[:type].to_s
43
+ button
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,65 @@
1
+ # rubocop:disable Metrics/MethodLength
2
+ module UI
3
+ ################## GENERIC TEMPLATE (aka CAROUSEL) #######################
4
+ # https://developers.facebook.com/docs/messenger-platform/send-api-reference/generic-template
5
+ class FBCarousel
6
+ def initialize(elements)
7
+ @template = {
8
+ recipient: { id: nil },
9
+ message: {
10
+ attachment: {
11
+ type: 'template',
12
+ payload: {
13
+ template_type: 'generic',
14
+ image_aspect_ratio: 'horizontal',
15
+ elements: parse_elements(elements)
16
+ }
17
+ }
18
+ }
19
+ }
20
+ end
21
+
22
+ # Sends the valid JSON to Messenger API
23
+ def send(user)
24
+ template = build(user)
25
+ Bot.deliver(template, access_token: ENV['ACCESS_TOKEN'])
26
+ end
27
+
28
+ # Use this method to return a valid hash and save it for later
29
+ def build(user)
30
+ @template[:recipient][:id] = user.id
31
+ @template
32
+ end
33
+
34
+ # set image aspect ratio to 'square'
35
+ def square_images
36
+ @template[:message][:attachment][:payload][:image_aspect_ratio] = 'square'
37
+ self
38
+ end
39
+
40
+ # set image aspect ratio to 'square'
41
+ def horizontal_images
42
+ hrz = 'horizontal'
43
+ @template[:message][:attachment][:payload][:image_aspect_ratio] = hrz
44
+ self
45
+ end
46
+
47
+ private
48
+
49
+ def parse_elements(elements)
50
+ elements = [elements] if elements.class == Hash
51
+ elements.map do |elt|
52
+ elt[:buttons] = parse_buttons(elt[:buttons])
53
+ elt
54
+ end
55
+ end
56
+
57
+ def parse_buttons(buttons)
58
+ return [] if buttons.nil? || buttons.empty?
59
+ buttons.map do |button|
60
+ button[:type] = button[:type].to_s
61
+ button
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,31 @@
1
+ # rubocop:disable Metrics/MethodLength
2
+ module UI
3
+ # https://developers.facebook.com/docs/messenger-platform/send-api-reference/image-attachment
4
+ class ImageAttachment
5
+ def initialize(url)
6
+ @template = {
7
+ recipient: {
8
+ id: nil
9
+ },
10
+ message: {
11
+ attachment: {
12
+ type: 'image',
13
+ payload: {
14
+ url: url
15
+ }
16
+ }
17
+ }
18
+ }
19
+ end
20
+
21
+ def send(user)
22
+ formed = build(user)
23
+ Bot.deliver(formed, access_token: ENV['ACCESS_TOKEN'])
24
+ end
25
+
26
+ def build(user)
27
+ @template[:recipient][:id] = user.id
28
+ @template
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,39 @@
1
+ module UI
2
+ # https://developers.facebook.com/docs/messenger-platform/send-api-reference/quick-replies
3
+ class QuickReplies
4
+ def self.build(*replies)
5
+ replies.map do |reply|
6
+ case reply
7
+ when Hash then build_from_hash(reply)
8
+ when Array then build_from_array(reply)
9
+ when String then build_from_string(reply)
10
+ else
11
+ raise ArgumentError, 'Arguments should be hashes or arrays of two'
12
+ end
13
+ end
14
+ end
15
+
16
+ def self.location
17
+ [{ content_type: 'location' }]
18
+ end
19
+
20
+ private_class_method def self.build_from_hash(reply)
21
+ unless reply.key?(:content_type)
22
+ reply[:content_type] = 'text'
23
+ error_msg = "type 'text' should have a payload"
24
+ raise ArgumentError, error_msg unless reply.key?(:payload)
25
+ end
26
+ reply
27
+ end
28
+
29
+ private_class_method def self.build_from_string(reply)
30
+ build_from_array([reply, reply.upcase])
31
+ end
32
+
33
+ private_class_method def self.build_from_array(reply)
34
+ error_msg = 'Only accepts arrays of two elements'
35
+ raise ArgumentError, error_msg if reply.length != 2
36
+ { content_type: 'text', title: reply[0].to_s, payload: reply[1].to_s }
37
+ end
38
+ end
39
+ end
data/rubotnik.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'rubotnik/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "rubotnik"
8
+ spec.version = Rubotnik::VERSION
9
+ spec.authors = ["Andy Barnov"]
10
+ spec.email = ["andy.barnov@gmail.com"]
11
+
12
+ spec.summary = %q{Ruby "bot-end" micro-framework for Facebook Messenger}
13
+ spec.homepage = "https://github.com/progapandist/rubotnik"
14
+ spec.license = "MIT"
15
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
16
+ f.match(%r{^(test|spec|features)/})
17
+ end
18
+ spec.bindir = "exe"
19
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
+ spec.require_paths = ["lib"]
21
+
22
+ spec.add_dependency 'facebook-messenger'
23
+ spec.add_dependency 'addressable'
24
+ spec.add_dependency 'dotenv'
25
+ spec.add_dependency 'httparty'
26
+ spec.add_dependency 'puma'
27
+ spec.add_dependency 'sinatra'
28
+ spec.add_dependency 'thor'
29
+
30
+ spec.add_development_dependency "bundler", "~> 1.15"
31
+ spec.add_development_dependency "rake", "~> 10.0"
32
+ spec.add_development_dependency "rspec", "~> 3.0"
33
+ end
data/templates/.env ADDED
@@ -0,0 +1,3 @@
1
+ VERIFY_TOKEN=verify_me
2
+ ACCESS_TOKEN=PUT_YOUR_ACCESS_TOKEN_HERE
3
+ DEBUG=true
@@ -0,0 +1 @@
1
+ .env
data/templates/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'https://rubygems.org'
2
+ gem 'rubotnik'
@@ -0,0 +1 @@
1
+ web: bundle exec puma -C config/puma.rb
data/templates/bot.rb ADDED
@@ -0,0 +1,91 @@
1
+ require 'rubotnik'
2
+ # require_relative all files in "bot" folder or do it by hand
3
+ Rubotnik::Autoloader.load('bot')
4
+
5
+ # Subscribe your bot to a Facebook Page (put access and verify tokens in .env)
6
+ Rubotnik.subscribe(ENV['ACCESS_TOKEN'])
7
+
8
+ # Set welcome screen, "get started" button and a menu (all optional)
9
+ # Edit profile.rb before uncommenting the following lines:
10
+
11
+ # Rubotnik.set_profile(
12
+ # Profile::START_BUTTON, Profile::START_GREETING, Profile::SIMPLE_MENU
13
+ # )
14
+
15
+ # Generates a location prompt for quick_replies
16
+ LOCATION_PROMPT = UI::QuickReplies.location
17
+
18
+ ####################### HANDLE INCOMING MESSAGES ##############################
19
+
20
+ Rubotnik.route :message do
21
+ # Will work for all variations of these three greetings
22
+ bind 'hi', 'hello', 'bonjour' do
23
+ say 'Hello from your new bot!'
24
+ end
25
+
26
+ # Start a thread (and provide an opening message with optional quick replies).
27
+ # You have to define method named as a symbol inside a Command module
28
+ # and treat user's response to your "reply_with" message there.
29
+ # commands/commands.rb already has this start_conversation method
30
+ # defined as an example.
31
+
32
+ bind 'how', 'do', 'you', all: true, to: :start_conversation, reply_with: {
33
+ text: "I'm doing fine! You?",
34
+ quick_replies: [['Good!', 'OK'], ['Not so well', 'NOT_OK']]
35
+ # second item in nested array will be the contents of message.quick_reply,
36
+ # once the user makes a selection. Quick reply text in ALL CAPS will be
37
+ # used as default values of payloads if you pass strings instead of arrays
38
+ # (e.g. quick_replies: ['Yes', 'No'], payloads "YES" and "NO" are inferred)
39
+ }
40
+
41
+ # Use 'all' flag if you want to trigger a command only if all words
42
+ # are present in a message (will trigger with each of them by default)
43
+ bind 'what', 'my', 'name', all: true do
44
+ info = get_user_info(:first_name) # helper to get fields from Graph API
45
+ say info[:first_name]
46
+ end
47
+
48
+ # Look for example of an API call with HTTParty in commands/location.rb
49
+ bind 'where', 'am', 'I', all: true, to: :lookup_location, reply_with: {
50
+ text: 'Let me know your location',
51
+ quick_replies: LOCATION_PROMPT
52
+ }
53
+
54
+ # Look for more UI examples in commands/ui_examples.rb
55
+ # Rubotnik currently supports Image, Button Template and Carousel
56
+ bind 'image', to: :show_image
57
+
58
+ # Invoked if none of the commands recognized. Has to come last, after all binds
59
+ default do
60
+ say "Sorry I did not get it"
61
+ end
62
+ end
63
+
64
+ ####################### HANDLE INCOMING POSTBACKS ##############################
65
+
66
+ Rubotnik.route :postback do
67
+ # postback from "Get Started" button
68
+ bind 'START' do
69
+ say "Welcome!"
70
+ end
71
+ end
72
+
73
+ ####################### HANDLE OTHER REQUESTS (NON-FB) #########################
74
+
75
+ get '/' do
76
+ 'I can have a landing page too!'
77
+ end
78
+
79
+ ############################ TEST ON LOCALHOST #################################
80
+
81
+ # 0. Have both Heroku CLI and ngrok
82
+ # 1. Set up "Messenger" app on Facebook for Developers, fill in .env
83
+ # 2. Run 'heroku local' from console, it will load Puma on port 5000
84
+ # 3. Expose port 5000 to the Internet with 'ngrok http 5000'
85
+ # 4. Provide your ngrok http_s_(!) address in Facebook Developer Dashboard
86
+ # for webhook validation.
87
+ # 5. Open Messenger and talk to your bot!
88
+
89
+ # P.S. While you have DEBUG environment variable set to "true" (default in .env)
90
+ # All StandardError exceptions will go to the message dialog instead of
91
+ # breaking the server.
@@ -0,0 +1,38 @@
1
+ module Commands
2
+ # You can write all your commands as methods here
3
+
4
+ # If the command is bound with reply_with specified,
5
+ # you have to deal with user response to the last message and react on it.
6
+ def start_conversation
7
+ # Quick replies are accessible through message object's quick_reply property,
8
+ # by default it's the quick reply text in ALL CAPS
9
+ # you can also react on the text itself
10
+ message.typing_on
11
+ case message.quick_reply
12
+ when 'OK'
13
+ say "Glad you're doing well!"
14
+ stop_thread
15
+ when 'NOT_OK'
16
+ say "Too bad. What happened?"
17
+ next_command :appear_nice
18
+ else
19
+ say "🤖"
20
+ # it's always a good idea to have an else, quick replies don't
21
+ # prevent user from typing any message in the dialogue
22
+ stop_thread
23
+ end
24
+ message.typing_off
25
+ end
26
+
27
+ def appear_nice
28
+ message.typing_on
29
+ case message.text
30
+ when /job/i then say "We've all been there"
31
+ when /family/i then say "That's just life"
32
+ else
33
+ say "It shall pass"
34
+ end
35
+ message.typing_off
36
+ stop_thread # future messages from user will be handled from top-level bindings
37
+ end
38
+ end
@@ -0,0 +1,12 @@
1
+ # require 'dotenv/load' # Use this if you want plain 'rackup' over 'heroku local'
2
+ require 'facebook/messenger'
3
+ require 'sinatra'
4
+
5
+ require_relative './bot/bot.rb'
6
+
7
+ map('/webhook') do
8
+ run Facebook::Messenger::Server
9
+ end
10
+
11
+ # run regular Sinatra too
12
+ run Sinatra::Application
@@ -0,0 +1,36 @@
1
+ module Commands
2
+ API_URL = 'https://maps.googleapis.com/maps/api/geocode/json?latlng='.freeze
3
+
4
+ # Lookup based on location data from user's device
5
+ def lookup_location
6
+ if message_contains_location?
7
+ handle_user_location
8
+ else
9
+ say("Please try your request again and use 'Send location' button")
10
+ end
11
+ stop_thread
12
+ end
13
+
14
+ def handle_user_location
15
+ coords = message.attachments.first['payload']['coordinates']
16
+ lat = coords['lat']
17
+ long = coords['long']
18
+ message.typing_on
19
+ parsed = get_parsed_response(API_URL, "#{lat},#{long}")
20
+ address = extract_full_address(parsed)
21
+ say "Coordinates of your location: Latitude #{lat}, Longitude #{long}. " \
22
+ "Looks like you're at #{address}"
23
+ message.typing_off
24
+ end
25
+
26
+ # Talk to API
27
+ def get_parsed_response(url, query)
28
+ response = HTTParty.get(url + query)
29
+ parsed = JSON.parse(response.body)
30
+ parsed['status'] != 'ZERO_RESULTS' ? parsed : nil
31
+ end
32
+
33
+ def extract_full_address(parsed)
34
+ parsed['results'].first['formatted_address']
35
+ end
36
+ end