lenny.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  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 + "(duration : " + str(duration) + ")")
  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. log("Worker is starting")
  116. f = open(self._incoming_stream_file, "rb")
  117. f.seek(0, io.SEEK_END)
  118. p = f.tell()
  119. buf = ''
  120. previous_status = self._conversation.status
  121. while call.state is not linphone.CallState.End and not self._is_quitting:
  122. if self._conversation.status is ConversationStatus.IMTALKING:
  123. f.seek(0, io.SEEK_END)
  124. p = f.tell()
  125. else:
  126. if previous_status != self._conversation.status:
  127. f.seek(0, io.SEEK_END)
  128. p = f.tell()
  129. f.seek(p)
  130. buf += f.read(4096)
  131. p = f.tell()
  132. if len(buf) >= 20000:
  133. volume = audioop.rms(buf, 2)
  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("Saving to mp3 : " + call.current_params.record_file)
  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(str(self) + ": cleaning on exit ...")
  176. os.unlink(self._incoming_stream_file)
  177. def start(self):
  178. log("starting " + str(self))
  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 __str__(self):
  195. return self._username + "@" + self._domain
  196. def __init__(self, domain, username, password):
  197. callbacks = {
  198. 'call_state_changed': self.call_state_changed
  199. }
  200. self._core = linphone.Core.new(callbacks, None, None)
  201. self._domain = domain
  202. self._username = username
  203. self._password = password
  204. self._is_quitting = False
  205. self._conversation = Conversation()
  206. self._replies_pos = 0
  207. self._volume_threshold = VOLUME_THRESHOLD
  208. self._incoming_stream_file = tempfile.NamedTemporaryFile(delete=False).name
  209. if __name__ == "__main__":
  210. cfg = ConfigParser.SafeConfigParser()
  211. cfg_path = current_dir + "/config.ini"
  212. if len(sys.argv) == 2:
  213. cfg_path = sys.argv[1]
  214. connections = []
  215. cfg.read(cfg_path)
  216. for c in cfg.sections():
  217. connections.append(SipConnection(cfg.get(c, "domain"), cfg.get(c, "username"), cfg.get(c, "password")))
  218. for sip_c in connections:
  219. threading.Thread(target=sip_c.start).start()
  220. # Ensuring clean quit and ressource releasing
  221. # when receiving ctrl-c from console or SIGTERM
  222. # from daemon manager.
  223. def signal_handler(sig, frame):
  224. print('External stop request!')
  225. for conn in connections:
  226. conn.request_quit()
  227. signal.signal(signal.SIGINT, signal_handler)
  228. signal.signal(signal.SIGTERM, signal_handler)
  229. signal.pause()