Skip to content

Instantly share code, notes, and snippets.

@casecode
Last active September 21, 2015 21:23
Show Gist options
  • Save casecode/c9087928c3224a2a0fac to your computer and use it in GitHub Desktop.
Save casecode/c9087928c3224a2a0fac to your computer and use it in GitHub Desktop.
Automated Message Responder - Code Snippet

Autoresponder

The SMS Responder below demonstrates how to declare hash properties as methods in Ruby. This can be convenient if you have long if/elsif/else or case statements in your Ruby code. For example:

if x == "a"
  do_something(args)
elsif x == "b"
  do_something_else(args)
# remainder of long if/elsif block
end

def do_something(params)
end

def do_something_else(params)
end

turns into:

# Forward declare methods
def do_something(params)
end

def do_something_else(params)
end

# Define hash with methods as properties
responder = {
  "a" => method(:do_something),
  "b" => method(:do_something_else)
}

# If x is equal to one of the keys, you can now call the associated method and pass in arguments
# Note that since you are passing in the same arguments regardless of the method called,
# all of the methods will need to accept the same arguments (or allow for optional arguments)
if x.in? responder.keys
  responder[x].call(args)
end

For this to work, you need to forward declare the methods. You could simply put them above the hash in the same file, but you run the risk of someone else moving the code around (especially since forward declaration is uncommon in Ruby). By using a module, you can abstract the method declarations so as to avoid this problem. This also gives you the feel of having something similar to a header file, like in C.

Below is an SMSResponder where I have employed this technique.

