lenny.py 12 KB

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