#!/usr/pkg/bin/perl
################################################################################
# Version: 0.1
# File: directory.agi
#
# The purpose of this agi script is to provide an online telephone directory
# that can be easily accessed using the numbers on the phone dial pad.
#
#
# You select entries by spelling out the name of the person you want to contact 
# using the phone dial pad. Now this is normally pretty labourious so the script 
# provides a few shortcuts to make things easier. 
#
# The best way to illustrate this is by example:
# Say you want to phone John Smith:
# -  You would start by typing 5, this would find all entries that start with 
# j,k or l.
# - Next you would type 6 which would narrow down the selection to all entries
# starting with either "j", "k" or "l" followed by either "m", "n" or "o".
# - You continue to spell out the name in this fashion (4 = gHi, 6 = mnO etc)
# until either a distinct match is found in the direcotry or the number of 
# matches is 9 or less. 
#
# If a distinct match is found the number associated with the name is returned 
# and can be dialed.
#
# If the number of matches is 9 or less you can have an IVR menu containing the
# matching names built on the fly and you will be prompted to select a name
# (e.g. Press 1 for John Smith, Press 2 for John Doe etc). Once a name is 
# selected the number associated with the name is returned and can be dialed.
#
#
# Now you might think that this is still pretty laborious but in fact you
# usually only have to spell out the first few letter of the first name and the
# last name to get a good match.
#
#
#
# Other feature include:
# - Being able to jump to the last name without having to finish spelling out the
# first name (i.e. Press 0 to skip to the last name)
# - Multiple numbers can be associated with a name. In this case you will be 
# prompted to select which number you wanted returned for dialing (e.g. Press 1 
# for Home, Press 2 for Business, etc)
# - Undo last typed entry in case you misstyped something
# - Wildcard matching (Press 1 to match any letter) 
# - IVR menus built on the fly so you do not need to prerecord anything
# - IVR menus cached (the more you use it the quicker it gets)
# - Returns the selected number in the variable "DIRNUMBER"
# 
#
#
# So now that you are interrested the next question is how do you get this thing 
# up and running? 
#
# First off you need the following:
# - Festival
# - Perl
# - The Perl module Asterisk::AGI
# 
# 
# Then just follow the next couple of steps:
# 1). Place this file in the Asterisk agi-bin directory (/usr/share/asterisk/agi-bin)
# and check the section "Check the following and adjust to your local environment"
# to make sure it fits with your needs
#
# 2). Create an extension something like this: 
#    exten => 100,1,AGI,directory.agi|Phonebook}
#    exten => 100,2,GotoIf($["${DIRNUMBER}" = ""]?3:4)
#    exten => 100,3,Hangup
#    exten => 100,4,Dial(SIP/${DIRNUMBER}@GW-PSTN,30)
#
# 3). Create a phone directory file called "Phonebook" and place it in 
# the directory /usr/share/asterisk/directory/.
# 
# The phone directory conisist of one Heading Line and multiple Entry Lines
# 
# The "Heading Line" has the following format:
#
#    First Name<TAB>Last Name<TAB>Phone Location 1<TAB>Phone Location 2<TAB>...
#
# where by the <TAB> must be a real tab character and there can be up to a
# maximum of 9 phone locations
#
# The "Entry Lines" contain the actual data for the heading line columns also
# seperated by <TAB> characters.
#
#
# Sounds complicated but the following example should help you understand: 
# 
# First Name<TAB>Last Name<TAB>Company<TAB>Business Phone<TAB>Home Phone<TAB>Mobile Phone
# Remko<TAB>Golden<TAB><TAB>+49 (89) 145456<TAB><TAB>
# Peter<TAB>Klein<TAB><TAB><TAB>0221 87654230<TAB>
# Claudia<TAB>Thompson<TAB><TAB>052 52586345<TAB>069 8765478<TAB>0171 65443897	
# 
# Of course you can always also do what I did and that is to use the Microsoft
# Outlook export feature.  
#
#
# To Do:
# - Find undo bug. Sometines after an undo the search gets confused and returns
# the wrong results.
# - Allow skipping between first, last and company names. The handling is not that 
# clean and you cannot switch back and forth.
# - Currently all the IVR prompts are build on the fly and cached. It would be 
# better to cat snippets together and use those. Would be simple if STREAM FILE
# could take a list of files instead of just one.   
# - Cleanup the Perl code.
# - Added ability to prerecord names as some are hard to understand.
#
#
# Copyright (C) 2006 C. de Souza ( m.list at yahoo.de )
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
################################################################################
use Asterisk::AGI;
use File::Basename;
use Digest::MD5 qw(md5_hex);