require 'ostruct'
module SmsResponder
# In order for Ruby to evaluate methods as hash properties
# (see keyword_dispatcher and non_keyword_dispatcher below)
# C-style forward declaration of those methods is necessary
# Thus, those methods have been abstracted into modules that extend SmsResponder
extend KeywordResponder
extend NonKeywordResponder
# Define all objects and methods on the module by adding to eigenclass
class << self
# Matrix for determining which method to call to respond to keyword message
def keyword_dispatcher
{
"REGISTER" => method(:respond_to_register_message),
"STOP" => method(:respond_to_stop_message),
"HELP" => method(:respond_to_help_message),
"RESUME" => method(:respond_to_resume_message)
}
end
# Matrix for determining which method to call to respond to non_keyword message
def non_keyword_dispatcher
{
ADDRESS_MESSAGE => method(:respond_to_address_message),
ADDRESS_MESSAGE_2 => method(:respond_to_address_message),
ADDRESS_MESSAGE_3 => method(:respond_to_address_message),
FULL_NAME_MESSAGE => method(:respond_to_full_name_message)
}
end
def deliver_response(sms_params)
response_messages = []
# Use openstruct to allow for dot notation and make code more readable
sms = OpenStruct.new(
:from_phone_number => sms_params["FromPhoneNumber"].gsub(/^1/, ''),
:to_phone_number => sms_params["ToPhoneNumber"],
:reference_id => sms_params["ReferenceID"],
:message_body => sms_params["Message"]
)
# If message body gets a hit for a keyword, respond to keyword message
if sms.message_body.upcase.in? keyword_dispatcher.keys
# Determine appropriate keyword response and push to response_messages
response_messages.push(*respond_to_keyword_message(sms))
elsif sms.reference_id.present?
# If reference_id gets a hit on the non_keyword_dispatcher
if sms.reference_id.in? non_keyword_dispatcher.keys
# Determine appropriate response and push to response_messages
response_messages.push(*respond_to_non_keyword_message(sms))
# Else if SMS received in response to outgoing message generated by user from app
# then read ref_id to determine recipient type and save reply,
# but do not send an automated response
elsif (ref_id = sms.reference_id.split('-'))[0] == "message"
recipient_type = ref_id[1] # either "applicant" or "user"
save_reply(sms, recipient_type)
return
end
# Otherwise, applicant needs to register before using service
else
response_messages << PLEASE_REGISTER_MESSAGE
end
send_responses(sms, response_messages)
end
# For keyword messages, call corresponding method in keyword_dispatcher hash
def respond_to_keyword_message(sms)
# All methods in KeywordResponder module return an array of response messages
# They also all take an sms argument
keyword_dispatcher[sms.message_body.upcase].call(sms)
end
# For non_keyword messages, call corresponding method in non_keyword_dispatcher hash
def respond_to_non_keyword_message(sms)
# All methods in NonKeywordResponder module return an array of response messages
# They also all take an sms argument
non_keyword_dispatcher[sms.reference_id].call(sms)
end
# Create new reply and associate it with parent message
# Returns newly created reply
def save_reply(sms, source)
# Extract parent message id from reference_id param to find parent message
parent_message = Message.find(sms.reference_id.rpartition('-')[-1].to_i)
applicant = source == "applicant" ? Applicant.find_by_phone(sms.from_phone_number) : nil
user = source == "user" ? User.find_by_phone(sms.from_phone_number) : nil
payload = {
:body => sms.message_body,
:from_phone_number => sms.from_phone_number,
:to_phone_number => sms.to_phone_number,
:applicant_id => applicant.nil? ? nil : applicant.id,
:user_id => user.nil? ? nil : user.id,
:message_id => parent_message.id,
:thread_root_id => parent_message.thread_root_id
}
reply = Reply.create(payload)
applicant.update_attributes(reply_rcvd: Time.now, last_contacted: nil) unless applicant.nil?
parent_message.increment_reply_count
return reply
end
# Send responses for registration messages via Sidekiq worker
def send_responses(sms, response_messages)
response_messages.each do |message|
# Note that all parameters are strings, thus do not require serialization
SmsWorkers::RegistrationMessage.perform_async(message, sms.to_phone_number, sms.from_phone_number)
end
end
end
end
# Assist SmsResponder in responding to keyword messages
# All methods return an array of response messages (strings)
module KeywordResponder
def respond_to_register_message(sms)
# Add new applicant to db and respond with address message
Applicant.add_phone_number(sms.from_phone_number, sms.message_body)
(response_messages ||= []) << ADDRESS_MESSAGE
end
def respond_to_stop_message(sms)
# Applicant no longer opted_in after texting STOP
applicant = Applicant.find_by_phone(sms.from_phone_number)
applicant.update_attribute(:opted_in, false)
# Return no messages - messaging service blocks all outgoing messages if applicant texts STOP
response_messages ||= []
end
def respond_to_help_message(sms)
(response_messages ||= []) << KEYWORD_HELP_RESPONSE
end
def respond_to_resume_message(sms)
# Applicant now opted_in after texting RESUME
applicant = Applicant.find_by_phone(sms.from_phone_number)
applicant.update_attribute(:opted_in, true)
if applicant.sign_up_complete
# Messaging service will automatically send an "OK" message
# No need for an additional message if the applicant already completed sign up
response_messages ||= []
else
# If applicant did not complete sign up prior to opting out
# then restart sign up process after RESUME
(response_messages ||= []) << PLEASE_REGISTER_MESSAGE
end
end
end
# Assist SmsResponder in responding to non-keyword messages
module NonKeywordResponder
# Determine approprate response for address_message
# Returns an array of strings representing messages to send to recipient
def respond_to_address_message(sms)
# Address found in sms message body
coordinates = Geocoder.search(sms.message_body)[0]
address_valid, response_messages = false, []
# Validate whether applicant address is valid
if (!coordinates.nil? && !coordinates.street_number.nil?)
address_valid, validation_complete = true, true
# Give applicant 3 attempts to provide a valid address
else
case sms.reference_id
when ADDRESS_MESSAGE then response_messages << ADDRESS_MESSAGE_2
when ADDRESS_MESSAGE_2 then response_messages << ADDRESS_MESSAGE_3
when ADDRESS_MESSAGE_3 then response_messages << ADDRESS_INVALID_MESSAGE and validation_complete = true
end
end
# Once address validation complete, respond accordingly based on address_valid
if address_valid # if address_valid is true, validation_complete must also be true
# Add applicant address to db and respond accordingly
Applicant.add_address(sms, coordinates)
response_messages << FULL_NAME_MESSAGE
elsif validation_complete # i.e. if validation_complete is true, but address_valid is false
# Remove applicant from db and respond accordingly
Applicant.delete_applicant(sms.from_phone_number)
response_messages << ADDRESS_INVALID_MESSAGE
end
return response_messages
end
# Generate response for full_name_message
# Returns an array of message strings
def respond_to_full_name_message(sms)
# Add applicant's full name to db - name found in sms message body
applicant = Applicant.find_by_phone(sms.from_phone_number)
applicant.update_attribute(:full_name, sms.message_body)
# Pull back first name from full name response and generate response message
first_name = applicant.full_name.split(" ")[0].titleize
(response_messages ||= []) << "#{first_name}, #{CONFIRMATION_MESSAGE}"
end
end
class Message < ActiveRecord::Base
belongs_to :user
has_many :replies
has_and_belongs_to_many :applicant_recipients, :join_table => :message_recipients, :class_name => "Applicant"
has_and_belongs_to_many :user_recipients, :join_table => :message_recipients, :class_name => "User"
def deliver_message(params)
# If message is not part of a current thread, create new thread with message as thread root
update_attribute(:thread_root_id, id) if thread_root_id.nil?
populate_recipients(params)
# If no scheduled time present, send and broadcast message now and update recipient attributes accordingly
unless scheduled_time.present?
broadcast and send_now
transition_applicant_recipients
else
# If scheduled time present, schedule job for time specified
schedule_job
end
end
# Determine message recipients based on message params
def populate_recipients(params)
applicant_ids, user_ids, = nil, nil
if params.group_type
case params.group_type
when "applicants"
if params.message_all == "true"
applicant_ids = Applicant.active.pluck(:id)
elsif params.group_ids.present?
# Collect ids for applicants in applicant groups
groups = Group.where(id: params.group_ids.map(&:to_i)).includes(:applicants)
group_ids = groups.pluck(:id)
# Collect unique ids in event applicant belongs to multiple groups (to avoid duplicate messages)
applicant_ids = groups.collect { |group| group.applicants.pluck(:id) }.flatten.uniq
elsif params.individual_ids.present?
applicant_ids = params.individual_ids.split(",").reject(&:empty?).map(&:to_i)
end
when "users"
if params.message_all == "true"
user_ids = User.staff.pluck(:id)
elsif params.individual_ids.present?
user_ids = params.individual_ids.split(",").reject(&:empty?).map(&:to_i)
end
end
end
insert_recipients(applicant_ids, user_ids)
end
# Populate message_recipients join table for applicants, applicant groups, and users
def insert_recipients(applicant_ids, user_ids)
message_id, inserts = self.id, []
# Collect inserts for mass db write
applicant_ids.each { |id| inserts.push "(#{message_id}, #{id}, NULL)" } unless applicant_ids.nil?
user_ids.each { |id| inserts.push "(#{message_id}, NULL, #{id})" } unless user_ids.nil?
# Use SQL inserts to speed up mass writes
MessageRecipient.connection.execute("INSERT INTO message_recipients (message_id, applicant_id, user_id) VALUES #{inserts.join(',')}")
end
# When applicant is messaged, update last_contact and clear reply_rcvd
def transition_applicant_recipients
self.applicant_recipients.update_all(last_contacted: Time.now, reply_rcvd: nil) if self.applicant_recipients.present?
end
def broadcast
# Define websocket data to push to clients connected to sms_channel
message_data = {
:id => id,
:message_type => message_type,
:thread_root_id => thread_root_id,
:reply_ref_id => reply_ref_id,
:user => user,
:body => body,
:created_at_formatted => created_at.in_time_zone(SettingsDetail.first.timezone).strftime("%A, %B %e, %Y at %l:%M %P %Z"),
:recipients => recipient_data_for_broadcast
}
# Broadcast "new_message" event on "sms_channel"
WebsocketRails[:sms_channel].trigger(:new_message, message_data)
end
def send_now
job = SmsWorkers::StandardMessage.perform_async(id)
update_attributes(sent: Time.now.utc, job_id: job)
end
def schedule_job
job = SmsWorkers::ScheduledMessage.perform_at(scheduled_time.to_time.utc, id)
update_attribute(:job_id, job)
end
# Delete old job and reschedule new one
def reschedule_job(new_time)
unless new_time.nil?
delete_scheduled_job
update_attribute(:scheduled_time, new_time)
schedule_job
end
end
# Delete scheduled job from Sidekiq and clear job_id of message
def delete_scheduled_job
message = self
scheduled_queue = Sidekiq::ScheduledSet.new
# jid is a Sidekiq convention for job_id
# Find Sidekiq job with jid matching job_id of message and delete from queue
scheduled_queue.each do |job|
job.delete if job.jid == message.job_id
end
# Clear job_id from message record
update_attribute(:job_id, nil)
end
# Collect union of applicant and user recipients
# Returns an array of phone numbers
def collect_recipient_numbers
applicant_recipients.collect { |applicant| applicant.unformatted_number } + user_recipients.collect { |user| user.unformatted_number }
end
# On incoming reply to message, increment reply count by 1
def increment_reply_count
update_attribute(:reply_count, ((reply_count ||= 0) + 1))
end
def sent_to_applicant?(applicant_id)
applicant_recipients.pluck(:id).include?(applicant_id)
end
# Collect all applicant OR user recipient objects
# Note that either applicant_recipients or user_recipients will be blank
# i.e. a message is only sent to either applicants OR users
# Thus, regardless of recipient type, the method will return an array of like objects
# (either applicant objects or user objects)
def recipients
applicant_recipients.collect { |applicant| applicant } + user_recipients.collect { |user| user }
end
def recipient_data_for_broadcast
applicant_recipients.select(["id", "full_name"]) + user_recipients.select(["id", "full_name"])
end
# Return collection of thread root messages, i.e. where id == thread_root_id
def self.get_thread_roots
where("id = thread_root_id")
end
def self.applicant_messages
where("message_type = ? OR message_type = ?", "applicant_msg", "applicant_reg_check")
end
def self.sent
where("sent IS NOT NULL")
end
def self.scheduled
where("scheduled_time IS NOT NULL AND sent IS NULL")
end
def self.search_messages(query)
search_input = "%#{query}%".downcase
where(['LOWER(body) LIKE ?', search_input])
end
end
module SmsWorkers
# Standard message worker for messages generated by user from app
class StandardMessage
include Sidekiq::Worker
sidekiq_options :queue => :correspondence
def perform(message_id)
message = Message.find(message_id)
SmsWorkers.send_message(message)
end
end
# Scheduled message worker for scheduled messages generated by user from app
class ScheduledMessage
include Sidekiq::Worker
sidekiq_options :queue => :scheduled
def perform(message_id)
message = Message.find(message_id)
SmsWorkers.send_message(message)
# For scheduled message, only update messages and recipient attributes once message in sent at a future time
message.update_attribute(:sent, Time.now.utc)
message.transition_applicant_recipients
end
end
# Registration message worker for auto-generated responses to registration SMSs from applicants
class RegistrationMessage
include Sidekiq::Worker
sidekiq_options :queue => :registration
# Auto-generated message responses to registration SMSs only sent to one recipient
# Thus, they can be delivered directly to messaging service and bypass send_message method
def perform(message_body, number_to_message)
ref_id = message_body
SmsWorkers.deliver_to_messaging_service(message_body, number_to_message, ref_id)
end
end
# Add shared code to module's eigenclass so that it is available to all sms workers
class << self
def send_message(message)
# Find numbers to message based on recipient join table
numbers_to_message = message.collect_recipient_numbers
# Set message ref_id
ref_id = message.message_type.present? ? "message-#{message.message_type}-#{message.id}" : "message-#{message.id}"
# Messaging service has a max recipient limit of 32,000 per message
# Thus, send message to batches of 32,000 recipients
numbers_to_message.each_slice(32000) do |batch|
deliver_to_messaging_service(message.body, batch, ref_id)
end
end
def deliver_to_messaging_service(message_body, numbers_to_message, ref_id)
# Generate required payload for messaging service
payload = {
"LicenseKey" => MESSAGING_SERVICE_KEY,
"SMSRequests" => [{
"Message" => message_body,
"ShortCode" => SHORT_CODE,
"PhoneNumbers" => [*numbers_to_message],
"ReferenceID" => ref_id
}],
}
# Send request to messaging service
http = Net::HTTP.new(MESSAGING_SERVICE_DOMAIN, 80)
header = { "Content-Type" => "application/json" }
req = Net::HTTP::Post.new(MESSAGING_SERVICE_PATH, header)
req.body = payload.to_json
http.request(req)
end
end
end
require 'ostruct'
# Module for recursively pulling back a message thread
# i.e. a message, replies to the message, response messages to the reply, etc.
module MessageThreader
class << self
def gather_threads(thread_roots, opts={})
applicant, user = opts[:applicant], user = opts[:user]
threads = []
# Gather thread for each thread root message, most recent first
[*thread_roots].reverse_each do |thread_root|
# Level determines the level of nesting in the thread
# E.g. the thread root has level 0
# a reply to thread root has level 1
# a response to the reply has level 2
thread, level = [], 0
# Begin recursively gathering thread, starting with thread root message
gather_messages(thread_root, thread, level, applicant, user)
# After gathering thread, push into threads array
threads << thread
end
return threads.flatten
end
private
# gather_messages and gather_replies recursively call one another until
# all messages, replies to messages, response messages to replies, etc. are collected
def gather_messages(messages, thread, level, applicant, user)
[*messages].reverse_each do |message|
# Add message to thread
thread << OpenStruct.new(
:element => message,
:level => level,
:sender => message.user,
:recipients => message.recipients
)
# Pull back all replies if no applicant passed, otherwise only collect replies from given applicant
replies = applicant.present? ? message.replies.where(applicant_id: applicant.id) : ( user.present? ? message.replies.where(user_id: user.id) : message.replies )
# Gather replies to message which will have a nesting level one greater that parent message
gather_replies(replies, thread, level + 1, applicant, user) unless replies.empty?
end
end
def gather_replies(replies, thread, level, applicant, user)
[*replies].reverse_each do |reply|
# Add reply to thread
thread << OpenStruct.new(
:element => reply,
:level => level,
:sender => reply.user ? reply.user : reply.applicant
)
# If messages sent in response to reply, gather messages
responses = Message.where(reply_ref_id: reply.id)
# Gather messages in response to reply, which will have a nesting level one greater that parent reply
gather_messages(responses, thread, level + 1, applicant, user) unless responses.empty?
end
end
end
end
@g10host
Copy link

g10host commented Sep 21, 2015

Hello, I noticed that you made a sms api, could direct me to a supplier of SMS shortcode?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment