#!/usr/bin/env python
"""
    Astsync is a daemon that generates Asterisk's .call files from a MySQL table.
    Author: Arnaldo M. Pereira - <egghunt@gmail.com>
"""

import sys, os, signal, tempfile, glob, getopt, commands, time

from datetime  import datetime
from time      import *
from sqlobject import *
from syslog    import *
from pyinotify import SimpleINotify, ProcessEvent, EventsCodes

#conn        = 'mysql://asteriskuser:asteriskpass@localhost/asterisk'
conn        = 'mysql://asteriskuser:asteriskpass@localhost/aes'
debug       = False

# default values
database  = 'asterisk'
username  = 'asteriskuser'
password  = 'asteriskpass'
path      = '/opt/asterisk/1.2/var/spool/asterisk/outgoing'
callerid  = 'AES <32924699>'
context   = 'default'
extension = 's'
priority  = 1
trunk     = 'SIP/proxy-netjet/0115511'
#trunk = 'SIP'
#trunk = 'IAX2'
#trunk     = 'IAX2/3036:1234@voip.principaltelecom.com.br'
#trunk     = 'UniCall/g1'

#
# Customer's possible states
#
ST_PROCESSING = '0'
ST_REGISTERED = '1'
ST_SPOOLED    = '2'
ST_PROCESSED  = '3'

# Default daemon parameters.
# File mode creation mask of the daemon.
UMASK = 022

# Default working directory for the daemon.
WORKDIR = "/"

# Default maximum for the number of available file descriptors.
MAXFD = 1024

# Save PID
PIDFILE = "/var/run/astsync.pid"

# Time to process call files
time_offset=5
callgap=5

# The standard I/O file descriptors are redirected to /dev/null by default.
if (hasattr(os, "devnull")):
    REDIRECT_TO = os.devnull
else:
    REDIRECT_TO = "/dev/null"

# .call files prefix
CALL_FILE_PREFIX = "astsync-"

#
# This is the template used to create the .call files
# Any desired changes on the .call files must be done
# here.
#
# Note: {{CHANNEL_EXT}} is meant to be the actual customer's
#       telephone, without garbage characters.
#
aes_tpl = """
Channel: {{TRUNK}}{{CHANNEL_EXT}}
MaxRetries: {{MAX_RETRIES}}
RetryTime: {{RETRY_INTERVAL}}
WaitTime: {{WAIT_TIME}}

Context: ivrng
Extension: {{CHANNEL_EXT}}
Priority: {{PRIORITY}}
Account: aes

Callerid: {{CALLERID}}
Set: ivrname={{CONTEXT}}
Set: campaign={{CAMPAIGN}}
Set: reference={{REFERENCE}}
Set: customer_id={{CUSTOMER_ID}}
Set: customer_name={{CUSTOMER_NAME}}
Set: customer_addr={{CUSTOMER_ADDR}}
Set: customer_phone={{PHONE}}
Set: userfield={{CONTEXT}},{{CAMPAIGN}},{{CUSTOMER_NAME}},{{CHANNEL_EXT}}
#Archive: yes
"""


def usage():
    print """
Astsync is a daemon that translates a MySQL table of customers Asterisk's 
.call files.

Valid arguments follows.

Asterisk options

    --callerid=CID      Caller ID to generate calls from        [ default=%s ]
    --extension=EXT     Context's extension                     [ default=%s ]
    --trunk=TRUNK       Trunk to place the call on              [ default=%s ]

General

    --maxcalls=N        Maximum number of simultaneous calls    [ default=%d ]
    --path=PATH         Path where the .call file must be saved [ default=%s ]
    --help              This help
    --debug             Print debug information

Author: Arnaldo M. Pereira - <egghunt@gmail.com>
License: have-no-idea
        """ % (callerid, extension, trunk, 60, path)