################################################################################
# Check the following and adjust to your local environment
################################################################################
# location of the phone directory files
local $DIRECTORYDIR="/usr/share/asterisk/directory/";

# location of the wave file cache and owrking directory
local $SOUNDDIR = "/var/lib/asterisk/festivalcache/";

# festival text2wave location 
local $T2WDIR= "/usr/bin/";

# International Country Code
local $INTLCOUNTRYCODE = "\\+49";

# International Dialing Code
local $INTLDAILINGCODE = "00";

# National Dialing Code
local $NATIONALDAILINGCODE = "0";


################################################################################
# Local stuff, should not require changing
################################################################################
local $hitCnt = 0;

local $FLSEPERATOR = "~";

local %directory;
local %directoryOrig;

local $searchstr = "";
local $searchstrOrig = "";

local @numberLabels = ();

local $MODE_COMMAND = "command";
local $MODE_ERROR = "error";
local $MODE_EXIT = "exit";
local $MODE_FOUND = "found";
local $MODE_SEARCHING = "searching";
local $mode = $MODE_SEARCHING;

#my $debug = 0;

my $SUBNAME = "MAIN";
my %input;

################################################################################
# Sub debug
################################################################################
sub debug {
  
  my $string = shift;
  my $level = shift || 3;

  $AGI->verbose($string, $level)   if ( $debug );
  
  return(0);

} # sub debug


################################################################################
# sub getTTSFilename 
################################################################################
sub getTTSFilename {
  
  my ( $text ) = @_;

  my $hash = md5_hex($text);
  my $wavefile = "$SOUNDDIR"."tts-diirectory-$hash.wav";
  
  unless( -f $wavefile ) {
    open( fileOUT, ">$SOUNDDIR"."say-text-$hash.txt" );
    print fileOUT "$text";
    close( fileOUT );
    my $execf=$T2WDIR."text2wave $SOUNDDIR"."say-text-$hash.txt -F 8000 -o $wavefile";
    system( $execf );
    unlink( $SOUNDDIR."say-text-$hash.txt" );
  }

  return "$SOUNDDIR".basename($wavefile,".wav");
} # sub getTTSFilename 

################################################################################
# sub performSearch {
################################################################################
sub performSearch {
  
  my( $digits, $mode, $hitCnt, $searchstr, $searchstrOrig ) = @_;

  my $SUBNAME = "performSearch"; 

  my $digit = "";

  $AGI->verbose( "$SUBNAME: Entering", 1 );

  while(( length( $digits ) > 0    ) &&
	( $mode eq $MODE_SEARCHING )    ) {
    
    $digit = substr( $digits, 0, 1 );
    $digits = substr( $digits, 1, length( $digits ) - 1 );
    
  switch: {
      if( $digit eq "*" ) 
	{ $mode = $MODE_COMMAND;
	  last switch}
      if( $digit == 0 ) 
	{ $searchstr .= ".*?~"; # use ? for minimal match i.e. first "$FLSEPERATOR"
	  $searchstrOrig .= $digit;
	  last switch}
      if( $digit == 1 ) 
	{ $searchstr .= ".";
	  last switch}
      if( $digit == 2 ) 
	{ $searchstr .= "[abc]";
	  $searchstrOrig .= $digit;
	  last switch}
      if( $digit == 3 ) 
	{ $searchstr .= "[def]";
	  $searchstrOrig .= $digit;
	  last switch}
      if( $digit == 4 ) 
	{ $searchstr .= "[ghi]";
	  $searchstrOrig .= $digit;
	  last switch}
      if( $digit == 5 ) 
	{ $searchstr .= "[jkl]";
	  $searchstrOrig .= $digit;
	  last switch}
      if( $digit == 6 ) 
	{ $searchstr .= "[mno]";
	  $searchstrOrig .= $digit;
	  last switch}
      if( $digit == 7 ) 
	{ $searchstr .= "[pqrs]";
	  $searchstrOrig .= $digit;
	  last switch}
      if( $digit == 8 ) 
	{ $searchstr .= "[tuv]";
	  $searchstrOrig .= $digit;
	  last switch}
      if( $digit == 9 ) 
	{ $searchstr .= "[wxyz]";
	  $searchstrOrig .= $digit;
	  last switch}
    } # switch
  
  }
  
  if( $mode eq $MODE_SEARCHING ) {
    my $name = "";
    foreach $name ( keys %directory ) {
      if( $name  !~ /^$searchstr/i ) {
	delete $directory{ $name };
	$hitCnt--;
      }
    } # foreach $name
  } # if( $mode eq $MODE_SEARCHING 

  # Output some status info for debug
  #foreach $name ( keys %directory ) {
  #  $AGI->verbose( "$SUBNAME: Found<$name>", 1 );
  #} # foreach $name
  $AGI->verbose( "$SUBNAME: mode<$mode>" , 1 );
  $AGI->verbose( "$SUBNAME: searchstr<$searchstr>" , 1 );
  $AGI->verbose( "$SUBNAME: searchstrOrig<$searchstrOrig>" , 1 );
  $AGI->verbose( "$SUBNAME: hitCnt<$hitCnt>", 1 );
  
  return $mode, $hitCnt, $searchstr, $searchstrOrig;
  
} # sub performSearch {

