lib/hg/chunk.rb
# frozen_string_literal: true
module Hg
module Chunk
def self.included(base)
base.extend ClassMethods
base.prepend Initializer
base.id = base.to_s
base.deliverables = []
base.dynamic = false
base.add_to_chunks
base.include_chunks
end
# @return [OpenStruct] The execution context for this chunk instance.
def context
@memoized_context ||= @context
end
def deliver
Sidekiq::Logging.logger.info 'DELIVERABLES'
self.class.deliverables.each do |deliverable|
if deliverable.is_a? Hash
Sidekiq::Logging.logger.info JSON.pretty_generate(deliverable)
else
Sidekiq::Logging.logger.info deliverable.inspect
end
end
Sidekiq::Logging.logger.info 'RECIPIENT'
Sidekiq::Logging.logger.info @recipient.inspect
self.class.deliverables.each do |deliverable|
# If another chunk...
if deliverable.is_a? Class
# ...deliver the chunk.
deliverable.new(recipient: @recipient, context: context).deliver
# If dynamic, then it needs to be evaluated at delivery time.
elsif deliverable.is_a? Proc
# Create a `template` anonymous subclass of the chunk class.
template = Class.new(self.class)
template.deliverables = []
# Evaluate the dynamic block within it.
template.class_exec(context, &deliverable)
# Deliver the chunk.
template.new(recipient: @recipient, context: context).deliver
# Otherwise, it's just a raw message.
else
# Deliver the message
response = Facebook::Messenger::Bot.deliver(deliverable.merge(recipient: @recipient), access_token: ENV['FB_ACCESS_TOKEN'])
# Send to Chatbase if env var present
if ENV['CHATBASE_API_KEY']
ChatbaseAPIClient.new.send_bot_message(deliverable, response)
end
end
end
end
module Initializer
def initialize(recipient: nil, context: nil)
# TODO: test
# Ensure recipient is transformed into a Hash
if recipient.is_a? Hash
@recipient = recipient
else
@recipient = {
'id': recipient
}
end
@context = HashWithIndifferentAccess.new(context)
end
end
module ClassMethods
attr_accessor :id
attr_accessor :deliverables
attr_accessor :label
attr_accessor :recipient
attr_accessor :context
attr_accessor :dynamic
def bot_class
Kernel.const_get(self.to_s.split('::').first)
end
def label(text)
@label = text
end
def add_to_chunks
bot_class.chunks << self
end
def include_chunks
bot_class.class_eval "include #{bot_class.to_s}::Chunks"
end
def dynamic(&block)
@dynamic = true
@deliverables << block
end
def show_typing(recipient)
Facebook::Messenger::Bot.deliver({
recipient: recipient,
sender_action: 'typing_on'
}, access_token: ENV['FB_ACCESS_TOKEN'])
end
# Add text message to chunk
#
# @param [String, Array] message
# Message to be delivered.
# If message is an array, will deliver one at random
#
# @return [void]
def text(message)
# Sample if message is an array
message = message.sample if message.respond_to?(:sample)
@deliverables <<
{
message: {
text: message
}
}
end
def title(text)
@card[:title] = text
end
def subtitle(text)
@card[:subtitle] = text
end
# Add a default action (link to navigate to when image is tapped) to the card.
#
# @param [String] url
# The URL to navigate to.
# @param [String|Symbol] webview_height_ratio
# The height of the webview to open (compact, tall, or full).
#
# @return [void]
def default_action(url, webview_height_ratio = 'full')
# TODO: This should be a private method check that runs anywhere we
# use this option.
unless %w[compact tall full].any? { |r| r == webview_height_ratio }
raise ArgumentError, 'webview_height_ratio must be one of "compact", "tall", or "full"'
end
@card[:default_action] = {
type: 'web_url',
url: url,
webview_height_ratio: webview_height_ratio.to_s
}
end
def image_url(url, options = {})
if options.has_key?(:host)
@card[:image_url] =
ApplicationController.helpers.image_url(url, options)
else
@card[:image_url] = url
end
# If aspect ratio changed to square from the horizontal default
if options[:square_aspect_ratio]
set_square_image_ratio
end
end
def item_url(url)
@card[:item_url] = url
end
# Build a button template message.
# See https://developers.facebook.com/docs/messenger-platform/send-api-reference/button-template
def buttons(&block)
@card = {}
yield
deliverable = {
message: {
attachment: {
type: 'template',
payload: {
template_type: 'button'
}
}
}
}
# Move buttons to proper location
deliverable[:message][:attachment][:payload][:buttons] = @card.delete(:buttons)
deliverable[:message][:attachment][:payload][:text] = @deliverables.pop[:message][:text]
@deliverables << deliverable
end
# TODO: should log_in and log_out be log_in_button and log_out_button?
def log_in(url)
button nil, url: url, type: 'account_link'
end
def log_out
button nil, type: 'account_unlink'
end
# Add a call button to a card.
#
# @param text [String]
# The text to appear in the button.
# @param number [String]
# The number to call when the button is pressed.
#
# @see https://developers.facebook.com/docs/messenger-platform/send-api-reference/call-button
#
# @return [void]
def call_button(text, number:)
add_button({
type: 'phone_number',
title: text,
payload: number
})
end
# Add a share button to a card.
#
# @see https://developers.facebook.com/docs/messenger-platform/send-api-reference/share-button
#
# @return [void]
def share_button(&block)
button_options = {
type: 'element_share'
}
# If a chunk is passed, add as share_contents option.
if block_given?
original_gallery = @gallery.clone
original_card = @card.clone
@gallery = {
cards: [],
attachment: {
type: 'template',
payload: {
template_type: 'generic',
elements: []
}
}
}
card(&block)
@gallery[:attachment][:payload][:elements] = @gallery.delete(:cards)
button_options[:share_contents] = @gallery.clone
@gallery = original_gallery
@card = original_card
end
add_button(button_options)
end
# TODO: High - buttons need their own module
def button(text, options = {})
# TODO: text needs a better name
# If the first argument is a chunk, then make this button a link to that chunk
if text.is_a? Class
klass = text
text = text.instance_variable_get(:@label)
button_content = {
title: text,
type: 'postback',
payload: klass.to_s
}
else
button_content = {
title: text
}
end
# If a `to` option is present, assume this is a postback link to another chunk.
if options[:to]
button_content[:type] = 'postback'
button_content[:payload] = JSON.generate({
action: Hg::InternalActions::DISPLAY_CHUNK,
parameters: {
chunk: options[:to].to_s
}
})
# If a different type of button is specified (e.g. "Log in"), then pass
# through the `type` and `url`.
elsif options[:type]
button_content[:type] = options[:type]
button_content[:url] = evaluate_option(options[:url])
# If a `url` option is present, assume this is a webview link button.
elsif options[:url]
button_content[:type] = 'web_url'
button_content[:url] = evaluate_option(options[:url])
elsif options[:payload]
button_content[:type] = 'postback'
# Encode the payload hash as JSON.
button_content[:payload] = JSON.generate(options[:payload])
end
# Pass through the `webview_height_ratio` option.
button_content[:webview_height_ratio] = options[:webview_height_ratio]
add_button(button_content)
end
def add_button(button_content)
@card[:buttons] = [] unless @card[:buttons]
@card[:buttons] << button_content
end
def quick_replies(*classes)
classes.each do |klass|
quick_reply klass.instance_variable_get(:@label), to: klass
end
end
def quick_reply(title, options = {})
quick_reply_content = {
content_type: 'text',
title: title
}
# If image_url is specified include the url or asset path
if options[:image_path]
quick_reply_content[:image_url] =
ApplicationController
.helpers
.image_url(options[:image_path], host: options[:host])
else
quick_reply_content[:image_url] = options[:image_url]
end
# If a `to` option is present, assume this is a postback link to another chunk.
if options[:to]
quick_reply_content[:payload] = JSON.generate({
action: Hg::InternalActions::DISPLAY_CHUNK,
parameters: {
chunk: options[:to].to_s
}
})
# If this is a location request, send the appropriate options.
elsif options[:location_request]
quick_reply_content[:content_type] = 'location'
quick_reply_content[:title] = nil
# Otherwise, just take the payload as passed.
else
quick_reply_content[:payload] = JSON.generate(options[:payload])
end
unless @deliverables.last[:message][:quick_replies]
@deliverables.last[:message][:quick_replies] = []
end
@deliverables.last[:message][:quick_replies] << quick_reply_content
end
# Generate a quick reply button that requests the user's location.
def quick_reply_location_request
quick_reply nil, location_request: true
end
def card(&block)
@card = {}
yield
@gallery[:cards] << @card
end
def gallery(&block)
@gallery = {
cards: [],
message: {
attachment: {
type: 'template',
payload: {
template_type: 'generic',
elements: []
}
}
}
}
yield
@gallery[:message][:attachment][:payload][:elements] = @gallery.delete(:cards)
@deliverables << @gallery
end
def image(url)
attachment('image', url)
end
def video(url)
attachment('video', url)
end
def chunk(chunk_class)
@deliverables << chunk_class
end
def t(*args)
I18n.t(*args)
end
def ref_link(page_id, payload)
"http://m.me/#{page_id}?ref=#{URI.encode(payload.to_json)}"
end
private
# Take an option, and either call it (if a lambda) or return its value.
#
# @param [lambda, String] option Either a lambda to be evaluated, or a value
# @return [String] The option value
#
# TODO: Is this method still necessary? Only place it's used doesn't seem to
# make use of this functionality.
def evaluate_option(option)
if option.respond_to?(:call)
# TODO: BUG - @context is a class instance variable, this isn't going to work correctly
option.call(@context)
else
option
end
end
def attachment(type, url)
@deliverables << {
message: {
attachment: {
type: type,
payload: {
url: url
}
}
}
}
end
# Sets image ratio on gallery to square. Horizontal by default.
#
# see: https://developers.facebook.com/docs/messenger-platform/send-api-reference/generic-template
#
# @return [void]
def set_square_image_ratio
@gallery[:message][:attachment][:payload][:image_aspect_ratio] = 'square'
end
end
end
end