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.

405 lines
17 KiB

8 years ago
  1. #! /usr/bin/python
  2. # -*- encoding: utf-8 -*-
  3. # Copyright (C) 2010-2015 Alexis de Lattre <alexis.delattre@akretion.com>
  4. #
  5. # This program is free software: you can redistribute it and/or modify
  6. # it under the terms of the GNU Affero General Public License as
  7. # published by the Free Software Foundation, either version 3 of the
  8. # License, or (at your option) any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU Affero General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU Affero General Public License
  16. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  17. """
  18. Name lookup in OpenERP for incoming and outgoing calls with an
  19. Asterisk IPBX
  20. This script is designed to be used as an AGI on an Asterisk IPBX...
  21. BUT I advise you to use a wrapper around this script to control the
  22. execution time. Why ? Because if the script takes too much time to
  23. execute or get stucks (in the XML-RPC request for example), then the
  24. incoming phone call will also get stucks and you will miss a call !
  25. The simplest solution I found is to use the "timeout" shell command to
  26. call this script, for example :
  27. # timeout 2s get_name_agi.py <OPTIONS>
  28. See my 2 sample wrappers "set_name_incoming_timeout.sh" and
  29. "set_name_outgoing_timeout.sh"
  30. It's probably a good idea to create a user in OpenERP dedicated to this task.
  31. This user only needs to be part of the group "Phone CallerID", which has
  32. read access on the 'res.partner' and other objects with phone numbers and
  33. names.
  34. Note that this script can be used without OpenERP, with just the
  35. geolocalisation feature : for that, don't use option --server ;
  36. only use --geoloc
  37. This script can be used both on incoming and outgoing calls :
  38. 1) INCOMING CALLS
  39. When executed from the dialplan on an incoming phone call, it will
  40. lookup in OpenERP's partners and other objects with phone numbers
  41. (leads, employees, etc...), and, if it finds the phone number, it will
  42. get the corresponding name of the person and use this name as CallerID
  43. name for the incoming call.
  44. Requires the "base_phone" module
  45. available from https://code.launchpad.net/openerp-asterisk-connector
  46. for OpenERP version >= 7.0
  47. Asterisk dialplan example :
  48. [from-extern]
  49. exten = _0141981242,1,AGI(/usr/local/bin/set_name_incoming_timeout.sh)
  50. same = n,Dial(SIP/10, 30)
  51. same = n,Answer
  52. same = n,Voicemail(10@default,u)
  53. same = n,Hangup
  54. 2) OUTGOING CALLS
  55. When executed from the dialplan on an outgoing call, it will
  56. lookup in OpenERP the name corresponding to the phone number
  57. that is called by the user and it will update the name of the
  58. callee on the screen of the phone of the caller.
  59. For that, it uses the CONNECTEDLINE dialplan function of Asterisk
  60. See the following page for more info:
  61. https://wiki.asterisk.org/wiki/display/AST/Manipulating+Party+ID+Information
  62. It is not possible to set the CONNECTEDLINE directly from an AGI script,
  63. (at least not with Asterisk 11) so the AGI script sets a variable
  64. "connectedlinename" that can then be read from the dialplan and passed
  65. as parameter to the CONNECTEDLINE function.
  66. Here is the code that I used on the pre-process subroutine
  67. "openerp-out-call" of the Outgoing Call of my Xivo server :
  68. [openerp-out-call]
  69. exten = s,1,AGI(/var/lib/asterisk/agi-bin/set_name_outgoing_timeout.sh)
  70. same = n,Set(CONNECTEDLINE(name,i)=${connectedlinename})
  71. same = n,Set(CONNECTEDLINE(name-pres,i)=allowed)
  72. same = n,Set(CONNECTEDLINE(num,i)=${XIVO_DSTNUM})
  73. same = n,Set(CONNECTEDLINE(num-pres)=allowed)
  74. same = n,Return()
  75. Of course, you should adapt this example to the Asterisk server you are using.
  76. """
  77. import xmlrpclib
  78. import sys
  79. from optparse import OptionParser
  80. __author__ = "Alexis de Lattre <alexis.delattre@akretion.com>"
  81. __date__ = "June 2015"
  82. __version__ = "0.6"
  83. # Name that will be displayed if there is no match
  84. # and no geolocalisation. Set it to False if you don't want
  85. # to have a 'not_found_name' when nothing is found
  86. not_found_name = "Not in Odoo"
  87. # Define command line options
  88. options = [
  89. {'names': ('-s', '--server'), 'dest': 'server', 'type': 'string',
  90. 'action': 'store', 'default': False,
  91. 'help': 'DNS or IP address of the OpenERP server. Default = none '
  92. '(will not try to connect to OpenERP)'},
  93. {'names': ('-p', '--port'), 'dest': 'port', 'type': 'int',
  94. 'action': 'store', 'default': 8069,
  95. 'help': "Port of OpenERP's XML-RPC interface. Default = 8069"},
  96. {'names': ('-e', '--ssl'), 'dest': 'ssl',
  97. 'help': "Use SSL connections instead of clear connections. "
  98. "Default = no, use clear XML-RPC or JSON-RPC",
  99. 'action': 'store_true', 'default': False},
  100. {'names': ('-j', '--jsonrpc'), 'dest': 'jsonrpc',
  101. 'help': "Use JSON-RPC instead of the default protocol XML-RPC. "
  102. "Default = no, use XML-RPC",
  103. 'action': 'store_true', 'default': False},
  104. {'names': ('-d', '--database'), 'dest': 'database', 'type': 'string',
  105. 'action': 'store', 'default': 'openerp',
  106. 'help': "OpenERP database name. Default = 'openerp'"},
  107. {'names': ('-u', '--user-id'), 'dest': 'userid', 'type': 'int',
  108. 'action': 'store', 'default': 2,
  109. 'help': "OpenERP user ID to use when connecting to OpenERP in "
  110. "XML-RPC. Default = 2"},
  111. {'names': ('-t', '--username'), 'dest': 'username', 'type': 'string',
  112. 'action': 'store', 'default': 'demo',
  113. 'help': "OpenERP username to use when connecting to OpenERP in "
  114. "JSON-RPC. Default = demo"},
  115. {'names': ('-w', '--password'), 'dest': 'password', 'type': 'string',
  116. 'action': 'store', 'default': 'demo',
  117. 'help': "Password of the OpenERP user. Default = 'demo'"},
  118. {'names': ('-a', '--ascii'), 'dest': 'ascii',
  119. 'action': 'store_true', 'default': False,
  120. 'help': "Convert name from UTF-8 to ASCII. Default = no, keep UTF-8"},
  121. {'names': ('-n', '--notify'), 'dest': 'notify',
  122. 'action': 'store_true', 'default': False,
  123. 'help': "Notify OpenERP users via a pop-up (requires the OpenERP "
  124. "module 'base_phone_popup'). If you use this option, you must pass "
  125. "the logins of the OpenERP users to notify as argument to the "
  126. "script. Default = no"},
  127. {'names': ('-g', '--geoloc'), 'dest': 'geoloc',
  128. 'action': 'store_true', 'default': False,
  129. 'help': "Try to geolocate phone numbers unknown to OpenERP. This "
  130. "features requires the 'phonenumbers' Python lib. To install it, "
  131. "run 'sudo pip install phonenumbers' Default = no"},
  132. {'names': ('-l', '--geoloc-lang'), 'dest': 'lang', 'type': 'string',
  133. 'action': 'store', 'default': "en",
  134. 'help': "Language in which the name of the country and city name "
  135. "will be displayed by the geolocalisation database. Use the 2 "
  136. "letters ISO code of the language. Default = 'en'"},
  137. {'names': ('-c', '--geoloc-country'), 'dest': 'country', 'type': 'string',
  138. 'action': 'store', 'default': "FR",
  139. 'help': "2 letters ISO code for your country e.g. 'FR' for France. "
  140. "This will be used by the geolocalisation system to parse the phone "
  141. "number of the calling party. Default = 'FR'"},
  142. {'names': ('-o', '--outgoing'), 'dest': 'outgoing',
  143. 'action': 'store_true', 'default': False,
  144. 'help': "Update the Connected Line ID name on outgoing calls via a "
  145. "call to the Asterisk function CONNECTEDLINE(), instead of updating "
  146. "the Caller ID name on incoming calls. Default = no."},
  147. {'names': ('-i', '--outgoing-agi-variable'), 'dest': 'outgoing_agi_var',
  148. 'type': 'string', 'action': 'store', 'default': "extension",
  149. 'help': "Enter the name of the AGI variable (without the 'agi_' "
  150. "prefix) from which the script will get the phone number dialed by "
  151. "the user on outgoing calls. For example, with Xivo, you should "
  152. "specify 'dnid' as the AGI variable. Default = 'extension'"},
  153. {'names': ('-m', '--max-size'), 'dest': 'max_size', 'type': 'int',
  154. 'action': 'store', 'default': 40,
  155. 'help': "If the name has more characters this maximum size, cut it "
  156. "to this maximum size. Default = 40"},
  157. ]
  158. def stdout_write(string):
  159. '''Wrapper on sys.stdout.write'''
  160. sys.stdout.write(string.encode(sys.stdout.encoding or 'utf-8', 'replace'))
  161. sys.stdout.flush()
  162. # When we output a command, we get an answer "200 result=1" on stdin
  163. # Purge stdin to avoid these Asterisk error messages :
  164. # utils.c ast_carefulwrite: write() returned error: Broken pipe
  165. sys.stdin.readline()
  166. return True
  167. def stderr_write(string):
  168. '''Wrapper on sys.stderr.write'''
  169. sys.stderr.write(string.encode(sys.stdout.encoding or 'utf-8', 'replace'))
  170. sys.stdout.flush()
  171. return True
  172. def geolocate_phone_number(number, my_country_code, lang):
  173. import phonenumbers
  174. import phonenumbers.geocoder
  175. res = ''
  176. phonenum = phonenumbers.parse(number, my_country_code.upper())
  177. city = phonenumbers.geocoder.description_for_number(phonenum, lang.lower())
  178. country_code = phonenumbers.region_code_for_number(phonenum)
  179. # We don't display the country name when it's my own country
  180. if country_code == my_country_code.upper():
  181. if city:
  182. res = city
  183. else:
  184. # Convert country code to country name
  185. country = phonenumbers.geocoder._region_display_name(
  186. country_code, lang.lower())
  187. if country and city:
  188. res = country + ' ' + city
  189. elif country and not city:
  190. res = country
  191. return res
  192. def convert_to_ascii(my_unicode):
  193. '''Convert to ascii, with clever management of accents (é -> e, è -> e)'''
  194. import unicodedata
  195. if isinstance(my_unicode, unicode):
  196. my_unicode_with_ascii_chars_only = ''.join((
  197. char for char in unicodedata.normalize('NFD', my_unicode)
  198. if unicodedata.category(char) != 'Mn'))
  199. return str(my_unicode_with_ascii_chars_only)
  200. # If the argument is already of string type, return it with the same value
  201. elif isinstance(my_unicode, str):
  202. return my_unicode
  203. else:
  204. return False
  205. def main(options, arguments):
  206. # print 'options = %s' % options
  207. # print 'arguments = %s' % arguments
  208. # AGI passes parameters to the script on standard input
  209. stdinput = {}
  210. while 1:
  211. input_line = sys.stdin.readline()
  212. if not input_line:
  213. break
  214. line = input_line.strip()
  215. try:
  216. variable, value = line.split(':')
  217. except:
  218. break
  219. if variable[:4] != 'agi_': # All AGI parameters start with 'agi_'
  220. stderr_write("bad stdin variable : %s\n" % variable)
  221. continue
  222. variable = variable.strip()
  223. value = value.strip()
  224. if variable and value:
  225. stdinput[variable] = value
  226. stderr_write("full AGI environnement :\n")
  227. for variable in stdinput.keys():
  228. stderr_write("%s = %s\n" % (variable, stdinput.get(variable)))
  229. if options.outgoing:
  230. phone_number = stdinput.get('agi_%s' % options.outgoing_agi_var)
  231. stdout_write('VERBOSE "Dialed phone number is %s"\n' % phone_number)
  232. else:
  233. # If we already have a "True" caller ID name
  234. # i.e. not just digits, but a real name, then we don't try to
  235. # connect to OpenERP or geoloc, we just keep it
  236. if (
  237. stdinput.get('agi_calleridname') and
  238. not stdinput.get('agi_calleridname').isdigit() and
  239. stdinput.get('agi_calleridname').lower()
  240. not in ['asterisk', 'unknown', 'anonymous'] and
  241. not options.notify):
  242. stdout_write(
  243. 'VERBOSE "Incoming CallerID name is %s"\n'
  244. % stdinput.get('agi_calleridname'))
  245. stdout_write(
  246. 'VERBOSE "As it is a real name, we do not change it"\n')
  247. return True
  248. phone_number = stdinput.get('agi_callerid')
  249. stderr_write('stdout encoding = %s\n' % sys.stdout.encoding or 'utf-8')
  250. if not isinstance(phone_number, str):
  251. stdout_write('VERBOSE "Phone number is empty"\n')
  252. exit(0)
  253. # Match for particular cases and anonymous phone calls
  254. # To test anonymous call in France, dial 3651 + number
  255. if not phone_number.isdigit():
  256. stdout_write(
  257. 'VERBOSE "Phone number (%s) is not a digit"\n' % phone_number)
  258. exit(0)
  259. stdout_write('VERBOSE "Phone number = %s"\n' % phone_number)
  260. if options.notify and not arguments:
  261. stdout_write(
  262. 'VERBOSE "When using the notify option, you must give arguments '
  263. 'to the script"\n')
  264. exit(0)
  265. if options.notify:
  266. method = 'incall_notify_by_login'
  267. else:
  268. method = 'get_name_from_phone_number'
  269. res = False
  270. # Yes, this script can be used without "-s openerp_server" !
  271. if options.server and options.jsonrpc:
  272. import odoorpc
  273. proto = options.ssl and 'jsonrpc+ssl' or 'jsonrpc'
  274. stdout_write(
  275. 'VERBOSE "Starting %s request on OpenERP %s:%d database '
  276. '%s username %s"\n' % (
  277. proto.upper(), options.server, options.port, options.database,
  278. options.username))
  279. try:
  280. odoo = odoorpc.ODOO(options.server, proto, options.port)
  281. odoo.login(options.database, options.username, options.password)
  282. if options.notify:
  283. res = odoo.execute(
  284. 'phone.common', method, phone_number, arguments)
  285. else:
  286. res = odoo.execute('phone.common', method, phone_number)
  287. stdout_write('VERBOSE "Called method %s"\n' % method)
  288. except:
  289. stdout_write(
  290. 'VERBOSE "Could not connect to OpenERP in JSON-RPC"\n')
  291. elif options.server:
  292. proto = options.ssl and 'https' or 'http'
  293. stdout_write(
  294. 'VERBOSE "Starting %s XML-RPC request on OpenERP %s:%d '
  295. 'database %s user ID %d"\n' % (
  296. proto, options.server, options.port, options.database,
  297. options.userid))
  298. sock = xmlrpclib.ServerProxy(
  299. '%s://%s:%d/xmlrpc/object'
  300. % (proto, options.server, options.port))
  301. try:
  302. if options.notify:
  303. res = sock.execute(
  304. options.database, options.userid, options.password,
  305. 'phone.common', method, phone_number, arguments)
  306. else:
  307. res = sock.execute(
  308. options.database, options.userid, options.password,
  309. 'phone.common', method, phone_number)
  310. stdout_write('VERBOSE "Called method %s"\n' % method)
  311. except:
  312. stdout_write('VERBOSE "Could not connect to OpenERP in XML-RPC"\n')
  313. # To simulate a long execution of the XML-RPC request
  314. # import time
  315. # time.sleep(5)
  316. # Function to limit the size of the name
  317. if res:
  318. if len(res) > options.max_size:
  319. res = res[0:options.max_size]
  320. elif options.geoloc:
  321. # if the number is not found in OpenERP, we try to geolocate
  322. stdout_write(
  323. 'VERBOSE "Trying to geolocate with country %s and lang %s"\n'
  324. % (options.country, options.lang))
  325. res = geolocate_phone_number(
  326. phone_number, options.country, options.lang)
  327. else:
  328. # if the number is not found in OpenERP and geoloc is off,
  329. # we put 'not_found_name' as Name
  330. stdout_write('VERBOSE "Phone number not found in OpenERP"\n')
  331. res = not_found_name
  332. # All SIP phones should support UTF-8...
  333. # but in case you have analog phones over TDM
  334. # or buggy phones, you should use the command line option --ascii
  335. if options.ascii:
  336. res = convert_to_ascii(res)
  337. stdout_write('VERBOSE "Name = %s"\n' % res)
  338. if res:
  339. if options.outgoing:
  340. stdout_write('SET VARIABLE connectedlinename "%s"\n' % res)
  341. else:
  342. stdout_write('SET CALLERID "%s"<%s>\n' % (res, phone_number))
  343. return True
  344. if __name__ == '__main__':
  345. usage = "Usage: set_name_agi.py [options] login1 login2 login3 ..."
  346. epilog = "Script written by Alexis de Lattre. "
  347. "Published under the GNU AGPL licence."
  348. description = "This is an AGI script that sends a query to OpenERP. "
  349. "It can also be used without OpenERP as to geolocate phone numbers "
  350. "of incoming calls."
  351. parser = OptionParser(usage=usage, epilog=epilog, description=description)
  352. for option in options:
  353. param = option['names']
  354. del option['names']
  355. parser.add_option(*param, **option)
  356. options, arguments = parser.parse_args()
  357. sys.argv[:] = arguments
  358. main(options, arguments)