Time based conditions in voice application


Typical telephony systems process incoming call flows based on various time conditions, and customers often require control over these time ranges. The platform might also be a multi-tenant server hosting different virtual PBXes.

Common time-based call-handling scenarios include:

  • Night: Calls are routed to voicemail.
  • Morning, Lunchtime, Evening: Calls are forwarded to a mobile phone.
  • Business Hours: Calls follow standard call-flow logic.
  • Fridays: Working hours are shorter.
  • Saturdays: The company operates only until lunchtime.
  • Sundays: The company is closed; calls go to voicemail.
  • Public Holidays: Calls are forwarded to voicemail.

Some time ago, I developed the following Ruby classes and ActiveRecord migrations for flexible business time management. I am sharing them now, as they could be beneficial for anyone implementing time-based conditions in backend applications, not limited to telephony.

The Item class utilizes a polymorphic association that can point to any voice-related entity within a PBX (e.g., extension, voicemail, conference). This approach allows for the creation of highly flexible call flow building blocks. These blocks can be linked to various PBX entities, enabling customizable and dynamic call handling based on specific time conditions.

When the operational? method is triggered for a given business-time collection, it returns the item_id if the time conditions are met, or nil otherwise (indicating, for example, that the office is 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