lenny.py 10 KB

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