################################################################################
# sub loadFile
################################################################################
sub loadFile {
  
  my( $DIRECTORYDIR, $FLSEPERATOR, $name ) = @_;

  my $SUBNAME = "loadFile";
  my $hitCnt = 0;
  my $line = "";
  my $flname = "";
  $AGI->verbose( "$SUBNAME: Entering", 1 );

  open( FILE, $DIRECTORYDIR.$name ); # or die "Cannot open '$FILENAME': $!";
  
  while(  $line = <FILE> ) {
    chop( $line );
    chop( $line );  # seem to have a ^M in as well
    #print "line<$line>\n";
    
    my ( $fname, $lname, $bname, $phoneNumbers ) = split /\t/, $line, 4;
    #print "fname<$fname>\tlname<$lname>\tbname<$bname>\n";
    
    $flname = "";
    
    $flname .= $fname.$FLSEPERATOR.$lname.$FLSEPERATOR.$bname;
    
    #print "flname<$flname>\tphone<$phoneNumbers>\n";
    

    if(( $phoneNumbers ne ""                          ) &&
       ( $flname       ne "$FLSEPERATOR.$FLSEPERATOR" )     ) {
      if( @numberLabels == 0 ) { # deal with labels
	( @numberLabels ) = split /\t/, $phoneNumbers, 9;
	
      } else { # deal with entries
	if( $directory{ $flname } ){
	  debug( "$SUBNAME: Duplicate entry <$flname>", 1 );
	} else {
	  $hitCnt++;
	  $directory{ $flname } = $phoneNumbers;
	  $directoryOrig{ $flname } = $phoneNumbers;
	}
      } #if( $hitCnt
      
    } else {
      debug( "$SUBNAME: No phone number(s) for <$flname>", 1 );
    } #if( $phoneNumbers
  } #while

  close( FILE );

  #print "hitCnt<$hitCnt>\n";
  
  #foreach $name ( keys %directory ) {
  #  print "Loaded <$name>\n";
  #}

  return $hitCnt;

} # sub loadFile

