lenny.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  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. return is_in_local_blacklist(a_number) or is_in_ktipp_blacklist(a_number) or is_in_shiansw_blacklist(a_number)
  55. def is_in_local_blacklist(a_number):
  56. black_list = current_dir + "/blacklist.txt"
  57. if os.path.isfile(black_list):
  58. return a_number in open(current_dir + "/blacklist.txt").read()
  59. def is_in_ktipp_blacklist(a_number):
  60. # On peut interroger le site ktipp:
  61. # https://www.ktipp.ch/service/warnlisten/detail/?warnliste_id=7&ajax=ajax-search-form&keyword=0445510503
  62. # Si argument keyword pas trouvé, ca donne ca dans la réponse :
  63. # 0 Einträge
  64. url = KTIP_LOOKUP_URL.replace("{$number$}", a_number)
  65. log("KTIP lookup : " + url)
  66. response = ""
  67. try:
  68. response = urllib2.urlopen(url).read()
  69. except urllib2.HTTPError:
  70. pass
  71. return "0 Eintr" not in response
  72. def is_in_shiansw_blacklist(a_number):
  73. url = SHOULDIANSWER_LOOKUP_URL.replace("{$number$}", a_number)
  74. log("SIANSW lookup : " + url)
  75. response = ""
  76. try:
  77. response = urllib2.urlopen(url).read()
  78. except urllib2.HTTPError:
  79. pass
  80. return '<div class="review_score negative"></div>' in response
  81. def log(msg):
  82. syslog.syslog(msg)
  83. print(msg)
  84. def get_wav_duration(fname):
  85. with contextlib.closing(wave.open(fname, 'r')) as f:
  86. frames = f.getnframes()
  87. rate = f.getframerate()
  88. return frames / float(rate)
  89. def sleep(duration):
  90. dummy_event = threading.Event()
  91. dummy_event.wait(timeout=duration)
  92. def say(core):
  93. global conversation
  94. global replies_pos
  95. if conversation.status is not ConversationStatus.IMTALKING:
  96. conversation.status = ConversationStatus.IMTALKING
  97. # On joue les repliques en sequence, puis quand
  98. # on arrive au bout, on en joue une au hasard
  99. # du groupe 'generic'
  100. voice_filename = replies_seq[replies_pos]
  101. replies_pos = (replies_pos + 1) % len(replies_seq)
  102. if replies_pos == 0:
  103. # On ne rejoue jamais la première réplique "allo"
  104. replies_pos = 1
  105. duration = get_wav_duration(voice_filename)
  106. log("Saying : " + voice_filename + "(duration : " + str(duration) + ")")
  107. core.play_file = voice_filename
  108. sleep(duration)
  109. core.play_file = ""
  110. # On laisse l'autre l'occassion de reparler
  111. conversation.status = ConversationStatus.WAITFORANSWER
  112. def call_state_changed(core, call, state, message):
  113. global conversation
  114. global replies_pos
  115. log("state changed : " + message)
  116. if state == linphone.CallState.Released:
  117. # Let's convert wav to mp3
  118. log("Converting output from wav to mp3")
  119. if call.current_params.record_file is not None and os.path.isfile(call.current_params.record_file):
  120. subprocess.call('lame --quiet --preset insane %s' % call.current_params.record_file, shell=True)
  121. os.remove(call.current_params.record_file)
  122. if state == linphone.CallState.IncomingReceived:
  123. log("Incoming call : {}".format(call.remote_address.username))
  124. replies_pos = 0
  125. if is_in_blacklists(call.remote_address.username):
  126. log("telemarketer calling : " + call.remote_address.username)
  127. call_params = core.create_call_params(call)
  128. a_file = current_dir + "/out/call_from_" + slugify(call.remote_address.username) + \
  129. "_" + datetime.now().strftime(
  130. '%Y-%m-%d_%Hh%Mmn%Ss') + ".wav"
  131. log(a_file)
  132. call_params.record_file = a_file
  133. # Let ring some time
  134. sleep(5)
  135. core.accept_call_with_params(call, call_params)
  136. call.start_recording()
  137. sleep(2)
  138. t = threading.Thread(target=incoming_stream_worker, args=[core, call])
  139. t.start()
  140. say(core)
  141. def incoming_stream_worker(core, call):
  142. global conversation
  143. log("Worker is starting")
  144. f = open(incoming_stream_file, "rb")
  145. f.seek(0, io.SEEK_END)
  146. p = f.tell()
  147. buf = ''
  148. previous_status = conversation.status
  149. while call.state is not linphone.CallState.End and not THREADS_MUST_QUIT:
  150. if conversation.status is ConversationStatus.IMTALKING:
  151. f.seek(0, io.SEEK_END)
  152. p = f.tell()
  153. else:
  154. if previous_status != conversation.status:
  155. f.seek(0, io.SEEK_END)
  156. p = f.tell()
  157. f.seek(p)
  158. buf += f.read(4096)
  159. p = f.tell()
  160. if len(buf) >= 20000:
  161. volume = audioop.rms(buf, 2)
  162. print("Detected volume : " + str(volume))
  163. # print("State : " + str(conversation.status))
  164. buf = ''
  165. if volume < VOLUME_THRESHOLD:
  166. if conversation.status is ConversationStatus.READY_TO_TALK:
  167. threading.Thread(target=say, args=[core]).start()
  168. else:
  169. conversation.status = ConversationStatus.READY_TO_TALK
  170. # We must sleep a bit to avoid cpu hog
  171. sleep(0.01)
  172. previous_status = conversation.status
  173. log("Worker is quitting")
  174. def main():
  175. log("lenny is starting ...")
  176. callbacks = {
  177. 'call_state_changed': call_state_changed
  178. }
  179. username = "621"
  180. password = "toto"
  181. port = "5060"
  182. domain = "192.168.1.1"
  183. core = linphone.Core.new(callbacks, None, None)
  184. # On fait le setup pour la capture et analyse du stream entrant
  185. os.system("rm -rf " + incoming_stream_file)
  186. os.system("touch " + incoming_stream_file)
  187. core.use_files = True
  188. core.record_file = incoming_stream_file
  189. proxy_cfg = core.create_proxy_config()
  190. proxy_cfg.identity_address = core.create_address('sip:' + username + '@' + domain + ':' + port)
  191. proxy_cfg.server_addr = 'sip:' + domain + ':' + port
  192. proxy_cfg.register_enabled = True
  193. core.add_proxy_config(proxy_cfg)
  194. auth_info = core.create_auth_info(username, None, password, None, None, domain)
  195. core.add_auth_info(auth_info)
  196. while True:
  197. sleep(0.03)
  198. core.iterate()
  199. if __name__ == "__main__":
  200. try:
  201. main()
  202. except KeyboardInterrupt:
  203. THREADS_MUST_QUIT = True
  204. print "Bye"
  205. # TODO : créer un fichier de config
  206. # TODO : pouvoir s'enregistrer sur plusieurs comptes SIP
  207. # TODO : Que le stream entrant utilise un fichier temporaire
  208. # TODO : De pouvoir utiliser des mp3 pour gagner de la place, qu'on convertit au vol en wav, puis qu'on efface.
  209. # TODO : Ecrire le readme