You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

374 lines
15 KiB

#! /usr/bin/python
# -*- coding: utf-8 -*-
# Copyright 2010-2018 Akretion France
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Name lookup in Odoo for incoming and outgoing calls with an
Asterisk IPBX
This script is designed to be used as an AGI on an Asterisk IPBX...
BUT I advise you to use a wrapper around this script to control the
execution time. Why ? Because if the script takes too much time to
execute or get stucks (in the XML-RPC request for example), then the
incoming phone call will also get stucks and you will miss a call !
The simplest solution I found is to use the "timeout" shell command to
call this script, for example :
# timeout 2s get_name_agi.py <OPTIONS>
See my 2 sample wrappers "set_name_incoming_timeout.sh" and
"set_name_outgoing_timeout.sh"
It's probably a good idea to create a user in Odoo dedicated to this task.
This user only needs to be part of the group "Phone CallerID", which has
read access on the 'res.partner' and other objects with phone numbers and
names.
Note that this script can be used without Odoo, with just the
geolocalisation feature : for that, don't use option --server ;
only use --geoloc
This script can be used both on incoming and outgoing calls :
1) INCOMING CALLS
When executed from the dialplan on an incoming phone call, it will
lookup in Odoo's partners and other objects with phone numbers
(leads, employees, etc...), and, if it finds the phone number, it will
get the corresponding name of the person and use this name as CallerID
name for the incoming call.
Requires the "base_phone" module
available from https://github.com/OCA/connector-telephony
Asterisk dialplan example :
[from-extern]
exten = _0141981242,1,AGI(/usr/local/bin/set_name_incoming_timeout.sh)
same = n,Dial(SIP/10, 30)
same = n,Answer
same = n,Voicemail(10@default,u)
same = n,Hangup
2) OUTGOING CALLS
When executed from the dialplan on an outgoing call, it will
lookup in Odoo the name corresponding to the phone number
that is called by the user and it will update the name of the
callee on the screen of the phone of the caller.
For that, it uses the CONNECTEDLINE dialplan function of Asterisk
See the following page for more info:
https://wiki.asterisk.org/wiki/display/AST/Manipulating+Party+ID+Information
It is not possible to set the CONNECTEDLINE directly from an AGI script,
(at least not with Asterisk 11) so the AGI script sets a variable
"connectedlinename" that can then be read from the dialplan and passed
as parameter to the CONNECTEDLINE function.
Here is the code that I used on the pre-process subroutine
"odoo-out-call" of the Outgoing Call of my Xivo server :
[odoo-out-call]
exten = s,1,AGI(/var/lib/asterisk/agi-bin/set_name_outgoing_timeout.sh)
same = n,Set(CONNECTEDLINE(name,i)=${connectedlinename})
same = n,Set(CONNECTEDLINE(name-pres,i)=allowed)
same = n,Set(CONNECTEDLINE(num,i)=${XIVO_DSTNUM})
same = n,Set(CONNECTEDLINE(num-pres)=allowed)
same = n,Return()
Of course, you should adapt this example to the Asterisk server you are using.
"""
import xmlrpclib
import sys
from optparse import OptionParser
from asterisk import agi as agilib # pip install pyst2
__author__ = "Alexis de Lattre <alexis.delattre@akretion.com>"
__date__ = "November 2018"
__version__ = "0.7"
# Name that will be displayed if there is no match
# and no geolocalisation. Set it to False if you don't want
# to have a 'not_found_name' when nothing is found
not_found_name = False
# Define command line options
options = [
{'names': ('-s', '--server'), 'dest': 'server', 'type': 'string',
'action': 'store', 'default': False,
'help': 'DNS or IP address of the Odoo server. Default = none '
'(will not try to connect to Odoo)'},
{'names': ('-p', '--port'), 'dest': 'port', 'type': 'int',
'action': 'store', 'default': False,
'help': "Port of Odoo's webservice interface. "
"Default = 443 when SSL is on, 8069 when SSL is off"},
{'names': ('-e', '--ssl'), 'dest': 'ssl',
'help': "Use SSL connections instead of clear connections. "
"Default = no, use clear XML-RPC or JSON-RPC",
'action': 'store_true', 'default': False},
{'names': ('-j', '--jsonrpc'), 'dest': 'jsonrpc',
'help': "Use JSON-RPC instead of the default protocol XML-RPC. "
"Default = no, use XML-RPC",
'action': 'store_true', 'default': False},
{'names': ('-d', '--database'), 'dest': 'database', 'type': 'string',
'action': 'store', 'default': 'odoo',
'help': "Odoo database name. Default = 'odoo'"},
{'names': ('-u', '--user-id'), 'dest': 'userid', 'type': 'int',
'action': 'store', 'default': 2,
'help': "Odoo user ID to use when connecting to Odoo in "
"XML-RPC. Default = 2"},
{'names': ('-t', '--username'), 'dest': 'username', 'type': 'string',
'action': 'store', 'default': 'demo',
'help': "Odoo username to use when connecting to Odoo in "
"JSON-RPC. Default = demo"},
{'names': ('-w', '--password'), 'dest': 'password', 'type': 'string',
'action': 'store', 'default': 'demo',
'help': "Password of the Odoo user. Default = 'demo'"},
{'names': ('-a', '--ascii'), 'dest': 'ascii',
'action': 'store_true', 'default': False,
'help': "Convert name from UTF-8 to ASCII. Default = no, keep UTF-8"},
{'names': ('-n', '--notify'), 'dest': 'notify',
'action': 'store_true', 'default': False,
'help': "Notify Odoo users via a pop-up (requires the Odoo "
"module 'base_phone_popup'). If you use this option, you must pass "
"the logins of the Odoo users to notify as argument to the "
"script. Default = no"},
{'names': ('-g', '--geoloc'), 'dest': 'geoloc',
'action': 'store_true', 'default': False,
'help': "Try to geolocate phone numbers unknown to Odoo. This "
"features requires the 'phonenumbers' Python lib. To install it, "
"run 'sudo pip install phonenumbers' Default = no"},
{'names': ('-l', '--geoloc-lang'), 'dest': 'lang', 'type': 'string',
'action': 'store', 'default': "en",
'help': "Language in which the name of the country and city name "
"will be displayed by the geolocalisation database. Use the 2 "
"letters ISO code of the language. Default = 'en'"},
{'names': ('-c', '--geoloc-country'), 'dest': 'country', 'type': 'string',
'action': 'store', 'default': "FR",
'help': "2 letters ISO code for your country e.g. 'FR' for France. "
"This will be used by the geolocalisation system to parse the phone "
"number of the calling party. Default = 'FR'"},
{'names': ('-o', '--outgoing'), 'dest': 'outgoing',
'action': 'store_true', 'default': False,
'help': "Update the Connected Line ID name on outgoing calls via a "
"call to the Asterisk function CONNECTEDLINE(), instead of updating "
"the Caller ID name on incoming calls. Default = no."},
{'names': ('-i', '--outgoing-agi-variable'), 'dest': 'outgoing_agi_var',
'type': 'string', 'action': 'store', 'default': "extension",
'help': "Enter the name of the AGI variable (without the 'agi_' "
"prefix) from which the script will get the phone number dialed by "
"the user on outgoing calls. For example, with Xivo, you should "
"specify 'dnid' as the AGI variable. Default = 'extension'"},
{'names': ('-m', '--max-size'), 'dest': 'max_size', 'type': 'int',
'action': 'store', 'default': 40,
'help': "If the name has more characters this maximum size, cut it "
"to this maximum size. Default = 40"},
]
def geolocate_phone_number(number, my_country_code, lang):
import phonenumbers
import phonenumbers.geocoder # Takes an enormous amount of time...
res = ''
phonenum = phonenumbers.parse(number, my_country_code.upper())
city = phonenumbers.geocoder.description_for_number(phonenum, lang.lower())
country_code = phonenumbers.region_code_for_number(phonenum)
# We don't display the country name when it's my own country
if country_code == my_country_code.upper():
if city:
res = city
else:
# Convert country code to country name
country = phonenumbers.geocoder._region_display_name(
country_code, lang.lower())
if country and city:
res = country + ' ' + city
elif country and not city:
res = country
return res
def convert_to_ascii(my_unicode):
'''Convert to ascii, with clever management of accents (é -> e, è -> e)'''
import unicodedata
if isinstance(my_unicode, unicode):
my_unicode_with_ascii_chars_only = ''.join((
char for char in unicodedata.normalize('NFD', my_unicode)
if unicodedata.category(char) != 'Mn'))
return str(my_unicode_with_ascii_chars_only)
# If the argument is already of string type, return it with the same value
elif isinstance(my_unicode, str):
return my_unicode
else:
return False
def main(options, arguments):
# print 'options = %s' % options
# print 'arguments = %s' % arguments
agi = agilib.AGI()
if options.outgoing:
phone_number = agi.env['agi_%s' % options.outgoing_agi_var]
agi.verbose("Dialed phone number is %s" % phone_number)
else:
# If we already have a "True" caller ID name
# i.e. not just digits, but a real name, then we don't try to
# connect to Odoo or geoloc, we just keep it
phone_chars = [str(d) for d in range(10)]
phone_chars += ['+']
if (
agi.env.get('agi_calleridname') and
any([
x not in phone_chars
for x in agi.env['agi_calleridname']]) and
agi.env['agi_calleridname'].lower()
not in ['asterisk', 'unknown', 'anonymous'] and
not options.notify):
agi.verbose(
"Incoming CallerID name is %s"
% agi.env['agi_calleridname'])
agi.verbose("As it is a real name, we do not change it")
return True
phone_number = agi.env['agi_callerid']
if not isinstance(phone_number, str):
agi.verbose("Phone number is empty")
exit(0)
# Match for particular cases and anonymous phone calls
# To test anonymous call in France, dial 3651 + number
if not phone_number.isdigit():
agi.verbose("Phone number (%s) is not a digit" % phone_number)
exit(0)
agi.verbose("Phone number = %s" % phone_number)
if options.notify and not arguments:
agi.verbose(
"When using the notify option, you must give arguments "
"to the script")
exit(0)
if options.notify:
method = 'incall_notify_by_login'
else:
method = 'get_name_from_phone_number'
if options.port:
port = options.port
# default port depends on protocol
else:
if options.ssl:
port = 443
else:
port = 8069
res = False
# Yes, this script can be used without "-s odoo_server" !
if options.server and options.jsonrpc:
import odoorpc
proto = options.ssl and 'jsonrpc+ssl' or 'jsonrpc'
agi.verbose(
"Starting %s request on Odoo %s:%d database %s username %s" % (
proto.upper(), options.server, port, options.database,
options.username))
try:
odoo = odoorpc.ODOO(options.server, proto, port)
odoo.login(options.database, options.username, options.password)
if options.notify:
res = odoo.execute(
'phone.common', method, phone_number, arguments)
else:
res = odoo.execute('phone.common', method, phone_number)
agi.verbose("Called method %s" % method)
except Exception:
agi.verbose("Could not connect to Odoo in JSON-RPC")
elif options.server:
proto = options.ssl and 'https' or 'http'
agi.verbose(
"Starting %s XML-RPC request on Odoo %s:%d "
"database %s user ID %d" % (
proto, options.server, port, options.database,
options.userid))
sock = xmlrpclib.ServerProxy(
'%s://%s:%d/xmlrpc/object' % (proto, options.server, port))
try:
if options.notify:
res = sock.execute(
options.database, options.userid, options.password,
'phone.common', method, phone_number, arguments)
else:
res = sock.execute(
options.database, options.userid, options.password,
'phone.common', method, phone_number)
agi.verbose("Called method %s" % method)
except Exception:
agi.verbose("Could not connect to Odoo in XML-RPC")
# To simulate a long execution of the XML-RPC request
# import time
# time.sleep(5)
# Function to limit the size of the name
if res:
if len(res) > options.max_size:
res = res[0:options.max_size]
elif options.geoloc:
# if the number is not found in Odoo, we try to geolocate
agi.verbose(
"Trying to geolocate with country %s and lang %s"
% (options.country, options.lang))
res = geolocate_phone_number(
phone_number, options.country, options.lang)
else:
# if the number is not found in Odoo and geoloc is off,
# we put 'not_found_name' as Name
agi.verbose("Phone number not found in Odoo")
res = not_found_name
# All SIP phones should support UTF-8...
# but in case you have analog phones over TDM
# or buggy phones, you should use the command line option --ascii
if options.ascii:
res = convert_to_ascii(res)
agi.verbose("Name = %s" % res)
if res:
if options.outgoing:
agi.set_variable('connectedlinename', res)
else:
agi.set_callerid('"%s"<%s>' % (res, phone_number))
return True
if __name__ == '__main__':
usage = "Usage: get_name_agi.py [options] login1 login2 login3 ..."
epilog = "Script written by Alexis de Lattre. "
"Published under the GNU AGPL licence."
description = "This is an AGI script that sends a query to Odoo. "
"It can also be used without Odoo to geolocate phone numbers "
"of incoming calls."
parser = OptionParser(usage=usage, epilog=epilog, description=description)
for option in options:
param = option['names']
del option['names']
parser.add_option(*param, **option)
options, arguments = parser.parse_args()
sys.argv[:] = arguments
main(options, arguments)