Hi all,

Lebbeous Fogle-Weekley and Joe Atzberger, Equinox's newest developers, have
starting building components to add telephony support to Evergreen using the
new Action/Trigger event infrastructure.  For those that are unfamiliar with
Action/Trigger, it's a generic mechanism by which "hooks" are added to the
code and different types of reactors can be attached to respond when a given
hook is encountered.  These will ultimately be used to implement a wide
variety of Evergreen behavior, the most notable for now, though, are the
different types of notices -- overdue notices, courtesy notices, billing
notices, hold notices, etc.  If you can think of a reason to send an email,
it will do it.

One of the key benefits of Action/Trigger is that you can define different
reactors.  The most widely used reactor thus far has been SendEmail.  With
telephony comes a new reactor called "AstCall".  It's an Asterisk reactor
responsible for generating asterisk call files based on configured templates
and run-time data.  In addition to the reactor, there is a mediator process
that shuffles the call files to and from the asterisk server for delivering
new notices and processing completed notices (to check for errors, etc.).
Attached is a patch which provides some of the initial pieces, including the
reactor, the mediator, configuration settings, Action/Trigger DB seed data,
and sample asterisk extensions configuration.

This patch does not represent a completed project, though outbound calls do
work with the sample audio files.  (We have audio files for testing if
anyone wants them, complete with  Joe's dulcet tones.)  There is still much
to do and much of the code in this patch will evolve, but before we get too
embroiled, I wanted to pass this on for general review.

Let us know what you think.

Thanks!

-b

-- 
Bill Erickson
| VP, Software Development & Integration
| Equinox Software, Inc. / The Evergreen Experts
| phone: 877-OPEN-ILS (673-6457)
| email: erick...@esilibrary.com
| web: http://esilibrary.com

Please join us for the Evergreen 2010 International Conference, April 20-23,
2010 at the Amway Grand Hotel and Convention Center, Grand Rapids, Michigan.
http://www.evergreen2010.org/
diff --git Open-ILS/examples/asterisk/extensions.conf.example Open-ILS/examples/asterisk/extensions.conf.example
new file mode 100644
index 0000000..d74befa
--- /dev/null
+++ Open-ILS/examples/asterisk/extensions.conf.example
@@ -0,0 +1,42 @@
+; Sample Asterisk configuration
+; To use, include this dialplan in your extensions.conf file and dialplan reload.
+; Note the explicitly numbered line sequences.  This makes it hard to edit or
+; and new lines.  Remember to preserve sequentiality and Goto integrity.
+;
+; *** You also have to have the sample-*.gsm files in your
+; /var/lib/asterisk/sounds directory, and you have to have Festival installed
+; and playing nice with Asterisk.
+;
+; First the ${ ... } variables and functions are evaluated and substituted.
+; Then the  $[ ... ] expressions are evaluated and substituted.
+
+[overdue-test]
+exten => s,1,Verbose(titlestring: ${titlestring})
+exten => s,n,Answer()
+; exten => s,n,Set(LOOP=${IF($[foo${x} = "foo"]?1:0${LOOP})})   ;  [${foo${LOOP} = "foo"}?1:0${LOOP}])  ; Buggy trinary ops
+exten => s,n,Set(LOOP=0${LOOP})                 ; Default will be zero if undefined
+exten => s,n,Verbose(LOOP top: ${LOOP})
+exten => s,n,Goto(10)
+exten => s,10,Wait(1)                           ; The beginning of loop.
+exten => s,11,Playback(sample-greeting)
+exten => s,12,SayDigits(${items})
+exten => s,13,GotoIf($[0${items} > 1]?20:30)    ; spaces are important here
+exten => s,20,Playback(sample-overdue-plural)
+exten => s,21,Festival(The items titles are)
+exten => s,22,Goto(40)
+exten => s,30,Playback(sample-overdue-singular)
+exten => s,31,Festival(Your items title is)
+exten => s,32,Goto(40)
+exten => s,40,Wait(1)
+exten => s,41,Festival(${titlestring})
+exten => s,42,Wait(1)
+exten => s,43,Playback(sample-thanks)
+exten => s,44,Set(LOOP=$[${LOOP}-1])            ; LOOP decrements
+exten => s,45,Verbose(LOOP bottom: ${LOOP})
+exten => s,46,GotoIf($[0${LOOP} >= 0]?10:48)    ; spaces are important here, we loop on zero because we already did decrement
+; exten => s,47,Verbose(REASON: ${REASON})
+exten => s,48,Hangup()
+
+exten => failed,1,Verbose(FAILED REASON: ${REASON})
+exten => failed,n,Verbose(CALLFILENAME: ${CALLFILENAME})
+
diff --git Open-ILS/examples/asterisk/mediator/eg-mediator.conf Open-ILS/examples/asterisk/mediator/eg-mediator.conf
new file mode 100644
index 0000000..676400b
--- /dev/null
+++ Open-ILS/examples/asterisk/mediator/eg-mediator.conf
@@ -0,0 +1,5 @@
+spool_path /var/spool/asterisk/outgoing
+staging_path /var/tmp
+port 10080
+owner asterisk
+group asterisk
diff --git Open-ILS/examples/asterisk/mediator/server.pl Open-ILS/examples/asterisk/mediator/server.pl
new file mode 100644
index 0000000..8c8adf5
--- /dev/null
+++ Open-ILS/examples/asterisk/mediator/server.pl
@@ -0,0 +1,137 @@
+#!/usr/bin/perl -w
+#
+# Copyright (C) 2009 Equinox Software, Inc.
+# Author: Lebbeous Fogle-Weekley
+# Author: Joe Atzberger
+#
+# License:
+#
+# 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.
+#
+# Overview:
+#
+#   This script is to be used on an asterisk server as an RPC::XML 
+#   daemon targeted by Evergreen.
+#
+# Configuration:
+#
+#   See the eg-mediator.conf and extensions.conf.example files.
+#
+# Usage:
+#
+#   perl server.pl -c /path/to/eg-mediator.conf
+#
+# TODO: 
+#
+# ~ Option to archive (/etc/asterisk/spool/outgoing_really_done) instead of delete?
+# ~ Serve retrieval of done files.
+# ~ Accept globby prefix for filtering files to be retrieved.
+# ~ init.d startup/shutdown/status script.
+# ~ More docs.
+# ~ perldoc/POD
+# - command line usage and --help
+#
+
+
+use RPC::XML::Server;
+use Config::General qw/ParseConfig/;
+use Getopt::Std;
+use File::Basename qw/basename/;
+use Sys::Syslog qw/:standard :macros/;
+
+our %config;
+our %opts = (c => "/etc/eg-mediator.conf");
+our $last_n = 0;
+
+sub load_config {
+    %config = ParseConfig($opts{c});
+
+    # validate
+    foreach my $opt (qw/staging_path spool_path/) {
+        if (not -d $config{$opt}) {
+            warn $config{$opt} . " ($opt): no such directory";
+            return;
+        }
+    }
+
+    if ($config{port} < 1 || $config{port} > 65535) {
+        warn $config{port} . ": not a valid port number";
+        return;
+    }
+
+    if ((!($config{owner} = getpwnam($config{owner})) > 0)) {
+        warn $config{owner} . ": invalid owner";
+        return;
+    }
+
+    if ((!($config{group} = getgrnam($config{group})) > 0)) {
+        warn $config{group} . ": invalid group";
+        return;
+    }
+}
+
+sub inject {
+    my ($data, $timestamp) = @_;
+    my $filename_fragment = sprintf("%d-%d.call", time, $last_n++);
+    my $filename = $config{staging_path} . "/" . $filename_fragment;
+    my $spooled_filename = $config{spool_path} . "/" . $filename_fragment;
+
+    my $failure = sub {
+        syslog LOG_ERR, $_[0];
+
+        return new RPC::XML::fault(
+            faultCode => 500,
+            faultString => $_[0])
+    };
+
+    $data .= "; added by inject() in the mediator\n";
+    $data .= "Set: callfilename=$filename_fragment\n";
+
+    open FH, ">$filename" or return &$failure("$filename: $!");
+    print FH $data or return &$failure("error writing data to $filename: $!");
+    close FH or return &$failure("$filename: $!");
+
+    chown($config{owner}, $config{group}, $filename) or
+        return &$failure(
+            "error changing $filename to $config{owner}:$config{group}: $!"
+        );
+
+    if ($timestamp > 0) {
+        utime $timestamp, $timestamp, $filename or
+            return &$failure("error utime'ing $filename to $timestamp: $!");
+    }
+
+    rename $filename, $spooled_filename or
+        return &$failure("rename $filename, $spooled_filename: $!");
+
+    syslog LOG_NOTICE, "Spooled $spooled_filename sucessfully";
+    return {
+        spooled_filename => $spooled_filename,
+        code => 200
+    };
+}
+
+sub main {
+    getopt('c:', \%opts);
+    load_config;
+    openlog basename($0), 'ndelay', LOG_USER;
+    my $server = new RPC::XML::Server(port => $config{port});
+
+    $server->add_proc({
+        name => 'inject', code => \&inject, signature => ['struct string int']
+    });
+
+    $server->add_default_methods;
+    $server->server_loop;
+    0;
+}
+
+exit main @ARGV;    # do it all!
diff --git Open-ILS/examples/asterisk/sounds/sample-greeting.gsm Open-ILS/examples/asterisk/sounds/sample-greeting.gsm
new file mode 100644
index 0000000..02f781c
Binary files /dev/null and Open-ILS/examples/asterisk/sounds/sample-greeting.gsm differ
diff --git Open-ILS/examples/asterisk/sounds/sample-overdue-plural.gsm Open-ILS/examples/asterisk/sounds/sample-overdue-plural.gsm
new file mode 100644
index 0000000..4fd5c29
Binary files /dev/null and Open-ILS/examples/asterisk/sounds/sample-overdue-plural.gsm differ
diff --git Open-ILS/examples/asterisk/sounds/sample-overdue-singular.gsm Open-ILS/examples/asterisk/sounds/sample-overdue-singular.gsm
new file mode 100644
index 0000000..a7a82f4
Binary files /dev/null and Open-ILS/examples/asterisk/sounds/sample-overdue-singular.gsm differ
diff --git Open-ILS/examples/asterisk/sounds/sample-thanks.gsm Open-ILS/examples/asterisk/sounds/sample-thanks.gsm
new file mode 100644
index 0000000..a63240d
Binary files /dev/null and Open-ILS/examples/asterisk/sounds/sample-thanks.gsm differ
diff --git Open-ILS/examples/opensrf.xml.example Open-ILS/examples/opensrf.xml.example
index c56b854..098cce6 100644
--- Open-ILS/examples/opensrf.xml.example
+++ Open-ILS/examples/opensrf.xml.example
@@ -53,6 +53,36 @@ vim:et:ts=4:sw=4:
         <smtp_server>localhost</smtp_server>
         <sender_address>evergr...@localhost</sender_address>
 
