2
\$\begingroup\$

I am currently building a small conversational bot, and I am struggling with the architecture. I use Facebook Messenger combined with Wit.ai's API and a Shopify store API. My rails app is the interface between those three APIs.

First, the rails app receives a message from Facebook. It transfers it to Wit, which sends back a message. If the user sent all the needed parameters to search the Shopify store, it will send a query to get some products, and transfer it afterwards to Facebook. If all the parameters aren't there, it will send other questions to Facebook (this is dealt with inside of Wit).

I did everything in a single controller, which I'm not comfortable with. The more the app is going to grow/the bot gets smarter, the more the controller will be huge and messy.

class MessengerBotController < ActionController::Base    
  def find_or_create_session(fbid, max_age: 5.minutes)
    Session.find_by(["facebook_id = ? AND last_exchange >= ?", fbid, max_age.ago]) ||
    Session.create(facebook_id: fbid, context: {})
  end

  def wit_request(msg, sender)
    @actions = {
      :say => -> (session_id, context, msg) {
          session = Session.find(session_id)
          session.update(context: context)
          sender.reply({ text: msg })
      },
      :merge => -> (session_id, context, entities, msg) {
        session = Session.find(session_id)
        p entities
        context["username"] = sender.get_profile[:body]["first_name"]
        if entities["gender"]
          if entities["gender"].first["value"] == "men"
            context["gender"] = 263046279
          elsif entities["gender"].first["value"] == "wom"
            context["gender"] = 263046151
          end
        end

    if entities["brand"]
      context["brand"] = entities["brand"].first["value"]
    end

    if entities["style"]
      context["style"] = entities["style"].first["value"]
    end
    @session.update(context: context)
    p context
    return context
  },
  :error => -> (session_id, context, error) {
    p 'Oops I don\'t know what to do.'
  },
  :run_query => -> (session_id, context) {
    session = Session.find(session_id)
    products = Oj.load(RestClient.get "https://#{ENV['shopify_token']}@myshopifybot.myshopify.com/admin/products.json?collection_id=#{context['gender']}&brand=#{context['brand']}&product_type=#{context['style']}")
     if context['gender'].nil? || context['brand'].nil? || context['style'].nil?
      fb_request(@session.facebook_id, "I need more information")
    else
      products = Oj.load(RestClient.get "https://#{ENV['shopify_token']}@myshopifybot.myshopify.com/admin/products.json?collection_id=#{context['gender']}&brand=#{context['brand']}&product_type=#{context['style']}")
      request_params =  {
          recipient: {id: session.facebook_id},
          message: {
          "attachment":{
            "type":"template",
            "payload":{
              "template_type":"generic",
              "elements":[
              ]
            }
          }
        },
          access_token: ENV["fb_token"]
        }
      products["products"].each do |h1|
        request_params[:message][:attachment][:payload][:elements] << { "title": h1["title"],
            "image_url": h1["images"].first["src"],
            "subtitle":"",
            "buttons":[
              {
                "type":"web_url",
                "url":"#",
                "title":"More info"
              },
              {
                "type":"postback",
                "payload": h1["id"],
                "title":"Check stock"
              },
              {
                "type":"postback",
                "title":"Similar items",
                "payload":"similar"
              }
            ]
          }
      end
    sender.reply(request_params)
  end
    return context
  }
}
client = Wit.new ENV["wit_token"], @actions


end



def message(event, sender)
    msg = event["message"]["text"]
    sender_id = event["sender"]["id"]
    session = find_or_create_session(sender_id)
    session.update(last_exchange: Time.now)
    wit_request(msg, sender)
    client.run_actions session.id, msg, session.context
  end

  def postback(event, sender)
    msg = event["postback"]["payload"]
    sender_id = event["sender"]["id"]
    session = find_or_create_session(sender)
    session.update(last_exchange: Time.now)
    wit_request(msg, sender)
    client.run_actions session.id, payload, session.context
  end
end

I'm especially struggling with the JSON/hash templates in the queries, that make the code difficult to read. Any advice/input on how to separate concerns, maybe in different controllers/helpers/methods would be much appreciated.

\$\endgroup\$
4
\$\begingroup\$