def daemon():
    """Detach the process from the controlling terminal and run it in the
    background as a daemon.
    """

    try:
        pid = os.fork()
    except OSError, e:
        raise Exception, "%s [%d]" % (e.strerror, e.errno)

    if (pid == 0):
        os.setsid()

        try:
            pid = os.fork()	# Fork a second child.
        except OSError, e:
            raise Exception, "%s [%d]" % (e.strerror, e.errno)

        if (pid == 0):	# The second child.
            os.chdir(WORKDIR)
            os.umask(UMASK)
        else:
            os._exit(0)
    else:
        # exit() or _exit()?
        # _exit is like exit(), but it doesn't call any functions registered
        # with atexit (and on_exit) or any registered signal handlers.  It also
        # closes any open file descriptors.  Using exit() may cause all stdio
        # streams to be flushed twice and any temporary files may be unexpectedly
        # removed.  It's therefore recommended that child branches of a fork()
        # and the parent branch(es) of a daemon use _exit().
        os._exit(0)	# Exit parent of the first child.

    import resource
    maxfd = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
    if (maxfd == resource.RLIM_INFINITY):
        maxfd = MAXFD
  
    for fd in range(0, maxfd):
        try:
            os.close(fd)
        except OSError:	# ERROR, fd wasn't open to begin with (ignored)
            pass

    os.open(REDIRECT_TO, os.O_RDWR)	# standard input (0)

    # Duplicate standard input to standard output and standard error.
    os.dup2(0, 1)			# standard output (1)
    os.dup2(0, 2)			# standard error (2)

    return(0)

class Customers_call_list(SQLObject):
    _connection = conn

    state      = EnumCol(enumValues = [ ST_REGISTERED, ST_SPOOLED, ST_PROCESSED ])
    name       = StringCol(length=64)
    address    = StringCol(length=128)
    context    = StringCol(length=32)
    phone      = StringCol(length=16)
    campaign   = StringCol(length=64)
    begin_date = DateTimeCol(default=datetime.today())
    end_date   = DateTimeCol(default=datetime.max.today())

    retries_when_busy = IntCol()
    retries_when_notanswered = IntCol()
    retries_interval = TimeCol()

    week_begin_period = TimeCol()
    week_end_period = TimeCol()
    weekend_begin_period = TimeCol()
    weekend_end_period = TimeCol()
    wait_time = IntCol()

    simultaneous_calls = IntCol()
    added = DateTimeCol()
    reference = StringCol(length=16)


