lenny.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. #!/usr/bin/env python2
  2. # -*- coding: utf-8 -*-
  3. import random
  4. import ConfigParser
  5. import audioop
  6. import contextlib
  7. import glob
  8. import io
  9. import os
  10. import re
  11. import signal
  12. import smtplib
  13. import string
  14. import subprocess
  15. import sys
  16. import syslog
  17. import tempfile
  18. import threading
  19. import urllib2
  20. import wave
  21. from datetime import datetime
  22. from email.mime.text import MIMEText
  23. import linphone
  24. import yaml
  25. from enum import Enum
  26. VOLUME_THRESHOLD = 100
  27. current_dir = os.path.dirname(os.path.realpath(__file__))
  28. def slugify(s):
  29. """
  30. Normalizes string, converts to lowercase, removes non-alpha characters,
  31. and converts spaces to hyphens, wich is url/filename friendly.
  32. """
  33. valid_chars = "-_.() %s%s" % (string.ascii_letters, string.digits)
  34. filename = ''.join(cc for cc in s if cc in valid_chars)
  35. filename = filename.replace(' ', '_') # I don't like spaces in filenames.
  36. return filename
  37. def get_ip_from_logfile(remote_sip_client_tag, log_file):
  38. # Le log file contiendra, via la mise a jour dyndns custom du routeur, une ligne du type:
  39. # 84.227.205.102 - jf [23/Jun/2017:17:06:19 +0200] "GET /itslenny/domain=[essai]/ip=[84.227.205.102] HTTP/1.1" 404 169 "-" "Fritz!Box DDNS/1.0.1"
  40. cmd = 'cat ' + log_file + ' | grep "' + remote_sip_client_tag + '" | tail -n 1'
  41. reg = "domain=\[" + remote_sip_client_tag + "\]\/ip=\[(.*)\]"
  42. s = re.findall(reg, subprocess.check_output(["bash", "-c", cmd]))
  43. if len(s) > 0:
  44. return s[0]
  45. class RepliesFactory(object):
  46. def __init__(self, base_path):
  47. self._base_path = base_path
  48. self._start_replies = None
  49. self._random_replies = None
  50. self._pos = None
  51. self._latest = None
  52. self.reset()
  53. def reset(self):
  54. self._pos = 0
  55. self.reset_random()
  56. self.reset_start()
  57. def reset_start(self):
  58. self._start_replies = glob.glob(self._base_path + "/start_sequence/*.opus")
  59. self._start_replies.sort()
  60. def reset_random(self):
  61. self._random_replies = glob.glob(self._base_path + "/random/*.opus")
  62. def clear_latest(self):
  63. os.remove(self._latest)
  64. @staticmethod
  65. def convert_from_opus(opus_fname):
  66. wav_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name
  67. subprocess.call("opusdec --quiet " + opus_fname + " " + wav_file, shell=True)
  68. return wav_file
  69. def next(self):
  70. # On commence par la sequence du début
  71. if self._pos < len(self._start_replies):
  72. result = self._start_replies[self._pos]
  73. self._pos += 1
  74. else:
  75. # Puis une fois épuisée, on puisse dans celle random
  76. if len(self._random_replies) == 0:
  77. self.reset_random()
  78. result = random.choice(self._random_replies)
  79. self._random_replies.remove(result)
  80. original_file = result
  81. result = self.convert_from_opus(result)
  82. self._latest = result
  83. return result, original_file
  84. class ConversationStatus(Enum):
  85. READY_TO_TALK = 0
  86. IMTALKING = 1
  87. WAITFORANSWER = 2
  88. class Conversation(object):
  89. def __init__(self):
  90. self._status = ConversationStatus.READY_TO_TALK
  91. @property
  92. def status(self):
  93. return self._status
  94. @status.setter
  95. def status(self, value):
  96. if value != self._status:
  97. self._status = value
  98. THREADS_MUST_QUIT = False
  99. def get_wav_duration(fname):
  100. with contextlib.closing(wave.open(fname, 'r')) as f:
  101. frames = f.getnframes()
  102. rate = f.getframerate()
  103. return frames / float(rate)
  104. def sleep(duration):
  105. dummy_event = threading.Event()
  106. dummy_event.wait(timeout=duration)
  107. class SipConnection(object):
  108. class MailType(Enum):
  109. Notify_Incoming_Call = 1
  110. Notify_Incoming_Telemarketer_Call = 2
  111. def log(self, msg):
  112. to_show = str(self) + ": " + msg
  113. print(to_show)
  114. syslog.syslog(to_show)
  115. def mail(self, mail_cfg, text):
  116. try:
  117. server = smtplib.SMTP(mail_cfg["smtp_host"])
  118. msg = MIMEText(text)
  119. msg['Subject'] = text
  120. msg['From'] = mail_cfg["from"]
  121. msg['To'] = mail_cfg["to"]
  122. server.sendmail(mail_cfg["from"], [mail_cfg["to"]], msg.as_string())
  123. server.quit()
  124. except smtplib.SMTPException as e:
  125. self.log("Error sending email " + e.message)
  126. def say(self, core):
  127. if self._conversation.status is not ConversationStatus.IMTALKING:
  128. self._conversation.status = ConversationStatus.IMTALKING
  129. voice_filename, original_file = self._replies.next()
  130. duration = get_wav_duration(voice_filename)
  131. self.log("Saying : " + original_file)
  132. core.play_file = voice_filename
  133. sleep(duration)
  134. core.play_file = ""
  135. self._replies.clear_latest()
  136. # On laisse l'autre l'occassion de reparler
  137. self._conversation.status = ConversationStatus.WAITFORANSWER
  138. def storage_path(self):
  139. path = current_dir + "/out/" + str(self) + "/"
  140. if not os.path.isdir(path):
  141. os.makedirs(path)
  142. return path
  143. def shoul_daccept_first_call(self, number):
  144. first_call_file = self.storage_path() + "first_calls.txt"
  145. if not os.path.isfile(first_call_file):
  146. os.system("touch " + first_call_file)
  147. if "accept_first_call" in self._config_info and self._config_info["accept_first_call"] and number not in open(
  148. first_call_file).read():
  149. os.system("echo " + number + " `date` >> " + first_call_file)
  150. self.log("Accepting first call of " + number)
  151. return True
  152. return False
  153. def incoming_stream_worker(self, core, call):
  154. f = open(self._incoming_stream_file, "rb")
  155. f.seek(0, io.SEEK_END)
  156. p = f.tell()
  157. buf = ''
  158. previous_status = self._conversation.status
  159. while call.state is not linphone.CallState.End and not self._is_quitting:
  160. if self._conversation.status is ConversationStatus.IMTALKING:
  161. f.seek(0, io.SEEK_END)
  162. p = f.tell()
  163. else:
  164. if previous_status != self._conversation.status:
  165. f.seek(0, io.SEEK_END)
  166. p = f.tell()
  167. f.seek(p)
  168. buf += f.read(4096)
  169. p = f.tell()
  170. if len(buf) >= 20000:
  171. volume = audioop.rms(buf, 2)
  172. # print("State : " + str(conversation.status))
  173. buf = ''
  174. if volume < self._volume_threshold:
  175. if self._conversation.status is ConversationStatus.READY_TO_TALK:
  176. threading.Thread(target=self.say, args=[core]).start()
  177. else:
  178. self._conversation.status = ConversationStatus.READY_TO_TALK
  179. # We must sleep a bit to avoid cpu hog
  180. sleep(0.05)
  181. previous_status = self._conversation.status
  182. self.log("Worker is quitting")
  183. def registration_state_changed(self, core, call, state, message):
  184. # Le client se ré-enregistre a de multiple reprise, on
  185. # s'en tappe un peu d'en être informé.
  186. if message != self._registration_previous_message:
  187. self.log("Registration status: " + message)
  188. self._registration_previous_message = message
  189. def call_state_changed(self, core, call, state, message):
  190. self.log("state changed : " + message)
  191. if state == linphone.CallState.Released:
  192. # Let's convert wav to mp3
  193. if call.current_params.record_file is not None and os.path.isfile(call.current_params.record_file):
  194. self.log("Saving to mp3 : " + call.current_params.record_file)
  195. subprocess.call('lame --quiet --preset medium %s' % call.current_params.record_file, shell=True)
  196. os.remove(call.current_params.record_file)
  197. if state == linphone.CallState.IncomingReceived:
  198. self.log("Incoming call : {}".format(call.remote_address.username))
  199. self.mail_if_needed(call.remote_address.username, self.MailType.Notify_Incoming_Call)
  200. self._replies.reset()
  201. if self.shoul_daccept_first_call(call.remote_address.username) or self.is_in_blacklists(
  202. call.remote_address.username):
  203. self.log("telemarketer calling : " + call.remote_address.username)
  204. self.mail_if_needed(call.remote_address.username, self.MailType.Notify_Incoming_Telemarketer_Call)
  205. call_params = core.create_call_params(call)
  206. if not os.path.isdir(current_dir + "/out"):
  207. os.makedirs(current_dir + "/out")
  208. a_file = self.storage_path() + "call_from_" + slugify(call.remote_address.username) + \
  209. "_" + datetime.now().strftime(
  210. '%Y-%m-%d_%Hh%Mmn%Ss') + ".wav"
  211. self.log("Recording to : " + a_file)
  212. call_params.record_file = a_file
  213. # Let ring some time
  214. sleep(4)
  215. core.accept_call_with_params(call, call_params)
  216. call.start_recording()
  217. sleep(2)
  218. t = threading.Thread(target=self.incoming_stream_worker, args=[core, call])
  219. t.start()
  220. self.say(core)
  221. def __enter__(self):
  222. return self
  223. def __exit__(self, exc_type, exc_value, traceback):
  224. self.log(str(self) + ": cleaning on exit ...")
  225. os.unlink(self._incoming_stream_file)
  226. def get_domain_info(self):
  227. if "domain" in self._config_info:
  228. return self._config_info["domain"]
  229. if "domain_dyn_tag" in self._config_info and "domain_dyn_log" in self._config_info:
  230. return get_ip_from_logfile(self._config_info["domain_dyn_tag"], self._config_info["domain_dyn_log"])
  231. def set_connection_info(self):
  232. self._core.clear_proxy_config()
  233. proxy_cfg = self._core.create_proxy_config()
  234. domain = self.get_domain_info()
  235. proxy_cfg.identity_address = self._core.create_address(
  236. 'sip:' + self._config_info["username"] + '@' + domain + ':5060')
  237. proxy_cfg.server_addr = 'sip:' + domain + ':5060'
  238. proxy_cfg.register_enabled = True
  239. self._core.add_proxy_config(proxy_cfg)
  240. auth_info = self._core.create_auth_info(self._config_info["username"], None, self._config_info["password"],
  241. None, None, domain)
  242. self._core.add_auth_info(auth_info)
  243. def start(self):
  244. self.log("starting ")
  245. self._core.use_files = True
  246. self._core.record_file = self._incoming_stream_file
  247. self.set_connection_info()
  248. while not self._is_quitting:
  249. sleep(0.05)
  250. self._core.iterate()
  251. def request_quit(self):
  252. self._is_quitting = True
  253. self.__exit__(None, None, None)
  254. def __str__(self):
  255. return self._config_info["username"] + "@" + self.get_domain_info()
  256. def __init__(self, config_info):
  257. callbacks = {
  258. 'call_state_changed': self.call_state_changed,
  259. 'registration_state_changed': self.registration_state_changed,
  260. }
  261. self._config_info = config_info
  262. self._replies = RepliesFactory(current_dir + "/replies/")
  263. self._core = linphone.Core.new(callbacks, None, None)
  264. self._is_quitting = False
  265. self._registration_previous_message = ""
  266. self._conversation = Conversation()
  267. self._volume_threshold = VOLUME_THRESHOLD
  268. self._incoming_stream_file = tempfile.NamedTemporaryFile(delete=False).name
  269. self._core.iterate()
  270. def mail_if_needed(self, number, type):
  271. if "mailer" in self._config_info:
  272. mail_cfg = self._config_info["mailer"]
  273. if type == self.MailType.Notify_Incoming_Call:
  274. if mail_cfg["log_all_call"]:
  275. self.mail(mail_cfg, "Appel entrant : " + number)
  276. if type == self.MailType.Notify_Incoming_Telemarketer_Call:
  277. self.mail(mail_cfg, "Appel telemarketeur entrant : " + number)
  278. def is_in_blacklists(self, a_number):
  279. return self.is_in_local_blacklist(a_number) or self.is_in_directory_ch_blacklist(
  280. a_number) or self.is_in_ktipp_blacklist(a_number) \
  281. or self.is_in_shiansw_blacklist(a_number)
  282. def is_in_local_blacklist(self, a_number):
  283. black_list = current_dir + "/blacklist.txt"
  284. if os.path.isfile(black_list):
  285. res = a_number in open(current_dir + "/blacklist.txt").read()
  286. if res:
  287. self.log(a_number + " Found in localblacklist")
  288. return res
  289. def is_in_ktipp_blacklist(self, a_number):
  290. # On peut interroger le site ktipp:
  291. # https://www.ktipp.ch/service/warnlisten/detail/?warnliste_id=7&ajax=ajax-search-form&keyword=0445510503
  292. # Si argument keyword pas trouvé, ca donne ca dans la réponse :
  293. # 0 Einträge
  294. base_url = "https://www.ktipp.ch/service/warnlisten/detail/?warnliste_id=7&ajax=ajax-search-form&keyword={" \
  295. "$number$}"
  296. the_number = a_number.lstrip("0")
  297. the_number = the_number.replace("+", "")
  298. url = base_url.replace("{$number$}", the_number)
  299. response = ""
  300. try:
  301. opener = urllib2.build_opener()
  302. opener.addheaders = [('User-Agent', 'Mozilla/5.0')]
  303. response = opener.open(url).read()
  304. except urllib2.HTTPError as e:
  305. self.log(" Error during ktipp lookup : url="+url+", exception : "+str(e))
  306. res = "0 Eintr" not in response
  307. if res:
  308. self.log(a_number + " found in ktipp blacklist")
  309. return res
  310. def is_in_shiansw_blacklist(self, a_number):
  311. base_url = "https://ch.shouldianswer.net/telefonnummer/{$number$}"
  312. url = base_url.replace("{$number$}", a_number)
  313. response = ""
  314. try:
  315. response = urllib2.urlopen(url).read()
  316. except urllib2.HTTPError:
  317. pass
  318. res = '<div class="review_score negative"></div>' in response
  319. if res:
  320. self.log("Found in ch.shouldianswer.net blacklist")
  321. return res
  322. def is_in_directory_ch_blacklist(self, a_number):
  323. base_url = "https://tel.local.ch/fr/{$number$}"
  324. url = base_url.replace("{$number$}", a_number)
  325. response = ""
  326. try:
  327. response = urllib2.urlopen(url).read()
  328. except urllib2.HTTPError:
  329. pass
  330. res = 'https://tel.local.ch/fr/spamnumber/' in response
  331. if res:
  332. self.log(a_number + " found in directories.ch blacklist")
  333. return res
  334. if __name__ == "__main__":
  335. cfg = ConfigParser.SafeConfigParser()
  336. cfg_path = current_dir + "/config.yml"
  337. if len(sys.argv) == 2:
  338. cfg_path = sys.argv[1]
  339. connections = []
  340. for connection_cfg in yaml.load(file(cfg_path)):
  341. connections.append(SipConnection(connection_cfg))
  342. for sip_c in connections:
  343. threading.Thread(target=sip_c.start).start()
  344. # Ensuring clean quit and ressource releasing
  345. # when receiving ctrl-c from console or SIGTERM
  346. # from daemon manager.
  347. def signal_handler(sig, frame):
  348. print('External stop request!')
  349. for conn in connections:
  350. conn.request_quit()
  351. signal.signal(signal.SIGINT, signal_handler)
  352. signal.signal(signal.SIGTERM, signal_handler)
  353. signal.pause()