lenny.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  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 linphone
  16. from enum import Enum
  17. VOLUME_THRESHOLD = 100
  18. def slugify(s):
  19. """
  20. Normalizes string, converts to lowercase, removes non-alpha characters,
  21. and converts spaces to hyphens, wich is url/filename friendly.
  22. """
  23. valid_chars = "-_.() %s%s" % (string.ascii_letters, string.digits)
  24. filename = ''.join(c for c in s if c in valid_chars)
  25. filename = filename.replace(' ', '_') # I don't like spaces in filenames.
  26. return filename
  27. class ConversationStatus(Enum):
  28. READY_TO_TALK = 0
  29. IMTALKING = 1
  30. WAITFORANSWER = 2
  31. class Conversation(object):
  32. def __init__(self):
  33. self._status = ConversationStatus.READY_TO_TALK
  34. @property
  35. def status(self):
  36. return self._status
  37. @status.setter
  38. def status(self, value):
  39. if value != self._status:
  40. self._status = value
  41. current_dir = os.path.dirname(os.path.realpath(__file__))
  42. replies_seq = glob.glob(current_dir + "/replies/sequence/*.wav")
  43. replies_seq.sort()
  44. replies_generic = glob.glob(current_dir + "/replies/generic/*.wav")
  45. replies_generic.sort()
  46. incoming_stream_file = "/tmp/lenny"
  47. conversation = Conversation()
  48. replies_pos = 0
  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. def say(core):
  95. global conversation
  96. global replies_pos
  97. if conversation.status is not ConversationStatus.IMTALKING:
  98. 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[replies_pos]
  103. replies_pos = (replies_pos + 1) % len(replies_seq)
  104. if replies_pos == 0:
  105. # On ne rejoue jamais la première réplique "allo"
  106. 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. conversation.status = ConversationStatus.WAITFORANSWER
  114. def call_state_changed(core, call, state, message):
  115. global conversation
  116. global replies_pos
  117. log("state changed : " + message)
  118. if state == linphone.CallState.Released:
  119. # Let's convert wav to mp3
  120. log("Converting output from wav to mp3")
  121. if call.current_params.record_file is not None and os.path.isfile(call.current_params.record_file):
  122. subprocess.call('lame --quiet --preset insane %s' % call.current_params.record_file, shell=True)
  123. os.remove(call.current_params.record_file)
  124. if state == linphone.CallState.IncomingReceived:
  125. log("Incoming call : {}".format(call.remote_address.username))
  126. replies_pos = 0
  127. if is_in_blacklists(call.remote_address.username):
  128. log("telemarketer calling : " + call.remote_address.username)
  129. call_params = core.create_call_params(call)
  130. a_file = current_dir + "/out/call_from_" + slugify(call.remote_address.username) + \
  131. "_" + datetime.now().strftime(
  132. '%Y-%m-%d_%Hh%Mmn%Ss') + ".wav"
  133. log(a_file)
  134. call_params.record_file = a_file
  135. # Let ring some time
  136. sleep(5)
  137. core.accept_call_with_params(call, call_params)
  138. call.start_recording()
  139. sleep(2)
  140. t = threading.Thread(target=incoming_stream_worker, args=[core, call])
  141. t.start()
  142. say(core)
  143. def incoming_stream_worker(core, call):
  144. global conversation
  145. log("Worker is starting")
  146. f = open(incoming_stream_file, "rb")
  147. f.seek(0, io.SEEK_END)
  148. p = f.tell()
  149. buf = ''
  150. previous_status = conversation.status
  151. while call.state is not linphone.CallState.End and not THREADS_MUST_QUIT:
  152. if conversation.status is ConversationStatus.IMTALKING:
  153. f.seek(0, io.SEEK_END)
  154. p = f.tell()
  155. else:
  156. if previous_status != conversation.status:
  157. f.seek(0, io.SEEK_END)
  158. p = f.tell()
  159. f.seek(p)
  160. buf += f.read(4096)
  161. p = f.tell()
  162. if len(buf) >= 20000:
  163. volume = audioop.rms(buf, 2)
  164. print("Detected volume : " + str(volume))
  165. # print("State : " + str(conversation.status))
  166. buf = ''
  167. if volume < VOLUME_THRESHOLD:
  168. if conversation.status is ConversationStatus.READY_TO_TALK:
  169. threading.Thread(target=say, args=[core]).start()
  170. else:
  171. conversation.status = ConversationStatus.READY_TO_TALK
  172. # We must sleep a bit to avoid cpu hog
  173. sleep(0.01)
  174. previous_status = conversation.status
  175. log("Worker is quitting")
  176. def main():
  177. log("lenny is starting ...")
  178. callbacks = {
  179. 'call_state_changed': call_state_changed
  180. }
  181. username = "621"
  182. password = "toto"
  183. port = "5060"
  184. domain = "192.168.1.1"
  185. core = linphone.Core.new(callbacks, None, None)
  186. # On fait le setup pour la capture et analyse du stream entrant
  187. os.system("rm -rf " + incoming_stream_file)
  188. os.system("touch " + incoming_stream_file)
  189. core.use_files = True
  190. core.record_file = incoming_stream_file
  191. proxy_cfg = core.create_proxy_config()
  192. proxy_cfg.identity_address = core.create_address('sip:' + username + '@' + domain + ':' + port)
  193. proxy_cfg.server_addr = 'sip:' + domain + ':' + port
  194. proxy_cfg.register_enabled = True
  195. core.add_proxy_config(proxy_cfg)
  196. auth_info = core.create_auth_info(username, None, password, None, None, domain)
  197. core.add_auth_info(auth_info)
  198. while True:
  199. sleep(0.03)
  200. core.iterate()
  201. if __name__ == "__main__":
  202. try:
  203. main()
  204. except KeyboardInterrupt:
  205. THREADS_MUST_QUIT = True
  206. print "Bye"
  207. # TODO : créer un fichier de config
  208. # TODO : pouvoir s'enregistrer sur plusieurs comptes SIP
  209. # TODO : Que le stream entrant utilise un fichier temporaire
  210. # TODO : De pouvoir utiliser des mp3 pour gagner de la place, qu'on convertit au vol en wav, puis qu'on efface.
  211. # TODO : Ecrire le readme