+        <!-- global telephony (asterisk) settings -->
+        <telephony>
+            <!-- replace all values below when telephony server is configured -->
+            <enabled>0</enabled>
+            <driver>SIP</driver>    <!-- SIP (default) or multi -->
+            <channels>              <!-- explicit list of channels used if multi -->
+                                    <!-- A channel specifies technology/resource -->
+                <channel>Zap/1</channel>
+                <channel>Zap/2</channel>
+                <channel>IAX/user:sec...@widgets.biz</channel>
+            </channels>
+            <host>localhost</host>
+            <port>10080</port>
+            <user>evergreen</user>
+            <pw>evergreen</pw>
+            <!--
+                The overall composition of callfiles is determined by the relevant template,
+                but this section can be invoked for callfile configs common to all outbound calls.
+                callfile_lines will be inserted into ALL generated callfiles after the Channel line.
+                This content mat be overridden (in whole) by the org unit setting callfile_lines.
+                Warning: Invalid syntax may break ALL outbound calls.
+            -->
+            <callfile_lines>
+                MaxRetries: 3
+                RetryTime: 60
+                WaitTime: 30
+                Extension: 10
+            </callfile_lines>
+        </telephony>
+
         <!-- Overdue notices -->
         <overdue>
 
diff --git Open-ILS/src/extras/opensrf_settings_puller.pl Open-ILS/src/extras/opensrf_settings_puller.pl
new file mode 100755
index 0000000..3bdbcc3
--- /dev/null
+++ Open-ILS/src/extras/opensrf_settings_puller.pl
@@ -0,0 +1,54 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use Data::Dumper;
+
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Config;
+use OpenSRF::Utils::SettingsParser;
+
+# TODO: GetOpts to set these
+my $config_file = '/openils/conf/opensrf_core.xml';
+my $verbose = 0;
+
+sub usage {
+    return <<USAGE
+
+    usage: $0 xpath/traversing/string
+
+Reads $config_file and dumps the structure found at the element
+located by the xpath argument.  Without argument, dumps whole <config>.
+
+    example: $0 apps/open-ils.search/app_settings
+USAGE
+}
+
+sub die_usage {
+    @_ and print "ERROR: @_\n";
+    print usage();
+    exit 1;
+}
+
+my $load = OpenSRF::Utils::Config->load(
+    config_file => $config_file
+);
+my $booty = $load->bootstrap();
+
+my $conf   = OpenSRF::Utils::Config->current;
+my $cfile  = $conf->bootstrap->settings_config;
+my $parser = OpenSRF::Utils::SettingsParser->new();
+$parser->initialize( $cfile );
+$OpenSRF::Utils::SettingsClient::host_config = $parser->get_server_config($conf->env->hostname);
+
+my $settings = OpenSRF::Utils::SettingsClient->new();
+# scalar(@ARGV) or die_usage("Argument is required");
+my @terms = scalar(@ARGV) ? split('/', shift) : ();
+$verbose and print "Looking under: ", join(', ', map {"<$_>"} @terms), "\n";
+
+my $target = $settings->config_value(@terms);
+print Dumper($target);
+
+# my $lines = $target->{callfile_lines};
+
diff --git Open-ILS/src/perlmods/OpenILS/Application/Trigger.pm Open-ILS/src/perlmods/OpenILS/Application/Trigger.pm
index 99c7f38..f4aa7ca 100644
--- Open-ILS/src/perlmods/OpenILS/Application/Trigger.pm
+++ Open-ILS/src/perlmods/OpenILS/Application/Trigger.pm
@@ -438,6 +438,7 @@ sub create_batch_events {
             $event->target( $o_id );
             $event->event_def( $def->id );
             $event->run_time( $run_time );
+            $event->granularity($granularity) if (defined $granularity);
 
             $editor->create_action_trigger_event( $event );
 
diff --git Open-ILS/src/perlmods/OpenILS/Application/Trigger/Reactor/AstCall.pm Open-ILS/src/perlmods/OpenILS/Application/Trigger/Reactor/AstCall.pm
new file mode 100644
index 0000000..f6bd01b
--- /dev/null
+++ Open-ILS/src/perlmods/OpenILS/Application/Trigger/Reactor/AstCall.pm
@@ -0,0 +1,116 @@
+package OpenILS::Application::Trigger::Reactor::AstCall;
+use strict; use warnings;
+use Error qw/:try/;
+use Data::Dumper;
+use OpenSRF::Utils::SettingsClient;
+use OpenILS::Application::Trigger::Reactor;
+use OpenSRF::Utils::Logger qw/:logger/;
+use RPC::XML::Client;
+$Data::Dumper::Indent = 0;
+
+use base 'OpenILS::Application::Trigger::Reactor';
+
+# $last_channel_used is:
+# ~ index (not literal value) of last channel used in a callfile
+# ~ index is of position in @channels (zero-based)
+# ~ cached at package level
+# ~ typically for Zap (PSTN), not VOIP
+
+our @channels;
+our $last_channel_used = 0;
+our $telephony;
+
+sub ABOUT {
+    return <<ABOUT;
+
+    The AstCall reactor module creates a callfile for Asterisk, given a
+    template describing the message and an environment defining
+    necessary information for contacting the Asterisk server and scheduling
+    a call with it.
+
+ABOUT
+}
+
+sub get_conf {
+    $telephony and return $telephony;
+    my $config = OpenSRF::Utils::SettingsClient->new;
+    $telephony = $config->config_value('notifications', 'telephony');  # config object cached by package
+    return $telephony;
+}
+
+sub get_channels {
+    @channels and return @channels;
+    my $config = get_conf();    # populated $telephony object
+    @channels = @{ $config->{channels} };
+    return @channels;
+}
+
+sub next_channel {
+    # Increments $last_channel_used, or resets it to zero, as necessary.
+    # Returns appropriate value from channels array.
+    my @chans = get_channels();
+    unless(@chans) {
+        $logger->error(__PACKAGE__ . ": Cannot build call using " . (shift ||'driver') . ", no notifications.telephony.channels found in config!");
+        return;
+    }
+    if (++$last_channel_used > $#chans) {
+        $last_channel_used = 0;
+    }
+    return $chans[$last_channel_used];     # say, 'Zap/1' or 'Zap/12'
+}
+
+sub channel {
+    my $tech = get_conf()->{driver} || 'SIP';
+    if ($tech !~ /^SIP/) {
+        return next_channel($tech);
+    }
+    return $tech;                          #  say, 'SIP' or 'SIP/ubab33'
+}
+
+sub get_extra_lines {
+    my $lines = get_conf()->{callfile_lines} or return '';
+    my @fixed;
+    foreach (split "\n", $lines) {
+        s/^\s*//g;      # strip leading spaces
+        /\S/ or next;   # skip empty lines
+        push @fixed, $_;
+    }
+    (scalar @fixed) or return '';
+    return join("\n", @fixed) . "\n";
+}
+
+sub handler {
+    my ($self, $env) = @_;
+
+    $logger->info(__PACKAGE__ . ": entered handler");
+
+    unless ($env->{channel_prefix} = channel()) {      # assignment, not comparison
+        $logger->error(__PACKAGE__ . ": Cannot find tech/resource in config");
+        return 0;
+    }
+
+    $env->{extra_lines} = get_extra_lines() || '';
+    my $tmpl_output = $self->run_TT($env);
+    if (not $tmpl_output) {
+        $logger->error(__PACKAGE__ . ": no template input");
+        return 0;
+    }
+
+    $logger->info(__PACKAGE__ . ": get_conf()");
+    my $conf = get_conf();
+
+
+    my $host = $conf->{host};
+    unless ($host) {
+        $logger->error(__PACKAGE__ . ": No telephony/host in config.");
+        return 0;
+    }
+    $conf->{port} and $host .= ":" . $conf->{port};
+    my $client = new RPC::XML::Client($host);
+# TODO: add scheduling intelligence and use it here.
+    my $resp = $client->send_request('inject', $tmpl_output, 0); # FIXME: 0 could be integer timestamp if deferred call needed
+
+    1;
+}
+
+1;
diff --git Open-ILS/src/sql/Pg/002.schema.config.sql Open-ILS/src/sql/Pg/002.schema.config.sql
index 8e18b2b..452de70 100644
--- Open-ILS/src/sql/Pg/002.schema.config.sql
+++ Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -51,7 +51,7 @@ CREATE TABLE config.upgrade_log (
     install_date    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
 );
 
-INSERT INTO config.upgrade_log (version) VALUES ('0079'); -- senator
+INSERT INTO config.upgrade_log (version) VALUES ('0080'); -- atz
 
 CREATE TABLE config.bib_source (
 	id		SERIAL	PRIMARY KEY,
diff --git Open-ILS/src/sql/Pg/950.data.seed-values.sql Open-ILS/src/sql/Pg/950.data.seed-values.sql
index 7e982f8..ba78d0b 100644
--- Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -2201,6 +2201,39 @@ INSERT INTO action_trigger.environment (
     ( 7, 'pickup_lib.billing_address'),
     ( 7, 'usr');
 
+-- Telephony notifications
+
+INSERT INTO action_trigger.reactor VALUES
+    ('AstCall', 'Possibly place a phone call with Asterisk');
+
+INSERT INTO action_trigger.event_definition
+    (id, active, owner,
+        name, hook, validator, reactor,
+        delay, delay_field, group_field,
+        template)
+    VALUES
+    (8, TRUE, 1,
+        'Telephone Overdue Notice', 'checkout.due', 'NOOP_True', 'AstCall',
+        '00:00:05', 'due_date', 'usr',
+$$
+[% phone = target.0.usr.day_phone | replace('[\s\-\(\)]', '') -%]
+[% IF phone.match('^[2-9]') %][% country = 1 %][% ELSE %][% country = '' %][% END -%]
+Channel: [% channel_prefix %]/[% country %][% phone %]
+Context: overdue-test
+[% extra_lines -%]
+Archive: 1
+Set: items=[% target.size %]
+Set: titlestring=[% titles = [] %][% FOR circ IN target %][% titles.push(circ.target_copy.call_number.record.simple_record.title) %][% END %][% titles.join(". ") %]
+$$
+    );
+
+INSERT INTO action_trigger.environment
+    (event_def, path) VALUES
+    (8, 'target_copy.call_number.record.simple_record'),
+    (8, 'usr')
+;
+
+
 SELECT SETVAL('action_trigger.event_definition_id_seq'::TEXT, 100);
 
 -- Org Unit Settings for configuring org unit weights and org unit max-loops for hold targeting
diff --git Open-ILS/src/sql/Pg/upgrade/0080.data.initial_asterisk_events.sql Open-ILS/src/sql/Pg/upgrade/0080.data.initial_asterisk_events.sql
new file mode 100644
index 0000000..408dd9f
--- /dev/null
+++ Open-ILS/src/sql/Pg/upgrade/0080.data.initial_asterisk_events.sql
@@ -0,0 +1,35 @@
+BEGIN;
+
+INSERT INTO config.upgrade_log (version) VALUES ('0080'); -- atz
+
+INSERT INTO action_trigger.reactor VALUES
+    ('AstCall', 'Possibly place a phone call with Asterisk');
+
+INSERT INTO action_trigger.event_definition
+    (id, active, owner,
+        name, hook, validator, reactor,
+        delay, delay_field, group_field,
+        template)
+    VALUES
+    (8, TRUE, 1,
+        'Telephone Overdue Notice', 'checkout.due', 'NOOP_True', 'AstCall',
+        '00:00:05', 'due_date', 'usr',
+$$
+[% phone = target.0.usr.day_phone | replace('[\s\-\(\)]', '') -%]
+[% IF phone.match('^[2-9]') %][% country = 1 %][% ELSE %][% country = '' %][% END -%]
+Channel: [% channel_prefix %]/[% country %][% phone %]
+Context: overdue-test
+[% extra_lines -%]
+Archive: 1
+Set: items=[% target.size %]
+Set: titlestring=[% titles = [] %][% FOR circ IN target %][% titles.push(circ.target_copy.call_number.record.simple_record.title) %][% END %][% titles.join(". ") %]
+$$
+    );
+
+INSERT INTO action_trigger.environment
+    (event_def, path) VALUES
+    (8, 'target_copy.call_number.record.simple_record'),
+    (8, 'usr')
+;
+
+COMMIT;

Reply via email to