lenny.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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. the_number = a_number.lstrip("0")
  56. the_number = the_number.replace("+", "")
  57. return is_in_local_blacklist(the_number) or is_in_ktipp_blacklist(the_number) or is_in_shiansw_blacklist(the_number)
  58. def is_in_local_blacklist(a_number):
  59. black_list = current_dir + "/blacklist.txt"
  60. if os.path.isfile(black_list):
  61. return a_number in open(current_dir + "/blacklist.txt").read()
  62. def is_in_ktipp_blacklist(a_number):
  63. # On peut interroger le site ktipp:
  64. # https://www.ktipp.ch/service/warnlisten/detail/?warnliste_id=7&ajax=ajax-search-form&keyword=0445510503
  65. # Si argument keyword pas trouvé, ca donne ca dans la réponse :
  66. # 0 Einträge
  67. url = KTIP_LOOKUP_URL.replace("{$number$}", a_number)
  68. log("KTIP lookup : " + url)
  69. response = ""
  70. try:
  71. response = urllib2.urlopen(url).read()
  72. except urllib2.HTTPError:
  73. pass
  74. return "0 Eintr" not in response
  75. def is_in_shiansw_blacklist(a_number):
  76. url = SHOULDIANSWER_LOOKUP_URL.replace("{$number$}", a_number)
  77. log("SIANSW lookup : " + url)
  78. response = ""
  79. try:
  80. response = urllib2.urlopen(url).read()
  81. except urllib2.HTTPError:
  82. pass
  83. return '<div class="review_score negative"></div>' in response
  84. def log(msg):
  85. syslog.syslog(msg)
  86. print(msg)
  87. def get_wav_duration(fname):
  88. with contextlib.closing(wave.open(fname, 'r')) as f:
  89. frames = f.getnframes()
  90. rate = f.getframerate()
  91. return frames / float(rate)
  92. def sleep(duration):
  93. dummy_event = threading.Event()
  94. dummy_event.wait(timeout=duration)
  95. class SipConnection(object):
  96. def say(self, core):
  97. if self._conversation.status is not ConversationStatus.IMTALKING:
  98. self._conversation.status = ConversationStatus.IMTALKING
  99. # On joue les repliques en sequence, puis quand
  100. # on arrive au bout, on en joue une au hasard
  101. # du groupe 'generic'
  102. voice_filename = replies_seq[self._replies_pos]
  103. self._replies_pos = (self._replies_pos + 1) % len(replies_seq)
  104. if self._replies_pos == 0:
  105. # On ne rejoue jamais la première réplique "allo"
  106. self._replies_pos = 1
  107. duration = get_wav_duration(voice_filename)
  108. log("Saying : " + voice_filename)
  109. core.play_file = voice_filename
  110. sleep(duration)
  111. core.play_file = ""
  112. # On laisse l'autre l'occassion de reparler
  113. self._conversation.status = ConversationStatus.WAITFORANSWER
  114. def incoming_stream_worker(self, core, call):
  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("State : " + str(conversation.status))
  134. buf = ''
  135. if volume < self._volume_threshold:
  136. if self._conversation.status is ConversationStatus.READY_TO_TALK:
  137. threading.Thread(target=self.say, args=[core]).start()
  138. else:
  139. self._conversation.status = ConversationStatus.READY_TO_TALK
  140. # We must sleep a bit to avoid cpu hog
  141. sleep(0.01)
  142. previous_status = self._conversation.status
  143. log("Worker is quitting")
  144. @staticmethod
  145. def registration_state_changed(core, call, state, message):
  146. log("Registration status: " + message)
  147. def call_state_changed(self, core, call, state, message):
  148. log("state changed : " + message)
  149. if state == linphone.CallState.Released:
  150. # Let's convert wav to mp3
  151. if call.current_params.record_file is not None and os.path.isfile(call.current_params.record_file):
  152. log("Saving to mp3 : " + call.current_params.record_file)
  153. subprocess.call('lame --quiet --preset insane %s' % call.current_params.record_file, shell=True)
  154. os.remove(call.current_params.record_file)
  155. if state == linphone.CallState.IncomingReceived:
  156. log("Incoming call : {}".format(call.remote_address.username))
  157. self._replies_pos = 0
  158. if is_in_blacklists(call.remote_address.username):
  159. log("telemarketer calling : " + call.remote_address.username)
  160. call_params = core.create_call_params(call)
  161. os.makedirs(current_dir + "/out")
  162. a_file = current_dir + "/out/call_from_" + slugify(call.remote_address.username) + \
  163. "_" + datetime.now().strftime(
  164. '%Y-%m-%d_%Hh%Mmn%Ss') + ".wav"
  165. log(a_file)
  166. call_params.record_file = a_file
  167. # Let ring some time
  168. sleep(4)
  169. core.accept_call_with_params(call, call_params)
  170. call.start_recording()
  171. sleep(2)
  172. t = threading.Thread(target=self.incoming_stream_worker, args=[core, call])
  173. t.start()
  174. self.say(core)
  175. def __enter__(self):
  176. return self
  177. def __exit__(self, exc_type, exc_value, traceback):
  178. log(str(self) + ": cleaning on exit ...")
  179. os.unlink(self._incoming_stream_file)
  180. def start(self):
  181. log("starting " + str(self))
  182. self._core.use_files = True
  183. self._core.record_file = self._incoming_stream_file
  184. proxy_cfg = self._core.create_proxy_config()
  185. proxy_cfg.identity_address = self._core.create_address('sip:' + self._username + '@' + self._domain + ':5060')
  186. proxy_cfg.server_addr = 'sip:' + self._domain + ':5060'
  187. proxy_cfg.register_enabled = True
  188. self._core.add_proxy_config(proxy_cfg)
  189. auth_info = self._core.create_auth_info(self._username, None, self._password, None, None, self._domain)
  190. self._core.add_auth_info(auth_info)
  191. while not self._is_quitting:
  192. sleep(0.03)
  193. self._core.iterate()
  194. def request_quit(self):
  195. self._is_quitting = True
  196. self.__exit__(None, None, None)
  197. def __str__(self):
  198. return self._username + "@" + self._domain
  199. def __init__(self, domain, username, password):
  200. callbacks = {
  201. 'call_state_changed': self.call_state_changed,
  202. 'registration_state_changed': self.registration_state_changed,
  203. }
  204. self._core = linphone.Core.new(callbacks, None, None)
  205. self._domain = domain
  206. self._username = username
  207. self._password = password
  208. self._is_quitting = False
  209. self._conversation = Conversation()
  210. self._replies_pos = 0
  211. self._volume_threshold = VOLUME_THRESHOLD
  212. self._incoming_stream_file = tempfile.NamedTemporaryFile(delete=False).name
  213. self._core.iterate()
  214. if __name__ == "__main__":
  215. cfg = ConfigParser.SafeConfigParser()
  216. cfg_path = current_dir + "/config.ini"
  217. if len(sys.argv) == 2:
  218. cfg_path = sys.argv[1]
  219. connections = []
  220. cfg.read(cfg_path)
  221. for c in cfg.sections():
  222. connections.append(SipConnection(cfg.get(c, "domain"), cfg.get(c, "username"), cfg.get(c, "password")))
  223. for sip_c in connections:
  224. threading.Thread(target=sip_c.start).start()
  225. # Ensuring clean quit and ressource releasing
  226. # when receiving ctrl-c from console or SIGTERM
  227. # from daemon manager.
  228. def signal_handler(sig, frame):
  229. print('External stop request!')
  230. for conn in connections:
  231. conn.request_quit()
  232. signal.signal(signal.SIGINT, signal_handler)
  233. signal.signal(signal.SIGTERM, signal_handler)
  234. signal.pause()