lenny.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. #!/usr/bin/env python2
  2. # -*- coding: utf-8 -*-
  3. import ConfigParser
  4. import audioop
  5. import contextlib
  6. import glob
  7. import io
  8. import os
  9. import re
  10. import signal
  11. import smtplib
  12. import string
  13. import subprocess
  14. import sys
  15. import syslog
  16. import tempfile
  17. import threading
  18. import urllib2
  19. import wave
  20. from datetime import datetime
  21. from email.mime.text import MIMEText
  22. import linphone
  23. import yaml
  24. from enum import Enum
  25. VOLUME_THRESHOLD = 100
  26. def slugify(s):
  27. """
  28. Normalizes string, converts to lowercase, removes non-alpha characters,
  29. and converts spaces to hyphens, wich is url/filename friendly.
  30. """
  31. valid_chars = "-_.() %s%s" % (string.ascii_letters, string.digits)
  32. filename = ''.join(cc for cc in s if cc in valid_chars)
  33. filename = filename.replace(' ', '_') # I don't like spaces in filenames.
  34. return filename
  35. def get_ip_from_logfile(remote_sip_client_tag, log_file):
  36. # Le log file contiendra, via la mise a jour dyndns custom du routeur, une ligne du type:
  37. # 84.227.205.102 - jf [23/Jun/2017:17:06:19 +0200] "GET /itslenny/domain=[essai]/ip=[84.227.205.102] HTTP/1.1" 404 169 "-" "Fritz!Box DDNS/1.0.1"
  38. cmd = 'cat ' + log_file + ' | grep "' + remote_sip_client_tag + '" | tail -n 1'
  39. reg = "domain=\[" + remote_sip_client_tag + "\]\/ip=\[(.*)\]"
  40. s = re.findall(reg, subprocess.check_output(["bash", "-c", cmd]))
  41. if len(s) > 0:
  42. return s[0]
  43. class ConversationStatus(Enum):
  44. READY_TO_TALK = 0
  45. IMTALKING = 1
  46. WAITFORANSWER = 2
  47. class Conversation(object):
  48. def __init__(self):
  49. self._status = ConversationStatus.READY_TO_TALK
  50. @property
  51. def status(self):
  52. return self._status
  53. @status.setter
  54. def status(self, value):
  55. if value != self._status:
  56. self._status = value
  57. current_dir = os.path.dirname(os.path.realpath(__file__))
  58. replies_seq = glob.glob(current_dir + "/replies/sequence/*.wav")
  59. replies_seq.sort()
  60. replies_generic = glob.glob(current_dir + "/replies/generic/*.wav")
  61. replies_generic.sort()
  62. THREADS_MUST_QUIT = False
  63. def get_wav_duration(fname):
  64. with contextlib.closing(wave.open(fname, 'r')) as f:
  65. frames = f.getnframes()
  66. rate = f.getframerate()
  67. return frames / float(rate)
  68. def sleep(duration):
  69. dummy_event = threading.Event()
  70. dummy_event.wait(timeout=duration)
  71. class SipConnection(object):
  72. class MailType(Enum):
  73. Notify_Incoming_Call = 1
  74. Notify_Incoming_Telemarketer_Call = 2
  75. def log(self, msg):
  76. to_show = str(self) + ": " + msg
  77. print(to_show)
  78. syslog.syslog(to_show)
  79. def mail(self, mail_cfg, text):
  80. try:
  81. server = smtplib.SMTP(mail_cfg["smtp_host"])
  82. msg = MIMEText(text)
  83. msg['Subject'] = text
  84. msg['From'] = mail_cfg["from"]
  85. msg['To'] = mail_cfg["to"]
  86. server.sendmail(mail_cfg["from"], [mail_cfg["to"]], msg.as_string())
  87. server.quit()
  88. except smtplib.SMTPException as e:
  89. self.log("Error sending email " + e.message)
  90. def say(self, core):
  91. if self._conversation.status is not ConversationStatus.IMTALKING:
  92. self._conversation.status = ConversationStatus.IMTALKING
  93. # On joue les repliques en sequence, puis quand
  94. # on arrive au bout, on en joue une au hasard
  95. # du groupe 'generic'
  96. voice_filename = replies_seq[self._replies_pos]
  97. self._replies_pos = (self._replies_pos + 1) % len(replies_seq)
  98. if self._replies_pos == 0:
  99. # On ne rejoue jamais la première réplique "allo"
  100. self._replies_pos = 1
  101. duration = get_wav_duration(voice_filename)
  102. self.log("Saying : " + voice_filename)
  103. core.play_file = voice_filename
  104. sleep(duration)
  105. core.play_file = ""
  106. # On laisse l'autre l'occassion de reparler
  107. self._conversation.status = ConversationStatus.WAITFORANSWER
  108. def storage_path(self):
  109. path = current_dir + "/out/" + str(self) + "/"
  110. if not os.path.isdir(path):
  111. os.makedirs(path)
  112. return path
  113. def shoul_daccept_first_call(self, number):
  114. first_call_file = self.storage_path() + "first_calls.txt"
  115. if not os.path.isfile(first_call_file):
  116. os.system("touch " + first_call_file)
  117. if "accept_first_call" in self._config_info and self._config_info["accept_first_call"] and number not in open(
  118. first_call_file).read():
  119. os.system("echo " + number + " `date` >> " + first_call_file)
  120. self.log("Accepting first call of " + number)
  121. return True
  122. return False
  123. def incoming_stream_worker(self, core, call):
  124. f = open(self._incoming_stream_file, "rb")
  125. f.seek(0, io.SEEK_END)
  126. p = f.tell()
  127. buf = ''
  128. previous_status = self._conversation.status
  129. while call.state is not linphone.CallState.End and not self._is_quitting:
  130. if self._conversation.status is ConversationStatus.IMTALKING:
  131. f.seek(0, io.SEEK_END)
  132. p = f.tell()
  133. else:
  134. if previous_status != self._conversation.status:
  135. f.seek(0, io.SEEK_END)
  136. p = f.tell()
  137. f.seek(p)
  138. buf += f.read(4096)
  139. p = f.tell()
  140. if len(buf) >= 20000:
  141. volume = audioop.rms(buf, 2)
  142. # print("State : " + str(conversation.status))
  143. buf = ''
  144. if volume < self._volume_threshold:
  145. if self._conversation.status is ConversationStatus.READY_TO_TALK:
  146. threading.Thread(target=self.say, args=[core]).start()
  147. else:
  148. self._conversation.status = ConversationStatus.READY_TO_TALK
  149. # We must sleep a bit to avoid cpu hog
  150. sleep(0.01)
  151. previous_status = self._conversation.status
  152. self.log("Worker is quitting")
  153. def registration_state_changed(self, core, call, state, message):
  154. # Le client se ré-enregistre a de multiple reprise, on
  155. # s'en tappe un peu d'en être informé.
  156. if message != self._registration_previous_message:
  157. self.log("Registration status: " + message)
  158. self._registration_previous_message = message
  159. def call_state_changed(self, core, call, state, message):
  160. self.log("state changed : " + message)
  161. if state == linphone.CallState.Released:
  162. # Let's convert wav to mp3
  163. if call.current_params.record_file is not None and os.path.isfile(call.current_params.record_file):
  164. self.log("Saving to mp3 : " + call.current_params.record_file)
  165. subprocess.call('lame --quiet --preset medium %s' % call.current_params.record_file, shell=True)
  166. os.remove(call.current_params.record_file)
  167. if state == linphone.CallState.IncomingReceived:
  168. self.log("Incoming call : {}".format(call.remote_address.username))
  169. self.mail_if_needed(call.remote_address.username, self.MailType.Notify_Incoming_Call)
  170. self._replies_pos = 0
  171. if self.shoul_daccept_first_call(call.remote_address.username) or self.is_in_blacklists(
  172. call.remote_address.username):
  173. self.log("telemarketer calling : " + call.remote_address.username)
  174. self.mail_if_needed(call.remote_address.username, self.MailType.Notify_Incoming_Telemarketer_Call)
  175. call_params = core.create_call_params(call)
  176. if not os.path.isdir(current_dir + "/out"):
  177. os.makedirs(current_dir + "/out")
  178. a_file = self.storage_path() + "call_from_" + slugify(call.remote_address.username) + \
  179. "_" + datetime.now().strftime(
  180. '%Y-%m-%d_%Hh%Mmn%Ss') + ".wav"
  181. self.log("Recording to : " + a_file)
  182. call_params.record_file = a_file
  183. # Let ring some time
  184. sleep(4)
  185. core.accept_call_with_params(call, call_params)
  186. call.start_recording()
  187. sleep(2)
  188. t = threading.Thread(target=self.incoming_stream_worker, args=[core, call])
  189. t.start()
  190. self.say(core)
  191. def __enter__(self):
  192. return self
  193. def __exit__(self, exc_type, exc_value, traceback):
  194. self.log(str(self) + ": cleaning on exit ...")
  195. os.unlink(self._incoming_stream_file)
  196. def get_domain_info(self):
  197. if "domain" in self._config_info:
  198. return self._config_info["domain"]
  199. if "domain_dyn_tag" in self._config_info and "domain_dyn_log" in self._config_info:
  200. return get_ip_from_logfile(self._config_info["domain_dyn_tag"], self._config_info["domain_dyn_log"])
  201. def set_connection_info(self):
  202. self._core.clear_proxy_config()
  203. proxy_cfg = self._core.create_proxy_config()
  204. domain = self.get_domain_info()
  205. proxy_cfg.identity_address = self._core.create_address(
  206. 'sip:' + self._config_info["username"] + '@' + domain + ':5060')
  207. proxy_cfg.server_addr = 'sip:' + domain + ':5060'
  208. proxy_cfg.register_enabled = True
  209. self._core.add_proxy_config(proxy_cfg)
  210. auth_info = self._core.create_auth_info(self._config_info["username"], None, self._config_info["password"],
  211. None, None, domain)
  212. self._core.add_auth_info(auth_info)
  213. def start(self):
  214. self.log("starting ")
  215. self._core.use_files = True
  216. self._core.record_file = self._incoming_stream_file
  217. self.set_connection_info()
  218. while not self._is_quitting:
  219. sleep(0.03)
  220. self._core.iterate()
  221. def request_quit(self):
  222. self._is_quitting = True
  223. self.__exit__(None, None, None)
  224. def __str__(self):
  225. return self._config_info["username"] + "@" + self.get_domain_info()
  226. def __init__(self, config_info):
  227. callbacks = {
  228. 'call_state_changed': self.call_state_changed,
  229. 'registration_state_changed': self.registration_state_changed,
  230. }
  231. self._config_info = config_info
  232. self._core = linphone.Core.new(callbacks, None, None)
  233. self._is_quitting = False
  234. self._registration_previous_message = ""
  235. self._conversation = Conversation()
  236. self._replies_pos = 0
  237. self._volume_threshold = VOLUME_THRESHOLD
  238. self._incoming_stream_file = tempfile.NamedTemporaryFile(delete=False).name
  239. self._core.iterate()
  240. def mail_if_needed(self, number, type):
  241. if "mailer" in self._config_info:
  242. mail_cfg = self._config_info["mailer"]
  243. if type == self.MailType.Notify_Incoming_Call:
  244. if mail_cfg["log_all_call"]:
  245. self.mail(mail_cfg, "Appel entrant : " + number)
  246. if type == self.MailType.Notify_Incoming_Telemarketer_Call:
  247. self.mail(mail_cfg, "Appel telemarketeur entrant : " + number)
  248. def is_in_blacklists(self, a_number):
  249. return self.is_in_local_blacklist(a_number) or self.is_in_directory_ch_blacklist(
  250. a_number) or self.is_in_ktipp_blacklist(a_number) \
  251. or self.is_in_shiansw_blacklist(a_number)
  252. def is_in_local_blacklist(self, a_number):
  253. black_list = current_dir + "/blacklist.txt"
  254. if os.path.isfile(black_list):
  255. res = a_number in open(current_dir + "/blacklist.txt").read()
  256. if res:
  257. self.log(a_number + " Found in localblacklist")
  258. return res
  259. def is_in_ktipp_blacklist(self, a_number):
  260. # On peut interroger le site ktipp:
  261. # https://www.ktipp.ch/service/warnlisten/detail/?warnliste_id=7&ajax=ajax-search-form&keyword=0445510503
  262. # Si argument keyword pas trouvé, ca donne ca dans la réponse :
  263. # 0 Einträge
  264. base_url = "https://www.ktipp.ch/service/warnlisten/detail/?warnliste_id=7&ajax=ajax-search-form&keyword={" \
  265. "$number$}"
  266. the_number = a_number.lstrip("0")
  267. the_number = the_number.replace("+", "")
  268. url = base_url.replace("{$number$}", the_number)
  269. response = ""
  270. try:
  271. response = urllib2.urlopen(url).read()
  272. except urllib2.HTTPError:
  273. pass
  274. res = "0 Eintr" not in response
  275. if res:
  276. self.log(a_number + " found in ktipp blacklist")
  277. return res
  278. def is_in_shiansw_blacklist(self, a_number):
  279. base_url = "https://ch.shouldianswer.net/telefonnummer/{$number$}"
  280. url = base_url.replace("{$number$}", a_number)
  281. response = ""
  282. try:
  283. response = urllib2.urlopen(url).read()
  284. except urllib2.HTTPError:
  285. pass
  286. res = '<div class="review_score negative"></div>' in response
  287. if res:
  288. self.log("Found in ch.shouldianswer.net blacklist")
  289. return res
  290. def is_in_directory_ch_blacklist(self, a_number):
  291. base_url = "https://tel.local.ch/fr/{$number$}"
  292. url = base_url.replace("{$number$}", a_number)
  293. response = ""
  294. try:
  295. response = urllib2.urlopen(url).read()
  296. except urllib2.HTTPError:
  297. pass
  298. res = 'https://tel.local.ch/fr/spamnumber/' in response
  299. if res:
  300. self.log(a_number + " found in directories.ch blacklist")
  301. return res
  302. if __name__ == "__main__":
  303. cfg = ConfigParser.SafeConfigParser()
  304. cfg_path = current_dir + "/config.yml"
  305. if len(sys.argv) == 2:
  306. cfg_path = sys.argv[1]
  307. connections = []
  308. for connection_cfg in yaml.load(file(cfg_path)):
  309. connections.append(SipConnection(connection_cfg))
  310. for sip_c in connections:
  311. threading.Thread(target=sip_c.start).start()
  312. # Ensuring clean quit and ressource releasing
  313. # when receiving ctrl-c from console or SIGTERM
  314. # from daemon manager.
  315. def signal_handler(sig, frame):
  316. print('External stop request!')
  317. for conn in connections:
  318. conn.request_quit()
  319. signal.signal(signal.SIGINT, signal_handler)
  320. signal.signal(signal.SIGTERM, signal_handler)
  321. signal.pause()