################################################################################
# sub cmdSelectContactFromMenu {
################################################################################
sub cmdSelectContactFromMenu {

  my( $mode, $hitCnt ) = @_;

  my $SUBNAME = "cmdSelectContactFromMenu";
  my $contactMenu = "";
  my $escapeDigits = "*";
  my $menuPos = 0;
  my $fname = "";
  my $lname = "";
  my $inputKey = "";

  $AGI->verbose( "$SUBNAME: Entering", 1 );
  
  if( $hitCnt > 9 ) {
    $AGI->verbose( "$SUBNAME: hitCnt > 9", 1);
    $AGI->stream_file( getTTSFilename( "$hitCnt" )); 
    $AGI->stream_file( getTTSFilename( "names is too may to list" )); 

  } elsif( $hitCnt == 0 ) {
    $AGI->verbose( "$SUBNAME: hitCnt == 0", 1);
    $AGI->stream_file( getTTSFilename( "There are no names in the list" )); 

  } else {
    my $name = "";
    foreach $name ( sort keys %directory ) {
      $name =~ s/~/ /g; # needs to replace with $FLSEPERATOR
      $contactMenu .= "Press " .  ++$menuPos . " to select $name. ";
      $escapeDigits .= "$menuPos";
    } # foreach $name
    
    $AGI->verbose( "$SUBNAME: <$escapeDigits>$contactMenu ", 1);

    my $dtmfInput = 0;
    while( $dtmfInput == 0 ) {
      $dtmfInput = $AGI->stream_file( getTTSFilename( "$contactMenu" ), "$escapeDigits" ); 
      ( $dtmfInput > 0 ) or 
	$dtmfInput = $AGI->stream_file( getTTSFilename( "Press star to exit"), "$escapeDigits" ); 
      
    } # while
    
    if( $dtmfInput < 0 ) { # ERROR!
      $mode = $MODE_EXIT;
    
    } else {
      
      $inputKey = chr( $dtmfInput );
    
      $AGI->verbose( "$SUBNAME: inputKey = <$inputKey>", 1 );

      if( $inputKey ne "*" ) {
	$menuPos = 0;
	foreach $name ( sort keys %directory ) {
	  if( ++$menuPos != $inputKey ) {
	    delete $directory{ $name };
	    $hitCnt--;
	    #print "deleting $name ht:$hitCnt mp:$menuPos \n";
	  }
	} # foreach $name
      } # if( $inputKey ne "*"
    } # if( $dtmfInput < 0
  } # if( $hitCnt

  #print $hitCnt;
  return $mode, $hitCnt;

} # sub cmdSelectContactFromMenu 

################################################################################
# sub cmdUndoLastSearch {
################################################################################
sub cmdUndoLastSearch {

  my( $searchstrOrig, $mode, $hitCnt, $searchstr ) = @_;

  my $SUBNAME = "cmdUndoLastSerach";
  my $lastInput = "";
  my $tmpSearchStrOrig = "";

  $AGI->verbose( "$SUBNAME: Entering", 1 );
  
  if( $searchstrOrig ) {
    # Reset hit count and search str as we will build this from the updated original search str
    $hitCnt = 0;
    $searchstr = "";

    # Get last input
    $lastInput = substr( $searchstrOrig, length( $searchstrOrig ) - 1, 1);
    $AGI->verbose( "$SUBNAME: lastInput <$lastInput>", 1 );

    # Chop last input off the end - could us chop()  
    chop( $searchstrOrig );
    $AGI->verbose( "$SUBNAME: searchstrOrig <$searchstrOrig>", 1 );
    
    # Overwrite re-init directory, should be okay to overwrite
    my $key = "";
    foreach $key ( keys %directoryOrig ) {
      $directory{ $key } = $directoryOrig{ $key };
      $hitCnt++;
    }
    
    # Reprocess search
    # We have to mess with the mode here as we are in command mode but need to be in 
    # search mode for the call to perform search - not nice
    ( $mode, $hitCnt, $searchstr, $searchStrOrig ) = 
      performSearch( $searchstrOrig, "$MODE_SEARCHING", $hitCnt, $searchstr, "" );
    $mode = $MODE_COMMAND;

    $AGI->stream_file( getTTSFilename( "Last search input, $lastInput, undone" ) ); 

  } else {
    $AGI->stream_file( getTTSFilename( "Search input empty, nothing to undo." ) );
    
  } # if( $searchstrOrig 

  return $searchstrOrig, $mode, $hitCnt, $searchstr;

} # sub cmdUndoLastSearch 

