Time based conditions in voice (AGI) application


Typical telephony system process incoming call-flow based on different times (and of course customer do want to have control on that, handle time-ranges; also whole platform could be an multi-tenant server with different virtual PBX’es):

  • During night call should be delivered to the voicemail;

  • In the morning, lunch time and evening should be forwarded to the cell phone;

  • During business hours, call should be processed following standard call-flow logic;

  • Friday’s time is shorter;

  • at Saturday company works only until lunch time;

  • and it’s closed at Sunday (calls should be routed to the voicemail);

  • Calls on public holidays should be forwarded to the voicemail too.

Long time ago I’ve created an following Ruby classes and ActiveRecord migrations for flexible business times management. And now sharing this, it could be useful for anyone who deal with time conditions in the backend apps (and that’s not only telephony-related).

Item is and kind polymorphic association that points to any voice-related “thing” in a PBX. I.e. to the extension, or voicemail, conference, whatever…

If we would trigger method operational? for given business-time collection, it should return “item_id” or nil if time conditions did not met (office closed?).

Tests

1.9.3-p327 :001 > Campaign.last.business_time.operational?

Campaign Load (0.1ms)  SELECT "campaigns".* FROM "campaigns" ORDER BY "campaigns"."id" DESC LIMIT 1
BusinessTime Load (0.2ms)  SELECT "business_times".* FROM "business_times" WHERE "business_times"."id" = 1 LIMIT 1
BusinessTimeMember Load (0.3ms)  SELECT "business_time_members".* FROM "business_time_members" WHERE "business_time_members"."business_time_id" = 1 ORDER BY
  business_time_id,
  day_of_month desc,
  month desc,
  year desc,
  weekday desc,
  time_from desc,
  time_to asc

=> 0

Migrations

create_table :business_time_members do |t|
	t.string   :name, :limit => 100
	t.integer  :business_time_id
	t.time     :time_from, :default => '2000-01-01 00:00:00'
	t.time     :time_to, :default => '2000-01-01 23:59:59'
	t.integer  :weekday      # 0 - sunday, 1 - monday, 2 -tuesday, ...
	t.integer  :day_of_month # 1-31
	t.integer  :month        # 1-12
	t.integer  :year         # 2012
	t.integer  :item_id, :default => 0      # item with action!
	t.timestamps
end

create_table :business_times do |t|
	t.string  :name, :limit => 100
	t.timestamps
end

execute "INSERT INTO `business_times` (`id`, `name`)
	VALUES (1, 'Call Mon-Fri 9 to 21; Sat, Sun 11 to 19')"

# Always closed!
# It is a default condition with lowest priority and will be executed it other conditions did not met.
# BusinessTimeMember.create :business_time => business_time, :item => closed_action
execute "INSERT INTO `business_time_members` (`id`, `business_time_id`, `item_id`) VALUES (?, 1, NULL)"
# Make calls on business days (Monday to Friday) from 9:00 to 21:00,
# on weekends (Sunday and Saturday) from 11:00 to 19:00
# 0 - Sun, 6 - Sat
0.upto(6) do |day|
	time_from, time_to = if day == 0 || day == 6 then
		#[Time.parse("11:00:00"), Time.parse("18:59:59")]
		[Time.parse("00:00:00"), Time.parse("23:59:59")]
	else
		#[Time.parse("9:00:00"), Time.parse("20:59:59")]
		[Time.parse("00:00:00"), Time.parse("23:59:59")]
	end

	# item_id 0 = continue dialplan!
	# BusinessTimeMember.create :business_time => business_time,
	#   :item_id => 0, :weekday => day,
	#   :time_from => "6:00", :time_to => "17:00"
	execute "INSERT INTO `business_time_members`
		(`id`, `business_time_id`, `weekday`, `time_from`, `time_to`, `item_id`)
		VALUES (?, 1, #{day}, '#{time_from}', '#{time_to}', 0)"
end

Models

class BusinessTimeMember < ActiveRecord::Base
  #validations
  validates_presence_of :business_time_id
  validates_presence_of :item_id
  # 0 (zero) means to continue dialplan after check!
  validates_numericality_of :item_id #, :greater_than => 0

  # relations
  belongs_to :business_time
  belongs_to :item
end

class BusinessTime < ActiveRecord::Base
  has_many :business_time_members, :dependent => :destroy
  has_many :items, :as => :routable

  has_many :soho_pbxes

  # return false if no records found,
  # 0 if specified - means "continue" dialplan,
  # or item_id
  def operational?(details = { :datetime => Time.now.utc })
    d = details[:datetime]

    =begin
      if self.timezone
        begin
          tz = TZInfo::Timezone.get(self.timezone)
          local = tz.utc_to_local(d)
          puts "got timezone #{self.timezone} - utc #{d}, local #{local}"
          d = local
        rescue
          puts "got exception for timezone #{self.timezone}"
        end
      end
    =end

    tm = BusinessTimeMember.find(
      :all,
      :conditions => { :business_time_id => id },
      :order => "
        business_time_id,
        day_of_month desc,
        month desc,
        year desc,
        weekday desc,
        time_from desc,
        time_to asc
      "
    )

    return false if tm.empty?
    tm.each { |t|
      tf = t[:time_from] ? Time.parse(t[:time_from].strftime("%H:%M:%S")) : Time.parse("00:00:00")
      tt = t[:time_to] ? Time.parse(t[:time_to].strftime("%H:%M:%S")) : Time.parse("23:59:59")
      tw = t[:weekday] ? t[:weekday].to_i : d.wday
      td = t[:day_of_month] ? t[:day_of_month].to_i : d.day
      tm = t[:month] ? t[:month].to_i : d.month
      ty = t[:year] ? t[:year].to_i : d.year

      if ty == d.year && tm == d.month && td == d.day && tw == d.wday && d >= tf && d <= tt then
        return t.item_id
      end
    }
    return false
  end
end