def sync():
    """Read rows from database and write necessary call files."""

    # Count files on asterisk's outbound directory, so we can control
    # the number of concurrent calls
    l = glob.glob(path + '/' + CALL_FILE_PREFIX + '*')
    limit = max_calls - len(l)
    if limit <= 0:
        if debug: 
            syslog(LOG_INFO, "%s already has %d files, won't touch it this time." % (path, len(l)))
        return
    if debug:
        syslog(LOG_INFO, "%d files on %s, adding up to %d files" % (len(l), path, limit))

    t = time() #- 180 # to pretend we're 3 minutes on the past
    tint = int(t)
    tfuture = tint + time_offset
    now = datetime.fromtimestamp(tint)

    c = Customers_call_list
    weekday = now.strftime("%a")
    if weekday == 'Sat' or weekday == 'Sun':
        if debug: print 'Weekend !'

        queryset = c.select(
            AND(
                c.q.state == ST_REGISTERED,
#                c.q.begin_date <= now,
#                c.q.end_date > now,
#                c.q.weekend_begin_period <= now,
#                c.q.weekend_end_period >= now,
            )
        ).limit(limit)

    else: 

        queryset = c.select(
            AND(
                c.q.state == ST_REGISTERED,
#                c.q.begin_date <= now,
#                c.q.end_date > now,
#                c.q.week_begin_period <= now,
#                c.q.week_end_period >= now,
            )
        ).limit(limit)


    for row in queryset:
        if debug:
            syslog(LOG_INFO, "[%s]\nname: %s\nbegin: %s\nend: %s" % (row.campaign, row.name, row.begin_date, row.end_date))

        # clean extension number
        ext = row.phone
        ext = ext.replace('-', '')
        ext = ext.replace(' ', '')
        ext = ext.replace('_', '')
        ext = ext.lstrip()
        ext = ext.rstrip()

        name = row.name
        name = name.replace(' ', '-')
        name = name.replace('_', '')
        name = name.lstrip()
        name = name.rstrip()
        name = name.lower()

        (hour, min, sec) = str(row.retries_interval).rsplit(':')
        retries_seconds  = int((int(hour) * 60 * 60) + (int(min) * 60) + int(sec))

        # fill in the template
        t = aes_tpl
        t = t.replace('{{CAMPAIGN}}',       row.campaign)
        t = t.replace('{{REFERENCE}}',      row.reference)
        t = t.replace('{{CUSTOMER_NAME}}',  name)
        t = t.replace('{{CUSTOMER_ID}}',    str(row.id))
        t = t.replace('{{CUSTOMER_ADDR}}',  row.address)
        t = t.replace('{{PHONE}}',          row.phone)
        t = t.replace('{{CHANNEL_EXT}}',    ext)
        t = t.replace('{{CALLERID}}',       callerid)
        t = t.replace('{{CONTEXT}}',        row.context)
        t = t.replace('{{EXTENSION}}',      extension)
        t = t.replace('{{PRIORITY}}',       priority)
        t = t.replace('{{TRUNK}}',          trunk)
        t = t.replace('{{MAX_RETRIES}}',    str(row.retries_when_busy + row.retries_when_notanswered))
        t = t.replace('{{RETRY_INTERVAL}}', str(retries_seconds))
        t = t.replace('{{WAIT_TIME}}',      str(row.wait_time))

        # Done: 
        #   let F be the number of files that match the expression queuedir/callset-call-(REFERENCE)
        #   let O be the time gap/offset between files with same REFERENCE/extension
        #   let C be the callfile from the current customer, that is to be generated
        #
        #   add a time gap of F*O to C's time
        #
        cf_dir = path
        cf_prefix = "callset-call-%s-" % (row.reference)
        cf_time_gap = 900 # 15 minutes
        if debug: 
            syslog(LOG_INFO, "Reference: %s cf_prefix: %s" % (row.reference, cf_prefix))

        cf_glob_pattern = "%s/%s*" % (cf_dir, cf_prefix)
        cf_reference_count = len(glob.glob(cf_glob_pattern))
        if debug:
            syslog(LOG_INFO, "Glob pattern: %s Reference count: %d\n" % (cf_glob_pattern, cf_reference_count))
        cf_reference_time_gap = tfuture + cf_reference_count * cf_time_gap

        # write temporary call file
        (fd, tmp_file) = tempfile.mkstemp(prefix=cf_prefix, dir=cf_dir)
        if debug: syslog(LOG_INFO, "Outfile: %s" % tmp_file)
        try:
            f = open(tmp_file, 'w')
            try: f.write(t)
            finally: f.close()
        except IOError, v:
            try:
                (code, message) = v
            except:
                code = 0
                message = v
            syslog(LOG_ERR, "Can't open [%s]: %s (%d)" % (tmp_file, message, code))
            continue
        os.close(fd)

        date_str = datetime.fromtimestamp(cf_reference_time_gap)
        cmd = "touch -d '%s' %s" % (date_str, tmp_file)
        commands.getoutput(cmd)
        tfuture = (tfuture + callgap)
        if debug:
            syslog(LOG_INFO, "Running %s @%s" % (cmd, now))
        
        # move file to where it belongs
        outfile = path + "/" + os.path.basename(tmp_file)
        if debug:
            syslog(LOG_INFO, "Moving [%s => %s]" % (tmp_file, outfile))
        try:
            os.rename(tmp_file, outfile)
        except OSError, v:
            try:
                (code, message) = v
            except:
                code = 0
                message = v
            syslog(LOG_ERR, "Can't rename [%s => %s]: %s (%d)" % (tmp_file, outfile, message, code))
            os.remove(tmp_file)
            continue

        # mark this row as spooled
        row.state = ST_SPOOLED

    # TODO: report of iteration


class PDelete(ProcessEvent):
    def process_IN_DELETE(self, event_k):
        """
        process 'IN_DELETE_*' events
        """
        if event_k.name:
            f = "%s" % os.path.join(event_k.path, event_k.name)
        else:
            f = "%s" % event_k.path
        if debug:
            syslog(LOG_INFO, "Ignoring %s removal.\n" % f)
        return
        sync()

def sighandler(signum, frame):
    if debug:
        syslog(LOG_INFO, "Received signal %s, calling sync()." % signum)

    if signum == signal.SIGHUP or signum == signal.SIGALRM:
        if debug:
            print "Resyncing as signal #%d asks me to." % signum
        
        sync()

    else:
        if debug:
            print "Signal #%d not handled." % signum