################################################################################
# sub cmdReviewSearch
################################################################################
sub cmdReviewSearch {

  my( $searchstrOrig ) = @_;

  my $SUBNAME = "cmdReviewSerach";

  $AGI->verbose( "$SUBNAME: Entering", 1 );
  
  if( $searchstrOrig ) {
    $AGI->stream_file( getTTSFilename( "Search input entered so far is $searchstrOrig. " ) );

  } else {
    $AGI->stream_file( getTTSFilename( "Search input empty." ) );
    
  } # if( $searchstrOrig 

  return $searchstrOrig, $mode, $hitCnt, $searchstr;

} # sub cmdReviewSearch 

################################################################################
# processTargetNumber {
################################################################################
sub processTargetNumber {

  my( $mode, $targetName, $targetNumber ) = @_;
      
  my $SUBNAME = "processTargetNumber";

  $AGI->verbose( "$SUBNAME: Entering", 1 );

  $AGI->verbose( "$SUBNAME: Target number before cleanup <$targetNumber>", 1 );

  # expect number in format or similar
  # - $INTLDAILINGCODE (area-code) local-number 
  # - $NATIONALDAILINGCODE area-code local-number
  # - local number
  $targetNumber =~ s/\s//g; 
  $targetNumber =~ s/$INTLCOUNTRYCODE/$NATIONALDAILINGCODE/;
  $targetNumber =~ s/\+/$INTLDAILINGCODE/;
  $targetNumber =~ s/\D//g;

  $AGI->verbose( "$SUBNAME: Target number after cleanup <$targetNumber>", 1 );

  $AGI->verbose( "$SUBNAME: Dialing $targetName on ($targetNumber)", 1 );
  
  $AGI->stream_file( getTTSFilename( "Dialing $targetName on $targetNumber" ) );
  
  $AGI->set_variable( 'DIRNUMBER', "$targetNumber" );

  return $mode;

} # sub processTargetNumber


###############################################################################
# Main
###############################################################################

#
# Initialise Asterisk AGI
#
$AGI = new Asterisk::AGI;

%input = $AGI->ReadParse();
;foreach $i (sort keys %input) {
;  $AGI->verbose( " -- $i = $input{ $i }", 4 );
;}

#
# Load the phone direcotry
#
my $directoryName = $ARGV[0];

$hitCnt = loadFile( $DIRECTORYDIR, $FLSEPERATOR, $directoryName );

if( $hitCnt == 0 ) {
  $mode = $MODE_EXIT;
  $AGI->verbose( "There was a problem opening the directory", 1);
  $AGI->stream_file( getTTSFilename( "There was a problem opening the directory" )); 
  $AGI->stream_file( getTTSFilename( "Please contact the system administrator" ));
}


#
# Enter the main processing loop
#
while(( $mode eq $MODE_SEARCHING ) ||
      ( $mode eq $MODE_COMMAND   )    ) { 

  # Return dynamic menu
  my $inputKey = "";
  my $validInput = ""; # False

  if( $mode eq $MODE_SEARCHING ) {
    $AGI->verbose( "$SUBNAME: Search Mode", 1);
    
    # $AGI->stream_file( getTTSFilename( "$hitCnt contacts listed" ) );
 
    if( $hitCnt == 0) {
      $inputKey = $AGI->get_data( getTTSFilename( "Zero contacts listed. Press the star key to access the undo last search input function" )); 
    } else {
      $inputKey = $AGI->get_data( getTTSFilename( "$hitCnt contacts listed. Spell out the name of the contact by pressing the numbers corresponding to the letters, press 0 to skip to the last name, press 1 to match any letter. Press star for more options" )); 
    }
      
    
    if( $inputKey == -1  ) { # ERROR!
      $mode = $MODE_EXIT;
    } elsif( $inputKey ne "" ) {
      $validInput = ! $validInput; # True
    }

    if( $validInput ) {     # Process the input
      $validInput = ""; # Reset to False

      ( $mode, $hitCnt, $searchstr, $searchstrOrig ) = 
	performSearch( $inputKey, $mode, $hitCnt, $searchstr, $searchstrOrig );
    
    } # if( $validInput

  } else { #MODE_COMMAND
    $AGI->verbose( "$SUBNAME: Command Mode", 1);

    $inputKey =
      $AGI->get_data( getTTSFilename( "Press 1 to list contacts. " .
				      "Press 2 to undo last search input. " .
				      "Press 3 to review search input. " .
				      "Press 9 to continue searching. " .
				      "Press star to exit. " ), 2000, 1 );

    if( $inputKey == -1  ) { # ERROR!
      $mode = $MODE_EXIT;
    } elsif( $inputKey ne "" ) {
      $validInput = ! $validInput; # True
    }

    if( $validInput ) {     # Process the input
      $validInput = ""; # Reset to False

    switch: {
	if( $inputKey eq "*" ) 
	  { $mode = $MODE_EXIT;
	    last switch}
	if( $inputKey == 1 ) 
	  { ( $mode, $hitCnt ) = cmdSelectContactFromMenu( $mode, $hitCnt );
	    last switch}
	if( $inputKey == 2 ) 
	  { ( $searchstrOrig, $mode, $hitCnt, $searchstr ) = 
	      cmdUndoLastSearch( $searchstrOrig, $mode, $hitCnt, $searchstr  );
	    last switch}
	if( $inputKey == 3 ) 
	  { cmdReviewSearch( $searchstrOrig );
	    last switch}
	if( $inputKey == 9 ) 
	  { $mode = $MODE_SEARCHING;
	    last switch}
      } # switch
      
    } # if( $validInput

  } # if( $mode eq $MODE_SEARCHING

  if(( $mode eq $MODE_SEARCHING ) ||
     ( $mode eq $MODE_COMMAND   )    ){
    # Check if we found what we want or nothing left
    if( $hitCnt == 1 ) {
      $mode = $MODE_FOUND;
    }
  }
  
} # while(


