lenny.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. #!/usr/bin/env python2
  2. # -*- coding: utf-8 -*-
  3. import audioop
  4. import contextlib
  5. import glob
  6. import io
  7. import os
  8. import string
  9. import subprocess
  10. import syslog
  11. import threading
  12. import urllib2
  13. import wave
  14. from datetime import datetime
  15. import tempfile
  16. import linphone
  17. from enum import Enum
  18. import signal
  19. import sys
  20. VOLUME_THRESHOLD = 100
  21. def slugify(s):
  22. """
  23. Normalizes string, converts to lowercase, removes non-alpha characters,
  24. and converts spaces to hyphens, wich is url/filename friendly.
  25. """
  26. valid_chars = "-_.() %s%s" % (string.ascii_letters, string.digits)
  27. filename = ''.join(c for c in s if c in valid_chars)
  28. filename = filename.replace(' ', '_') # I don't like spaces in filenames.
  29. return filename
  30. class ConversationStatus(Enum):
  31. READY_TO_TALK = 0
  32. IMTALKING = 1
  33. WAITFORANSWER = 2
  34. class Conversation(object):
  35. def __init__(self):
  36. self._status = ConversationStatus.READY_TO_TALK
  37. @property
  38. def status(self):
  39. return self._status
  40. @status.setter
  41. def status(self, value):
  42. if value != self._status:
  43. self._status = value
  44. current_dir = os.path.dirname(os.path.realpath(__file__))
  45. replies_seq = glob.glob(current_dir + "/replies/sequence/*.wav")
  46. replies_seq.sort()
  47. replies_generic = glob.glob(current_dir + "/replies/generic/*.wav")
  48. replies_generic.sort()
  49. THREADS_MUST_QUIT = False
  50. KTIP_LOOKUP_URL = "https://www.ktipp.ch/service/warnlisten/detail/?warnliste_id=7&ajax=ajax-search-form&keyword={" \
  51. "$number$}"
  52. SHOULDIANSWER_LOOKUP_URL = "https://ch.shouldianswer.net/telefonnummer/{$number$}"
  53. def is_in_blacklists(a_number):
  54. the_number = a_number.lstrip("0")
  55. the_number = the_number.replace("+", "")
  56. return is_in_local_blacklist(the_number) or is_in_ktipp_blacklist(the_number) or is_in_shiansw_blacklist(the_number)
  57. def is_in_local_blacklist(a_number):
  58. black_list = current_dir + "/blacklist.txt"
  59. if os.path.isfile(black_list):
  60. return a_number in open(current_dir + "/blacklist.txt").read()
  61. def is_in_ktipp_blacklist(a_number):
  62. # On peut interroger le site ktipp:
  63. # https://www.ktipp.ch/service/warnlisten/detail/?warnliste_id=7&ajax=ajax-search-form&keyword=0445510503
  64. # Si argument keyword pas trouvé, ca donne ca dans la réponse :
  65. # 0 Einträge
  66. url = KTIP_LOOKUP_URL.replace("{$number$}", a_number)
  67. log("KTIP lookup : " + url)
  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. log("SIANSW lookup : " + url)
  77. response = ""
  78. try:
  79. response = urllib2.urlopen(url).read()
  80. except urllib2.HTTPError:
  81. pass
  82. return '<div class="review_score negative"></div>' in response
  83. def log(msg):
  84. syslog.syslog(msg)
  85. print(msg)
  86. def get_wav_duration(fname):
  87. with contextlib.closing(wave.open(fname, 'r')) as f:
  88. frames = f.getnframes()
  89. rate = f.getframerate()
  90. return frames / float(rate)
  91. def sleep(duration):
  92. dummy_event = threading.Event()
  93. dummy_event.wait(timeout=duration)
  94. class SipConnection(object):
  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. log("Saying : " + voice_filename + "(duration : " + str(duration) + ")")
  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. log("Worker is starting")
  115. f = open(self._incoming_stream_file, "rb")
  116. f.seek(0, io.SEEK_END)
  117. p = f.tell()
  118. buf = ''
  119. previous_status = self._conversation.status
  120. while call.state is not linphone.CallState.End and not self._is_quitting:
  121. if self._conversation.status is ConversationStatus.IMTALKING:
  122. f.seek(0, io.SEEK_END)
  123. p = f.tell()
  124. else:
  125. if previous_status != self._conversation.status:
  126. f.seek(0, io.SEEK_END)
  127. p = f.tell()
  128. f.seek(p)
  129. buf += f.read(4096)
  130. p = f.tell()
  131. if len(buf) >= 20000:
  132. volume = audioop.rms(buf, 2)
  133. print("Detected volume : " + str(volume))
  134. # print("State : " + str(conversation.status))
  135. buf = ''
  136. if volume < self._volume_threshold:
  137. if self._conversation.status is ConversationStatus.READY_TO_TALK:
  138. threading.Thread(target=self.say, args=[core]).start()
  139. else:
  140. self._conversation.status = ConversationStatus.READY_TO_TALK
  141. # We must sleep a bit to avoid cpu hog
  142. sleep(0.01)
  143. previous_status = self._conversation.status
  144. log("Worker is quitting")
  145. def call_state_changed(self, core, call, state, message):
  146. log("state changed : " + message)
  147. if state == linphone.CallState.Released:
  148. # Let's convert wav to mp3
  149. if call.current_params.record_file is not None and os.path.isfile(call.current_params.record_file):
  150. log("Converting output from wav to mp3")
  151. subprocess.call('lame --quiet --preset insane %s' % call.current_params.record_file, shell=True)
  152. os.remove(call.current_params.record_file)
  153. if state == linphone.CallState.IncomingReceived:
  154. log("Incoming call : {}".format(call.remote_address.username))
  155. self._replies_pos = 0
  156. if is_in_blacklists(call.remote_address.username):
  157. log("telemarketer calling : " + call.remote_address.username)
  158. call_params = core.create_call_params(call)
  159. a_file = current_dir + "/out/call_from_" + slugify(call.remote_address.username) + \
  160. "_" + datetime.now().strftime(
  161. '%Y-%m-%d_%Hh%Mmn%Ss') + ".wav"
  162. log(a_file)
  163. call_params.record_file = a_file
  164. # Let ring some time
  165. sleep(4)
  166. core.accept_call_with_params(call, call_params)
  167. call.start_recording()
  168. sleep(2)
  169. t = threading.Thread(target=self.incoming_stream_worker, args=[core, call])
  170. t.start()
  171. self.say(core)
  172. def __enter__(self):
  173. return self
  174. def __exit__(self, exc_type, exc_value, traceback):
  175. log("Cleaning on exit ...")
  176. os.unlink(self._incoming_stream_file)
  177. def start(self):
  178. print("starting")
  179. self._core.use_files = True
  180. self._core.record_file = self._incoming_stream_file
  181. proxy_cfg = self._core.create_proxy_config()
  182. proxy_cfg.identity_address = self._core.create_address('sip:' + self._username + '@' + self._domain + ':5060')
  183. proxy_cfg.server_addr = 'sip:' + self._domain + ':5060'
  184. proxy_cfg.register_enabled = True
  185. self._core.add_proxy_config(proxy_cfg)
  186. auth_info = self._core.create_auth_info(self._username, None, self._password, None, None, self._domain)
  187. self._core.add_auth_info(auth_info)
  188. while not self._is_quitting:
  189. sleep(0.03)
  190. self._core.iterate()
  191. def request_quit(self):
  192. self._is_quitting = True
  193. self.__exit__(None, None, None)
  194. def __init__(self, domain, username, password):
  195. print("init")
  196. callbacks = {
  197. 'call_state_changed': self.call_state_changed
  198. }
  199. self._core = linphone.Core.new(callbacks, None, None)
  200. self._domain = domain
  201. self._username = username
  202. self._password = password
  203. self._is_quitting = False
  204. self._conversation = Conversation()
  205. self._replies_pos = 0
  206. self._volume_threshold = VOLUME_THRESHOLD
  207. self._incoming_stream_file = tempfile.NamedTemporaryFile(delete=False).name
  208. if __name__ == "__main__":
  209. connections = []
  210. def signal_handler(signal, frame):
  211. print('External stop request!')
  212. for conn in connections:
  213. conn.request_quit()
  214. conn1 = SipConnection("192.168.1.1", "621", "toto")
  215. connections.append(conn1)
  216. threading.Thread(target=conn1.start).start()
  217. signal.signal(signal.SIGINT, signal_handler)
  218. signal.signal(signal.SIGTERM, signal_handler)
  219. signal.pause()
  220. # TODO : créer un fichier de config
  221. # TODO : pouvoir s'enregistrer sur plusieurs comptes SIP
  222. # TODO : De pouvoir utiliser des mp3 pour gagner de la place, qu'on convertit au vol en wav, puis qu'on efface.
  223. # TODO : Ecrire le readme