if __name__ == "__main__":

    if len(sys.argv) < 2:
        usage()
        sys.exit(2)

    try:
        opts, args = getopt.getopt(sys.argv[1:], "hdn:u:x:t:p:c:o:e:r:k:m:", ["help", "debug", "path=", "name=", 
"username=", "secret=", "callerid=", "context=", "extension=", "priority=", "trunk=", "maxcalls="])
    except getopt.GetoptError:
        print "Error parsing line arguments."
        usage()
        sys.exit(2)
    for o, a in opts:
        if o in ("-h", "--help"):
            usage()
            sys.exit()
        if o in ("-p", "--path"):
            path = a
        if o in ("-d", "--debug"):
            debug = True
        if o in ("-n", "--name"):
            database = a
        if o in ("-u", "--username"):
            username = a
        if o in ("-s", "--secret"):
            password = a
        if o in ("-c", "--callerid"):
            callerid = a
        if o in ("-o", "--context"):
            context = a
        if o in ("-e", "--extension"):
            extension = a
        if o in ("-r", "--priority"):
            priority = a
        if o in ("-k", "--trunk"):
            trunk = a
        if o in ("-m", "--maxcalls"):
            max_calls = int(a)

    conn = "mysql://%s:%s@localhost/%s" % (username, password, database)
    if debug:
        print "DATABASE OPTIONS\n"
        print "name: %s\nusername: %s\npassword: %s\n" % (database, username, password)

        print "ASTERISK OPTIONS\n"
        print "callerid: %s\ncontext: %s\nextension: %s\npriority: %s\ntrunk: %s\n" % (callerid, context, extension, 
priority, trunk)

        print "GENERAL OPTIONS\n"
        print "debug: %d\npath: %s\nmaxcalls: %d\n" % (debug, path, max_calls)

    #
    # Append a DEBUG string on pidfile, when debug is set
    #
    pidfile = None
    if debug: pidfile = "%s.DEBUG" % (PIDFILE)
    else: pidfile = "%s" % (PIDFILE)
    PIDFILE = pidfile
    print "PIDFILE: %s" % PIDFILE

    # check if pidfile is writable before daemonizing
    try:
        f = open(pidfile, "w")

    except IOError, v:
        try:
            (code, message) = v
        except:
            code = 0
            message = v
        print "Can't open [%s] for writing: %s (%s)" % (PIDFILE, message, code)
        print "Aborting execution."
        sys.exit(2)
    f.close()

    if debug:
        openlog("astsync", LOG_PID|LOG_PERROR, LOG_LOCAL7)
    else:
        retCode = daemon()
        openlog("astsync", LOG_PID, LOG_LOCAL7)

    syslog(LOG_ERR, "Log initialized.")
    try:
        f = open(PIDFILE, "w")
        try:
            f.write("%s\n" % os.getpid())
        finally:
            f.close()
    except IOError, v:
        try:
            (code, message) = v
        except:
            code = 0
            message = v
        syslog(LOG_ERR, "Can't open [%s]: %s (%d)" % (PIDFILE, message, code))

    # listen for SIGHUP
    signal.signal(signal.SIGHUP, sighandler)

    connection = connectionForURI(conn)
    sqlhub.processConnection = connection

    # only watch those events
    mask = EventsCodes.IN_DELETE

    # class instance and init
    ino = SimpleINotify()

    if debug:
        syslog(LOG_INFO, "Starting to monitor %s for IN_DELETE events" % path)

    added_flag = False
    # read and process events
    while True:
        try:
            if not added_flag:
                sync()
                ino.add_watch(path, mask, PDelete())
                added_flag = True
            ino.process_events()
            if ino.event_check():
                ino.read_events()
        except KeyboardInterrupt:
            # ...until c^c signal
            syslog(LOG_INFO, "Stoping %s monitoring." % path)
            # close inotify's instance
            ino.close()
            break
        except Exception, err:
            # otherwise keep on watching
            if debug:
                print err
                syslog(LOG_ERR, "%s" % err)

    syslog(LOG_INFO, "Exiting.")
    closelog()
    sys.exit(0)