if( $mode eq $MODE_FOUND ) {

  #
  # Determine number to dial
  #
  my $targetName = "";
  my $targetNumber = ""; 
  my @targetNumbers;

  # Get array of possible numbers to dial, should only be one contact to take
  my $name = "";
  foreach $name ( keys %directory ) {
    $targetName = $name;
    $targetName =~ s/~/ /g; #need to replace with FLSEPERATOR
    ( @targetNumbers ) = split /\t/, $directory{ $name }, 9;
    
  } # foreach $name
  
  # Match the numbers to the number labels in case we need to prompt
  my $numberPosCnt = 0;
  my @numberMenu = ();
  my $escapeDigits = "*";

  my $number = "";
  foreach $number ( @targetNumbers ) {
    $numberPosCnt++;
    
    if( $number ne "" ) { # Create a menu entry
      $targetNumber = $number;
      $escapeDigits .= "$numberPosCnt";
      $numberMenu[ @numberMenu ] = 
	"Press $numberPosCnt to dial $numberLabels[ $numberPosCnt - 1 ]. ";
    }
  } #foreach $number
  
  $numberMenu[ @numberMenu ] = "Press * to exit. ";
  
  $AGI->verbose( "$SUBNAME: numberMenu <@numberMenu>", 1);

  if( @numberMenu > 2 ) {  # Multiple numbers, prompt
    $mode = $MODE_SEARCHING;
    my $digit = 0;

    while( $mode eq $MODE_SEARCHING ) { # keep prompting till we get valid input
      my $dfmtInput = 0;
      my $prompt = "";

      $AGI->stream_file( getTTSFilename( "$targetName has multiple numbers listed. " ) );

      foreach $prompt ( @numberMenu ) { # cycle through the prompts
	($dtmfInput > 1 ) or 
	  $dtmfInput = $AGI->stream_file( getTTSFilename( "$prompt" ), "$escapeDigits" );
	$AGI->verbose( "$SUBNAME: Chosen number<$dtmfInput>", 1 );
      } # foreach
      
      if( $dtmfInput < 0 ) { # ERROR!
	$mode = $MODE_EXIT;
  
      } elsif( $dtmfInput > 0 ) { # valid input
	$mode = $MODE_FOUND;
	$digit = chr( $dtmfInput );
      }
      
    } # while 

    if( $digit eq "*" ) {
      $mode = $MODE_EXIT;
    } else {
      $targetNumber =  $targetNumbers[ $digit - 1 ];
    }
  } # if( @numberMenu

  if( $mode eq $MODE_FOUND ) {
    $mode = processTargetNumber( $mode, $targetName, $targetNumber ); 
  }
} #( $mode eq $MODE_FOUND 
