rubotnik 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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