So, I think that this would be a good opportunity for a generalized service pattern. In general fat controllers are a fairly bad idea, in fact fat methods anywhere are generally a fairly bad idea. Unfortunately it looks like the Wit ruby client prefers closures to more object oriented patterns. Here are some things that I'd do:

  1. Make a strong distinction between your class's helper methods and public method boundaries. Anything called only internally put at the bottom of the class in and mark the method as private.
  2. Don't create instance variables unless they are used more than once (or it's very unclear what you're working with)
  3. DRY up your methods
  4. Add some helper methods with explicit names where they can help document what you're doing
  5. Move all interactions with external, third-party applications into their own class so that you're working with an abstraction instead of creating API payloads directly in your controller.
  6. Consider wrapping the Wit closure stuff in method calls to make it a bit simpler to follow with OOP.

Here's a quick refactor of your code. There were some things that look broken about the code you have above (such as calling client.run_actions without ever setting client in the current scope), so I fixed some of those. That being said, the method fb_request wasn't in your posted code, so I just kept it where it was.

MessengerBotController

class MessengerBotController < ActionController::Base
  def message(event, sender)
    session = find_or_create_session(event["sender"]["id"])
    WitService.new(sender).run_actions(session.id, event["message"]["text"], session.context)
  end

  def postback(event, sender)
    session = find_or_create_session(event["sender"]["id"])
    WitService.new(sender).run_actions(session.id, event["postback"]["payload"], session.context)
  end

  private

  def find_or_create_session(fbid, max_age: 5.minutes)
    @session ||= Session.find_by(["facebook_id = ? AND last_exchange >= ?", fbid, max_age.ago]) || Session.create(facebook_id: fbid, context: {})
    @session.update(last_exchange: Time.now)
  end
end

WitService (I kept the handler methods public for ease of testing)

class WitService
  def initialize(sender)
    @sender = sender
  end

  def run(session_id, context, message)
    client.run_actions session_id, message, context
  end

  def say(session_id, context, message)
    Session.where(id: session_id).limit(1).update_all(context: context)
    @sender.reply(text: message)
  end

  def merge(session_id, context, entities, message)
    session = Session.find(session_id)
    gender = entities["gender"].first["value"] if entities["gender"]

    context["username"] = @sender.get_profile[:body]["first_name"]
    context["gender"] = (gender == "men" ? 263046279 : 263046151) if ["men", "wom"].include?(gender)
    context["brand"] = entities["brand"].first["value"] if entities["brand"]
    context["style"] = entities["style"].first["value"] if entities["style"]

    session.update(context: context)
    context
  end

  def handle_error(session_id, context, error)
    puts "Got error: #{error} for session: #{session_id}"
  end

  def run_query(session_id, context)
    session = Session.find(session_id)

    return fb_request(session.facebook_id, "I need more information") if context['gender'].nil? || context['brand'].nil? || context['style'].nil?

    products = Oj.load(shopify_products(context["gender"], context["brand"], context["style"]))
    @sender.reply({
      recipient: {
        id: session.facebook_id
      },
      message: {
        attachment:{
          type: "template",
          payload: {
            template_type: "generic",
            elements: build_products_payload(products["products"])
          }
        }
      },
      access_token: ENV["fb_token"]
    })
    context
  end

  private

  def client
    @client ||= Wit.new ENV["wit_token"], {
      say: lambda(&method(:say)),
      merge: lambda(&method(:merge))
      error: lambda(&method(:handle_error))
      run_query: lambda(&method(:run_query))
    }
  end

  def shopify_products(gender, brand, style)
    RestClient.get("https://#{ENV['shopify_token']}@myshopifybot.myshopify.com/admin/products.json?collection_id=#{gender}&brand=#{brand}&product_type=#{style}")
  end

  def build_products_payload(products)
    products.map do |product|
      {
        title: product["title"],
        image_url: product["images"].first["src"],
        subtitle: "",
        buttons: [
          {
            type: "web_url",
            url: "#",
            title: "More info"
          },
          {
            type: "postback",
            payload: product["id"],
            title: "Check stock"
          },
          {
            type: "postback",
            title: "Similar items",
            payload: "similar"
          }
        ]
      }
    end
  end
end
\$\endgroup\$
2
  • \$\begingroup\$ Thanks for your help! Where am I supposed to add the WitService file ? I can't get my controllers to access its methods \$\endgroup\$ – Graham Slick May 3 '16 at 19:10
  • \$\begingroup\$ @GrahamSlick: Just dump this somewhere accessible in your Rails autoload_paths. What I generally like to do is add a services directory under app that contains all helper services. If you need help managing your autoload_path, check out guides.rubyonrails.org/configuring.html \$\endgroup\$ – photoionized May 3 '16 at 19:37

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.