From 91230ac101762f1fd953e3c744374f848040099f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 3 Jul 2017 07:19:01 +0200 Subject: [PATCH 01/57] modulecontext: use inheritance instead of conditional init --- nemubot/__init__.py | 4 +- nemubot/bot.py | 4 +- nemubot/modulecontext.py | 183 +++++++++++++++++++++------------------ 3 files changed, 101 insertions(+), 90 deletions(-) diff --git a/nemubot/__init__.py b/nemubot/__init__.py index a56c472..42a2fba 100644 --- a/nemubot/__init__.py +++ b/nemubot/__init__.py @@ -17,9 +17,9 @@ __version__ = '4.0.dev3' __author__ = 'nemunaire' -from nemubot.modulecontext import ModuleContext +from nemubot.modulecontext import _ModuleContext -context = ModuleContext(None, None) +context = _ModuleContext() def requires_version(min=None, max=None): diff --git a/nemubot/bot.py b/nemubot/bot.py index 42f9aa7..bb97e49 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -478,7 +478,7 @@ class Bot(threading.Thread): module.print = prnt # Create module context - from nemubot.modulecontext import ModuleContext + from nemubot.modulecontext import _ModuleContext, ModuleContext module.__nemubot_context__ = ModuleContext(self, module) if not hasattr(module, "logger"): @@ -486,7 +486,7 @@ class Bot(threading.Thread): # Replace imported context by real one for attr in module.__dict__: - if attr != "__nemubot_context__" and type(module.__dict__[attr]) == ModuleContext: + if attr != "__nemubot_context__" and isinstance(module.__dict__[attr], _ModuleContext): module.__dict__[attr] = module.__nemubot_context__ # Register decorated functions diff --git a/nemubot/modulecontext.py b/nemubot/modulecontext.py index 1d1b3d0..877b8de 100644 --- a/nemubot/modulecontext.py +++ b/nemubot/modulecontext.py @@ -1,5 +1,5 @@ # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2015 Mercier Pierre-Olivier +# Copyright (C) 2012-2017 Mercier Pierre-Olivier # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -14,105 +14,61 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -class ModuleContext: +class _ModuleContext: - def __init__(self, context, module): - """Initialize the module context - - arguments: - context -- the bot context - module -- the module - """ + def __init__(self, module=None): + self.module = module if module is not None: - module_name = module.__spec__.name if hasattr(module, "__spec__") else module.__name__ + self.module_name = module.__spec__.name if hasattr(module, "__spec__") else module.__name__ else: - module_name = "" - - # Load module configuration if exists - if (context is not None and - module_name in context.modules_configuration): - self.config = context.modules_configuration[module_name] - else: - from nemubot.config.module import Module - self.config = Module(module_name) + self.module_name = "" self.hooks = list() self.events = list() - self.debug = context.verbosity > 0 if context is not None else False + self.debug = False + from nemubot.config.module import Module + self.config = Module(self.module_name) + + + def load_data(self): + from nemubot.tools.xmlparser import module_state + return module_state.ModuleState("nemubotstate") + + def add_hook(self, hook, *triggers): from nemubot.hooks import Abstract as AbstractHook + assert isinstance(hook, AbstractHook), hook + self.hooks.append((triggers, hook)) - # Define some callbacks - if context is not None: - def load_data(): - return context.datastore.load(module_name) + def del_hook(self, hook, *triggers): + from nemubot.hooks import Abstract as AbstractHook + assert isinstance(hook, AbstractHook), hook + self.hooks.remove((triggers, hook)) - def add_hook(hook, *triggers): - assert isinstance(hook, AbstractHook), hook - self.hooks.append((triggers, hook)) - return context.treater.hm.add_hook(hook, *triggers) + def subtreat(self, msg): + return None - def del_hook(hook, *triggers): - assert isinstance(hook, AbstractHook), hook - self.hooks.remove((triggers, hook)) - return context.treater.hm.del_hooks(*triggers, hook=hook) + def add_event(self, evt, eid=None): + return self.events.append((evt, eid)) - def subtreat(msg): - yield from context.treater.treat_msg(msg) - def add_event(evt, eid=None): - return context.add_event(evt, eid, module_src=module) - def del_event(evt): - return context.del_event(evt, module_src=module) + def del_event(self, evt): + for i in self.events: + e, eid = i + if e == evt: + self.events.remove(i) + return True + return False - def send_response(server, res): - if server in context.servers: - if res.server is not None: - return context.servers[res.server].send_response(res) - else: - return context.servers[server].send_response(res) - else: - module.logger.error("Try to send a message to the unknown server: %s", server) - return False + def send_response(self, server, res): + self.module.logger.info("Send response: %s", res) - else: # Used when using outside of nemubot - def load_data(): - from nemubot.tools.xmlparser import module_state - return module_state.ModuleState("nemubotstate") - - def add_hook(hook, *triggers): - assert isinstance(hook, AbstractHook), hook - self.hooks.append((triggers, hook)) - def del_hook(hook, *triggers): - assert isinstance(hook, AbstractHook), hook - self.hooks.remove((triggers, hook)) - def subtreat(msg): - return None - def add_event(evt, eid=None): - return context.add_event(evt, eid, module_src=module) - def del_event(evt): - return context.del_event(evt, module_src=module) - - def send_response(server, res): - module.logger.info("Send response: %s", res) - - def save(): - context.datastore.save(module_name, self.data) - - def subparse(orig, cnt): - if orig.server in context.servers: - return context.servers[orig.server].subparse(orig, cnt) - - self.load_data = load_data - self.add_hook = add_hook - self.del_hook = del_hook - self.add_event = add_event - self.del_event = del_event - self.save = save - self.send_response = send_response - self.subtreat = subtreat - self.subparse = subparse + def save(self): + self.context.datastore.save(self.module_name, self.data) + def subparse(self, orig, cnt): + if orig.server in self.context.servers: + return self.context.servers[orig.server].subparse(orig, cnt) @property def data(self): @@ -129,7 +85,62 @@ class ModuleContext: self.del_hook(h, *s) # Remove registered events - for e in self.events: - self.del_event(e) + for evt, eid, module_src in self.events: + self.del_event(evt) self.save() + + +class ModuleContext(_ModuleContext): + + def __init__(self, context, *args, **kwargs): + """Initialize the module context + + arguments: + context -- the bot context + module -- the module + """ + + super().__init__(*args, **kwargs) + + # Load module configuration if exists + if self.module_name in context.modules_configuration: + self.config = context.modules_configuration[self.module_name] + + self.context = context + self.debug = context.verbosity > 0 + + + def load_data(self): + return self.context.datastore.load(self.module_name) + + def add_hook(self, hook, *triggers): + from nemubot.hooks import Abstract as AbstractHook + assert isinstance(hook, AbstractHook), hook + self.hooks.append((triggers, hook)) + return self.context.treater.hm.add_hook(hook, *triggers) + + def del_hook(self, hook, *triggers): + from nemubot.hooks import Abstract as AbstractHook + assert isinstance(hook, AbstractHook), hook + self.hooks.remove((triggers, hook)) + return self.context.treater.hm.del_hooks(*triggers, hook=hook) + + def subtreat(self, msg): + yield from self.context.treater.treat_msg(msg) + + def add_event(self, evt, eid=None): + return self.context.add_event(evt, eid, module_src=self.module) + + def del_event(self, evt): + return self.context.del_event(evt, module_src=self.module) + + def send_response(self, server, res): + if server in self.context.servers: + if res.server is not None: + return self.context.servers[res.server].send_response(res) + else: + return self.context.servers[server].send_response(res) + else: + self.module.logger.error("Try to send a message to the unknown server: %s", server) + return False From 465bfefdab583fa365eba0250a8456251aaaeb8f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 10 Nov 2016 18:32:50 +0100 Subject: [PATCH 02/57] Update weather module: refleting forcastAPI changes --- modules/weather.py | 78 +++++++++++++++++----------------------------- 1 file changed, 28 insertions(+), 50 deletions(-) diff --git a/modules/weather.py b/modules/weather.py index 1fadc71..8b3540e 100644 --- a/modules/weather.py +++ b/modules/weather.py @@ -1,6 +1,6 @@ # coding=utf-8 -"""The weather module""" +"""The weather module. Powered by Dark Sky """ import datetime import re @@ -17,7 +17,7 @@ nemubotversion = 4.0 from more import Response -URL_DSAPI = "https://api.forecast.io/forecast/%s/%%s,%%s" +URL_DSAPI = "https://api.darksky.net/forecast/%s/%%s,%%s?lang=%%s&units=%%s" def load(context): if not context.config or "darkskyapikey" not in context.config: @@ -30,52 +30,14 @@ def load(context): URL_DSAPI = URL_DSAPI % context.config["darkskyapikey"] -def help_full (): - return "!weather /city/: Display the current weather in /city/." - - -def fahrenheit2celsius(temp): - return int((temp - 32) * 50/9)/10 - - -def mph2kmph(speed): - return int(speed * 160.9344)/100 - - -def inh2mmh(size): - return int(size * 254)/10 - - def format_wth(wth): - return ("%s °C %s; precipitation (%s %% chance) intensity: %s mm/h; relative humidity: %s %%; wind speed: %s km/h %s°; cloud coverage: %s %%; pressure: %s hPa; ozone: %s DU" % - ( - fahrenheit2celsius(wth["temperature"]), - wth["summary"], - int(wth["precipProbability"] * 100), - inh2mmh(wth["precipIntensity"]), - int(wth["humidity"] * 100), - mph2kmph(wth["windSpeed"]), - wth["windBearing"], - int(wth["cloudCover"] * 100), - int(wth["pressure"]), - int(wth["ozone"]) - )) + return ("{temperature} °C {summary}; precipitation ({precipProbability:.0%} chance) intensity: {precipIntensity} mm/h; relative humidity: {humidity:.0%}; wind speed: {windSpeed} km/s {windBearing}°; cloud coverage: {cloudCover:.0%}; pressure: {pressure} hPa; ozone: {ozone} DU" + .format(**wth) + ) def format_forecast_daily(wth): - return ("%s; between %s-%s °C; precipitation (%s %% chance) intensity: maximum %s mm/h; relative humidity: %s %%; wind speed: %s km/h %s°; cloud coverage: %s %%; pressure: %s hPa; ozone: %s DU" % - ( - wth["summary"], - fahrenheit2celsius(wth["temperatureMin"]), fahrenheit2celsius(wth["temperatureMax"]), - int(wth["precipProbability"] * 100), - inh2mmh(wth["precipIntensityMax"]), - int(wth["humidity"] * 100), - mph2kmph(wth["windSpeed"]), - wth["windBearing"], - int(wth["cloudCover"] * 100), - int(wth["pressure"]), - int(wth["ozone"]) - )) + return ("{summary}; between {temperatureMin}-{temperatureMax} °C; precipitation ({precipProbability:.0%} chance) intensity: maximum {precipIntensity} mm/h; relative humidity: {humidity:.0%}; wind speed: {windSpeed} km/h {windBearing}°; cloud coverage: {cloudCover:.0%}; pressure: {pressure} hPa; ozone: {ozone} DU".format(**wth)) def format_timestamp(timestamp, tzname, tzoffset, format="%c"): @@ -126,8 +88,8 @@ def treat_coord(msg): raise IMException("indique-moi un nom de ville ou des coordonnées.") -def get_json_weather(coords): - wth = web.getJSON(URL_DSAPI % (float(coords[0]), float(coords[1]))) +def get_json_weather(coords, lang="en", units="auto"): + wth = web.getJSON(URL_DSAPI % (float(coords[0]), float(coords[1]), lang, units)) # First read flags if wth is None or "darksky-unavailable" in wth["flags"]: @@ -149,10 +111,16 @@ def cmd_coordinates(msg): return Response("Les coordonnées de %s sont %s,%s" % (msg.args[0], coords["lat"], coords["long"]), channel=msg.channel) -@hook.command("alert") +@hook.command("alert", + keywords={ + "lang=LANG": "change the output language of weather sumarry; default: en", + "units=UNITS": "return weather conditions in the requested units; default: auto", + }) def cmd_alert(msg): loc, coords, specific = treat_coord(msg) - wth = get_json_weather(coords) + wth = get_json_weather(coords, + lang=msg.kwargs["lang"] if "lang" in msg.kwargs else "en", + units=msg.kwargs["units"] if "units" in msg.kwargs else "auto") res = Response(channel=msg.channel, nomore="No more weather alert", count=" (%d more alerts)") @@ -166,10 +134,20 @@ def cmd_alert(msg): return res -@hook.command("météo") +@hook.command("météo", + help="Display current weather and previsions", + help_usage={ + "CITY": "Display the current weather and previsions in CITY", + }, + keywords={ + "lang=LANG": "change the output language of weather sumarry; default: en", + "units=UNITS": "return weather conditions in the requested units; default: auto", + }) def cmd_weather(msg): loc, coords, specific = treat_coord(msg) - wth = get_json_weather(coords) + wth = get_json_weather(coords, + lang=msg.kwargs["lang"] if "lang" in msg.kwargs else "en", + units=msg.kwargs["units"] if "units" in msg.kwargs else "auto") res = Response(channel=msg.channel, nomore="No more weather information") From b1ad4bcf23a422ae9b7bd219f98ebc5f1757c540 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 4 Jul 2017 06:53:34 +0200 Subject: [PATCH 03/57] tools/web: forward all arguments passed to getJSON and getXML to getURLContent --- nemubot/tools/web.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/nemubot/tools/web.py b/nemubot/tools/web.py index d35740c..dc967be 100644 --- a/nemubot/tools/web.py +++ b/nemubot/tools/web.py @@ -162,15 +162,13 @@ def getURLContent(url, body=None, timeout=7, header=None): (res.status, http.client.responses[res.status])) -def getXML(url, timeout=7): +def getXML(*args, **kwargs): """Get content page and return XML parsed content - Arguments: - url -- the URL to get - timeout -- maximum number of seconds to wait before returning an exception + Arguments: same as getURLContent """ - cnt = getURLContent(url, timeout=timeout) + cnt = getURLContent(*args, **kwargs) if cnt is None: return None else: @@ -178,15 +176,13 @@ def getXML(url, timeout=7): return parseString(cnt) -def getJSON(url, timeout=7): +def getJSON(*args, **kwargs): """Get content page and return JSON content - Arguments: - url -- the URL to get - timeout -- maximum number of seconds to wait before returning an exception + Arguments: same as getURLContent """ - cnt = getURLContent(url, timeout=timeout) + cnt = getURLContent(*args, **kwargs) if cnt is None: return None else: From 109b7440e03f2885a3a17d3c50c1338234531510 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 4 Jul 2017 07:26:37 +0200 Subject: [PATCH 04/57] tools/web: improve redirection reliability --- nemubot/tools/web.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/nemubot/tools/web.py b/nemubot/tools/web.py index dc967be..fc37391 100644 --- a/nemubot/tools/web.py +++ b/nemubot/tools/web.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from urllib.parse import urlparse, urlsplit, urlunsplit +from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit from nemubot.exception import IMException @@ -156,7 +156,11 @@ def getURLContent(url, body=None, timeout=7, header=None): elif ((res.status == http.client.FOUND or res.status == http.client.MOVED_PERMANENTLY) and res.getheader("Location") != url): - return getURLContent(res.getheader("Location"), timeout=timeout) + return getURLContent( + urljoin(url, res.getheader("Location")), + body=body, + timeout=timeout, + header=header) else: raise IMException("A HTTP error occurs: %d - %s" % (res.status, http.client.responses[res.status])) From 5f58f71d2fe9e6dd16ad24708cb8bc398cf87f5c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 4 Jul 2017 07:27:44 +0200 Subject: [PATCH 05/57] tools/web: fill a default Content-Type in case of POST --- nemubot/tools/web.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nemubot/tools/web.py b/nemubot/tools/web.py index fc37391..0852664 100644 --- a/nemubot/tools/web.py +++ b/nemubot/tools/web.py @@ -108,6 +108,9 @@ def getURLContent(url, body=None, timeout=7, header=None): elif "User-agent" not in header: header["User-agent"] = "Nemubot v%s" % __version__ + if body is not None and "Content-Type" not in header: + header["Content-Type"] = "application/x-www-form-urlencoded" + import socket try: if o.query != '': From 12403a3690bd31c87a6e6185ddf3a2636c3dc013 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 7 Jul 2017 06:38:00 +0200 Subject: [PATCH 06/57] suivi: use getURLContent instead of call to urllib --- modules/suivi.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/modules/suivi.py b/modules/suivi.py index 79910d4..f62bd84 100644 --- a/modules/suivi.py +++ b/modules/suivi.py @@ -17,8 +17,7 @@ from more import Response def get_tnt_info(track_id): values = [] - data = getURLContent('www.tnt.fr/public/suivi_colis/recherche/' - 'visubontransport.do?bonTransport=%s' % track_id) + data = getURLContent('www.tnt.fr/public/suivi_colis/recherche/visubontransport.do?bonTransport=%s' % track_id) soup = BeautifulSoup(data) status_list = soup.find('div', class_='result__content') if not status_list: @@ -32,8 +31,7 @@ def get_tnt_info(track_id): def get_colissimo_info(colissimo_id): - colissimo_data = getURLContent("http://www.colissimo.fr/portail_colissimo/" - "suivre.do?colispart=%s" % colissimo_id) + colissimo_data = getURLContent("http://www.colissimo.fr/portail_colissimo/suivre.do?colispart=%s" % colissimo_id) soup = BeautifulSoup(colissimo_data) dataArray = soup.find(class_='dataArray') @@ -47,9 +45,8 @@ def get_colissimo_info(colissimo_id): def get_chronopost_info(track_id): data = urllib.parse.urlencode({'listeNumeros': track_id}) - track_baseurl = "http://www.chronopost.fr/expedier/" \ - "inputLTNumbersNoJahia.do?lang=fr_FR" - track_data = urllib.request.urlopen(track_baseurl, data.encode('utf-8')) + track_baseurl = "http://www.chronopost.fr/expedier/inputLTNumbersNoJahia.do?lang=fr_FR" + track_data = getURLContent(track_baseurl, data.encode('utf-8')) soup = BeautifulSoup(track_data) infoClass = soup.find(class_='numeroColi2') @@ -65,9 +62,8 @@ def get_chronopost_info(track_id): def get_colisprive_info(track_id): data = urllib.parse.urlencode({'numColis': track_id}) - track_baseurl = "https://www.colisprive.com/moncolis/pages/" \ - "detailColis.aspx" - track_data = urllib.request.urlopen(track_baseurl, data.encode('utf-8')) + track_baseurl = "https://www.colisprive.com/moncolis/pages/detailColis.aspx" + track_data = getURLContent(track_baseurl, data.encode('utf-8')) soup = BeautifulSoup(track_data) dataArray = soup.find(class_='BandeauInfoColis') @@ -82,8 +78,7 @@ def get_laposte_info(laposte_id): data = urllib.parse.urlencode({'id': laposte_id}) laposte_baseurl = "http://www.part.csuivi.courrier.laposte.fr/suivi/index" - laposte_data = urllib.request.urlopen(laposte_baseurl, - data.encode('utf-8')) + laposte_data = getURLContent(laposte_baseurl, data.encode('utf-8')) soup = BeautifulSoup(laposte_data) search_res = soup.find(class_='resultat_rech_simple_table').tbody.tr if (soup.find(class_='resultat_rech_simple_table').thead @@ -112,8 +107,7 @@ def get_postnl_info(postnl_id): data = urllib.parse.urlencode({'barcodes': postnl_id}) postnl_baseurl = "http://www.postnl.post/details/" - postnl_data = urllib.request.urlopen(postnl_baseurl, - data.encode('utf-8')) + postnl_data = getURLContent(postnl_baseurl, data.encode('utf-8')) soup = BeautifulSoup(postnl_data) if (soup.find(id='datatables') and soup.find(id='datatables').tbody From a624fca347efa16ec8ca5479b816f0b332a623d8 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 8 Jul 2017 14:38:24 +0200 Subject: [PATCH 07/57] suivi: add fedex --- modules/suivi.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/modules/suivi.py b/modules/suivi.py index f62bd84..a6f6ab4 100644 --- a/modules/suivi.py +++ b/modules/suivi.py @@ -2,14 +2,14 @@ # PYTHON STUFF ############################################ -import urllib.request +import json import urllib.parse from bs4 import BeautifulSoup import re from nemubot.hooks import hook from nemubot.exception import IMException -from nemubot.tools.web import getURLContent +from nemubot.tools.web import getURLContent, getJSON from more import Response @@ -126,6 +126,41 @@ def get_postnl_info(postnl_id): return (post_status.lower(), post_destination, post_date) +def get_fedex_info(fedex_id, lang="en_US"): + data = urllib.parse.urlencode({ + 'data': json.dumps({ + "TrackPackagesRequest": { + "appType": "WTRK", + "appDeviceType": "DESKTOP", + "uniqueKey": "", + "processingParameters": {}, + "trackingInfoList": [ + { + "trackNumberInfo": { + "trackingNumber": str(fedex_id), + "trackingQualifier": "", + "trackingCarrier": "" + } + } + ] + } + }), + 'action': "trackpackages", + 'locale': lang, + 'version': 1, + 'format': "json" + }) + fedex_baseurl = "https://www.fedex.com/trackingCal/track" + + fedex_data = getJSON(fedex_baseurl, data.encode('utf-8')) + + if ("TrackPackagesResponse" in fedex_data and + "packageList" in fedex_data["TrackPackagesResponse"] and + len(fedex_data["TrackPackagesResponse"]["packageList"]) + ): + return fedex_data["TrackPackagesResponse"]["packageList"][0] + + # TRACKING HANDLERS ################################################### def handle_tnt(tracknum): @@ -183,6 +218,17 @@ def handle_coliprive(tracknum): return ("Colis Privé: \x02%s\x0F : \x02%s\x0F." % (tracknum, info)) +def handle_fedex(tracknum): + info = get_fedex_info(tracknum) + if info: + if info["displayActDeliveryDateTime"] != "": + return ("{trackingCarrierDesc}: \x02{statusWithDetails}\x0F: in \x02{statusLocationCity}, {statusLocationCntryCD}\x0F, delivered on: {displayActDeliveryDateTime}.".format(**info)) + elif info["statusLocationCity"] != "": + return ("{trackingCarrierDesc}: \x02{statusWithDetails}\x0F: estimated delivery: {displayEstDeliveryDateTime}.".format(**info)) + else: + return ("{trackingCarrierDesc}: \x02{statusWithDetails}\x0F: in \x02{statusLocationCity}, {statusLocationCntryCD}\x0F, estimated delivery: {displayEstDeliveryDateTime}.".format(**info)) + + TRACKING_HANDLERS = { 'laposte': handle_laposte, 'postnl': handle_postnl, @@ -190,6 +236,7 @@ TRACKING_HANDLERS = { 'chronopost': handle_chronopost, 'coliprive': handle_coliprive, 'tnt': handle_tnt, + 'fedex': handle_fedex, } From 1d554e0b0fab84d0a052ecbecdee8125ec2bb502 Mon Sep 17 00:00:00 2001 From: nemunaire Date: Fri, 23 Jun 2017 20:07:22 +0200 Subject: [PATCH 08/57] Handle multiple SIGTERM --- nemubot/bot.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/nemubot/bot.py b/nemubot/bot.py index bb97e49..57e3c00 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -149,6 +149,8 @@ class Bot(threading.Thread): def run(self): + global sync_queue + self._poll.register(sync_queue._reader, select.POLLIN | select.POLLPRI) logger.info("Starting main loop") @@ -222,6 +224,7 @@ class Bot(threading.Thread): c = Consumer(self) self.cnsr_thrd.append(c) c.start() + sync_queue = None logger.info("Ending main loop") @@ -566,9 +569,10 @@ class Bot(threading.Thread): self.datastore.close() - self.stop = True - sync_act("end") - sync_queue.join() + if self.stop is False or sync_queue is not None: + self.stop = True + sync_act("end") + sync_queue.join() # Treatment From ad23fadab1940eb4bbf51765ecc83467225f9cbe Mon Sep 17 00:00:00 2001 From: nemunaire Date: Fri, 23 Jun 2017 20:41:57 +0200 Subject: [PATCH 09/57] Fix communication over unix socket --- nemubot/server/socket.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nemubot/server/socket.py b/nemubot/server/socket.py index 72c0c7b..84b1f4f 100644 --- a/nemubot/server/socket.py +++ b/nemubot/server/socket.py @@ -125,9 +125,15 @@ class UnixSocket: super().connect(self._socket_path) +class SocketClient(_Socket, socket.socket): + + def read(self): + return self.recv() + + class _Listener: - def __init__(self, new_server_cb, instanciate=_Socket, **kwargs): + def __init__(self, new_server_cb, instanciate=SocketClient, **kwargs): super().__init__(**kwargs) self._instanciate = instanciate From 302086d75b810c6dccf6eb5ad97578f9364049b9 Mon Sep 17 00:00:00 2001 From: nemunaire Date: Fri, 23 Jun 2017 21:20:32 +0200 Subject: [PATCH 10/57] Local client now detects when server close the connection --- nemubot/__init__.py | 63 ++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/nemubot/__init__.py b/nemubot/__init__.py index 42a2fba..82be366 100644 --- a/nemubot/__init__.py +++ b/nemubot/__init__.py @@ -53,41 +53,50 @@ def attach(pid, socketfile): sys.stderr.write("\n") return 1 - from select import select + import select + mypoll = select.poll() + + mypoll.register(sys.stdin.fileno(), select.POLLIN | select.POLLPRI) + mypoll.register(sock.fileno(), select.POLLIN | select.POLLPRI) try: while True: - rl, wl, xl = select([sys.stdin, sock], [], []) + for fd, flag in mypoll.poll(): + if flag & (select.POLLERR | select.POLLHUP | select.POLLNVAL): + sock.close() + print("Connection closed.") + return 1 - if sys.stdin in rl: - line = sys.stdin.readline().strip() - if line == "exit" or line == "quit": - return 0 - elif line == "reload": - import os, signal - os.kill(pid, signal.SIGHUP) - print("Reload signal sent. Please wait...") + if fd == sys.stdin.fileno(): + line = sys.stdin.readline().strip() + if line == "exit" or line == "quit": + return 0 + elif line == "reload": + import os, signal + os.kill(pid, signal.SIGHUP) + print("Reload signal sent. Please wait...") - elif line == "shutdown": - import os, signal - os.kill(pid, signal.SIGTERM) - print("Shutdown signal sent. Please wait...") + elif line == "shutdown": + import os, signal + os.kill(pid, signal.SIGTERM) + print("Shutdown signal sent. Please wait...") - elif line == "kill": - import os, signal - os.kill(pid, signal.SIGKILL) - print("Signal sent...") - return 0 + elif line == "kill": + import os, signal + os.kill(pid, signal.SIGKILL) + print("Signal sent...") + return 0 - elif line == "stack" or line == "stacks": - import os, signal - os.kill(pid, signal.SIGUSR1) - print("Debug signal sent. Consult logs.") + elif line == "stack" or line == "stacks": + import os, signal + os.kill(pid, signal.SIGUSR1) + print("Debug signal sent. Consult logs.") - else: - sock.send(line.encode() + b'\r\n') + else: + sock.send(line.encode() + b'\r\n') + + if fd == sock.fileno(): + sys.stdout.write(sock.recv(2048).decode()) - if sock in rl: - sys.stdout.write(sock.recv(2048).decode()) except KeyboardInterrupt: pass except: From b7e12037de18d178a89619a07705e6c316f352b1 Mon Sep 17 00:00:00 2001 From: nemunaire Date: Fri, 23 Jun 2017 21:22:12 +0200 Subject: [PATCH 11/57] Deamonize later --- nemubot/__main__.py | 56 ++++++++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/nemubot/__main__.py b/nemubot/__main__.py index 2eda441..9dea209 100644 --- a/nemubot/__main__.py +++ b/nemubot/__main__.py @@ -1,5 +1,5 @@ # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2017 Mercier Pierre-Olivier # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -74,28 +74,6 @@ def main(): args.files = [x for x in map(os.path.abspath, args.files)] args.modules_path = [x for x in map(os.path.abspath, args.modules_path)] - # Check if an instance is already launched - if args.pidfile is not None and os.path.isfile(args.pidfile): - with open(args.pidfile, "r") as f: - pid = int(f.readline()) - try: - os.kill(pid, 0) - except OSError: - pass - else: - from nemubot import attach - sys.exit(attach(pid, args.socketfile)) - - # Daemonize - if not args.debug: - from nemubot import daemonize - daemonize() - - # Store PID to pidfile - if args.pidfile is not None: - with open(args.pidfile, "w+") as f: - f.write(str(os.getpid())) - # Setup logging interface import logging logger = logging.getLogger("nemubot") @@ -115,6 +93,18 @@ def main(): fh.setFormatter(formatter) logger.addHandler(fh) + # Check if an instance is already launched + if args.pidfile is not None and os.path.isfile(args.pidfile): + with open(args.pidfile, "r") as f: + pid = int(f.readline()) + try: + os.kill(pid, 0) + except OSError: + pass + else: + from nemubot import attach + sys.exit(attach(pid, args.socketfile)) + # Add modules dir paths modules_paths = list() for path in args.modules_path: @@ -149,6 +139,17 @@ def main(): for module in args.module: __import__(module) + if args.socketfile: + from nemubot.server.socket import UnixSocketListener + context.add_server(UnixSocketListener(new_server_cb=context.add_server, + location=args.socketfile, + name="master_socket")) + + # Daemonize + if not args.debug: + from nemubot import daemonize + daemonize() + # Signals handling def sigtermhandler(signum, frame): """On SIGTERM and SIGINT, quit nicely""" @@ -182,11 +183,10 @@ def main(): "".join(traceback.format_stack(stack))) signal.signal(signal.SIGUSR1, sigusr1handler) - if args.socketfile: - from nemubot.server.socket import UnixSocketListener - context.add_server(UnixSocketListener(new_server_cb=context.add_server, - location=args.socketfile, - name="master_socket")) + # Store PID to pidfile + if args.pidfile is not None: + with open(args.pidfile, "w+") as f: + f.write(str(os.getpid())) # context can change when performing an hotswap, always join the latest context oldcontext = None From 838b76081dcb4f4a374146c816e0ca2442f61c07 Mon Sep 17 00:00:00 2001 From: nemunaire Date: Fri, 23 Jun 2017 22:15:26 +0200 Subject: [PATCH 12/57] When launched in daemon mode, attach to the socket --- nemubot/__init__.py | 14 +++++++++++++- nemubot/__main__.py | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/nemubot/__init__.py b/nemubot/__init__.py index 82be366..4b14c07 100644 --- a/nemubot/__init__.py +++ b/nemubot/__init__.py @@ -106,13 +106,25 @@ def attach(pid, socketfile): return 0 -def daemonize(): +def daemonize(socketfile=None): """Detach the running process to run as a daemon """ import os import sys + if socketfile is not None: + try: + pid = os.fork() + if pid > 0: + import time + os.waitpid(pid, 0) + time.sleep(1) + sys.exit(attach(pid, socketfile)) + except OSError as err: + sys.stderr.write("Unable to fork: %s\n" % err) + sys.exit(1) + try: pid = os.fork() if pid > 0: diff --git a/nemubot/__main__.py b/nemubot/__main__.py index 9dea209..fa9d3ba 100644 --- a/nemubot/__main__.py +++ b/nemubot/__main__.py @@ -148,7 +148,7 @@ def main(): # Daemonize if not args.debug: from nemubot import daemonize - daemonize() + daemonize(args.socketfile) # Signals handling def sigtermhandler(signum, frame): From 1f5cfb2ead5681d30a7422dba4c7be157ebc0d0b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 10 Nov 2016 18:36:10 +0100 Subject: [PATCH 13/57] Try to restaure frm_owner flag --- nemubot/message/abstract.py | 7 ++++--- nemubot/server/message/IRC.py | 3 ++- nemubot/treatment.py | 3 +++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/nemubot/message/abstract.py b/nemubot/message/abstract.py index 5d74549..6ee43d5 100644 --- a/nemubot/message/abstract.py +++ b/nemubot/message/abstract.py @@ -21,7 +21,7 @@ class Abstract: """This class represents an abstract message""" - def __init__(self, server=None, date=None, to=None, to_response=None, frm=None): + def __init__(self, server=None, date=None, to=None, to_response=None, frm=None, frm_owner=False): """Initialize an abstract message Arguments: @@ -40,7 +40,7 @@ class Abstract: else [ to_response ]) self.frm = frm # None allowed when it designate this bot - self.frm_owner = False # Filled later, in consumer + self.frm_owner = frm_owner @property @@ -78,7 +78,8 @@ class Abstract: "date": self.date, "to": self.to, "to_response": self._to_response, - "frm": self.frm + "frm": self.frm, + "frm_owner": self.frm_owner, } for w in without: diff --git a/nemubot/server/message/IRC.py b/nemubot/server/message/IRC.py index 4c9e280..5ccd735 100644 --- a/nemubot/server/message/IRC.py +++ b/nemubot/server/message/IRC.py @@ -150,7 +150,8 @@ class IRC(Abstract): "date": self.tags["time"], "to": receivers, "to_response": [r if r != srv.nick else self.nick for r in receivers], - "frm": self.nick + "frm": self.nick, + "frm_owner": self.nick == srv.owner } # If CTCP, remove 0x01 diff --git a/nemubot/treatment.py b/nemubot/treatment.py index 884de4a..4f629e0 100644 --- a/nemubot/treatment.py +++ b/nemubot/treatment.py @@ -109,6 +109,9 @@ class MessageTreater: msg -- message to treat """ + if hasattr(msg, "frm_owner"): + msg.frm_owner = (not hasattr(msg.server, "owner") or msg.server.owner == msg.frm) + while hook is not None: res = hook.run(msg) From d4302780da3b547bd88131824ad60a34bd7c9fd0 Mon Sep 17 00:00:00 2001 From: nemunaire Date: Wed, 10 Aug 2016 23:56:50 +0200 Subject: [PATCH 14/57] Use getaddrinfo to create the right socket --- nemubot/server/IRC.py | 3 ++- nemubot/server/socket.py | 17 +++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/nemubot/server/IRC.py b/nemubot/server/IRC.py index 89eeab5..7469abc 100644 --- a/nemubot/server/IRC.py +++ b/nemubot/server/IRC.py @@ -16,6 +16,7 @@ from datetime import datetime import re +import socket from nemubot.channel import Channel from nemubot.message.printer.IRC import IRC as IRCPrinter @@ -240,7 +241,7 @@ class _IRC: if self.capabilities is not None: self.write("CAP LS") self.write("NICK :" + self.nick) - self.write("USER %s %s bla :%s" % (self.username, self.host, self.realname)) + self.write("USER %s %s bla :%s" % (self.username, socket.getfqdn(), self.realname)) def close(self): diff --git a/nemubot/server/socket.py b/nemubot/server/socket.py index 84b1f4f..2510833 100644 --- a/nemubot/server/socket.py +++ b/nemubot/server/socket.py @@ -81,24 +81,17 @@ class _Socket(AbstractServer): class _SocketServer(_Socket): def __init__(self, host, port, bind=None, **kwargs): - super().__init__(family=socket.AF_INET, **kwargs) + (family, type, proto, canonname, sockaddr) = socket.getaddrinfo(host, port)[0] - assert(host is not None) - assert(isinstance(port, int)) + super().__init__(family=family, type=type, proto=proto, **kwargs) - self._host = host - self._port = port + self._sockaddr = sockaddr self._bind = bind - @property - def host(self): - return self._host - - def connect(self): - self.logger.info("Connection to %s:%d", self._host, self._port) - super().connect((self._host, self._port)) + self.logger.info("Connection to %s:%d", *self._sockaddr[:2]) + super().connect(self._sockaddr) if self._bind: super().bind(self._bind) From 8982965ed92c4ba58f8165d4baf381c0a38be4ad Mon Sep 17 00:00:00 2001 From: nemunaire Date: Sat, 15 Jul 2017 10:53:30 +0200 Subject: [PATCH 15/57] main: new option -A to run as daemon --- nemubot/__init__.py | 13 ++++++++----- nemubot/__main__.py | 5 ++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/nemubot/__init__.py b/nemubot/__init__.py index 4b14c07..48de6ea 100644 --- a/nemubot/__init__.py +++ b/nemubot/__init__.py @@ -106,7 +106,7 @@ def attach(pid, socketfile): return 0 -def daemonize(socketfile=None): +def daemonize(socketfile=None, autoattach=True): """Detach the running process to run as a daemon """ @@ -117,10 +117,13 @@ def daemonize(socketfile=None): try: pid = os.fork() if pid > 0: - import time - os.waitpid(pid, 0) - time.sleep(1) - sys.exit(attach(pid, socketfile)) + if autoattach: + import time + os.waitpid(pid, 0) + time.sleep(1) + sys.exit(attach(pid, socketfile)) + else: + sys.exit(0) except OSError as err: sys.stderr.write("Unable to fork: %s\n" % err) sys.exit(1) diff --git a/nemubot/__main__.py b/nemubot/__main__.py index fa9d3ba..e1576fb 100644 --- a/nemubot/__main__.py +++ b/nemubot/__main__.py @@ -37,6 +37,9 @@ def main(): default=["./modules/"], help="directory to use as modules store") + parser.add_argument("-A", "--no-attach", action="store_true", + help="don't attach after fork") + parser.add_argument("-d", "--debug", action="store_true", help="don't deamonize, keep in foreground") @@ -148,7 +151,7 @@ def main(): # Daemonize if not args.debug: from nemubot import daemonize - daemonize(args.socketfile) + daemonize(args.socketfile, not args.no_attach) # Signals handling def sigtermhandler(signum, frame): From f60ab46274ad212751819aa250397bef77101f2f Mon Sep 17 00:00:00 2001 From: nemunaire Date: Sun, 16 Jul 2017 18:17:15 +0200 Subject: [PATCH 16/57] rename module nextstop: ratp to avoid import loop with the inderlying Python module --- modules/nextstop.xml | 4 ---- modules/{nextstop.py => ratp.py} | 0 2 files changed, 4 deletions(-) delete mode 100644 modules/nextstop.xml rename modules/{nextstop.py => ratp.py} (100%) diff --git a/modules/nextstop.xml b/modules/nextstop.xml deleted file mode 100644 index d34e8ae..0000000 --- a/modules/nextstop.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/modules/nextstop.py b/modules/ratp.py similarity index 100% rename from modules/nextstop.py rename to modules/ratp.py From 1a5aca4844ca2d0d37720e6d638a0f800cdbd68f Mon Sep 17 00:00:00 2001 From: nemunaire Date: Sun, 16 Jul 2017 18:39:56 +0200 Subject: [PATCH 17/57] event: ensure that enough consumers are launched at the end of an event --- nemubot/bot.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nemubot/bot.py b/nemubot/bot.py index 57e3c00..2407496 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -207,6 +207,9 @@ class Bot(threading.Thread): elif action == "exit": self.quit() + elif action == "launch_consumer": + pass # This is treated after the loop + elif action == "loadconf": for path in args: logger.debug("Load configuration from %s", path) @@ -418,6 +421,7 @@ class Bot(threading.Thread): while len(self.events) > 0 and datetime.now(timezone.utc) >= self.events[0].current: evt = self.events.pop(0) self.cnsr_queue.put_nowait(EventConsumer(evt)) + sync_act("launch_consumer") self._update_event_timer() From c3b1c7534cda14eece6ea493d863a4edb3eebdcc Mon Sep 17 00:00:00 2001 From: nemunaire Date: Sun, 16 Jul 2017 21:15:10 +0200 Subject: [PATCH 18/57] run: recreate the sync_queue on run, it seems to have strange behaviour when created before the fork --- nemubot/bot.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/nemubot/bot.py b/nemubot/bot.py index 2407496..6ab6c34 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -151,6 +151,11 @@ class Bot(threading.Thread): def run(self): global sync_queue + # Rewrite the sync_queue, as the daemonization process tend to disturb it + old_sync_queue, sync_queue = sync_queue, JoinableQueue() + while not old_sync_queue.empty(): + sync_queue.put_nowait(old_sync_queue.get()) + self._poll.register(sync_queue._reader, select.POLLIN | select.POLLPRI) logger.info("Starting main loop") @@ -190,6 +195,8 @@ class Bot(threading.Thread): args = sync_queue.get() action = args.pop(0) + logger.debug("Executing sync_queue action %s%s", action, args) + if action == "sckt" and len(args) >= 2: try: if args[0] == "write": From 53bedd338aa503a65f5d55892d07ae7a8d23888f Mon Sep 17 00:00:00 2001 From: nemunaire Date: Sat, 15 Jul 2017 23:30:50 +0200 Subject: [PATCH 19/57] events: fix help when no event is defined --- modules/events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/events.py b/modules/events.py index 2887514..a35c28b 100644 --- a/modules/events.py +++ b/modules/events.py @@ -16,7 +16,7 @@ from more import Response def help_full (): - return "This module store a lot of events: ny, we, " + (", ".join(context.datas.index.keys())) + "\n!eventslist: gets list of timer\n!start /something/: launch a timer" + return "This module store a lot of events: ny, we, " + (", ".join(context.datas.index.keys() if hasattr(context, "datas") else [])) + "\n!eventslist: gets list of timer\n!start /something/: launch a timer" def load(context): From 9f8fa9f31fbbe9b9115160876438a116d95ee227 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 17 Jul 2017 07:53:36 +0200 Subject: [PATCH 20/57] socket: limit getaddrinfo to TCP connections --- nemubot/server/socket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nemubot/server/socket.py b/nemubot/server/socket.py index 2510833..8a0950c 100644 --- a/nemubot/server/socket.py +++ b/nemubot/server/socket.py @@ -81,7 +81,7 @@ class _Socket(AbstractServer): class _SocketServer(_Socket): def __init__(self, host, port, bind=None, **kwargs): - (family, type, proto, canonname, sockaddr) = socket.getaddrinfo(host, port)[0] + (family, type, proto, canonname, sockaddr) = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)[0] super().__init__(family=family, type=type, proto=proto, **kwargs) From 8e0d746e4eea9ec35ce1d63fcd1effa6fb503600 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 21 Jul 2017 07:26:00 +0200 Subject: [PATCH 21/57] cve: update and clean module, following NIST website changes --- modules/cve.py | 47 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/modules/cve.py b/modules/cve.py index 23a0302..c470e29 100644 --- a/modules/cve.py +++ b/modules/cve.py @@ -10,7 +10,7 @@ from nemubot.tools.web import getURLContent, striphtml from more import Response -BASEURL_NIST = 'https://web.nvd.nist.gov/view/vuln/detail?vulnId=' +BASEURL_NIST = 'https://nvd.nist.gov/vuln/detail/' # MODULE CORE ######################################################### @@ -19,15 +19,40 @@ def get_cve(cve_id): search_url = BASEURL_NIST + quote(cve_id.upper()) soup = BeautifulSoup(getURLContent(search_url)) - vuln = soup.body.find(class_="vuln-detail") - cvss = vuln.findAll('div')[4] - return [ - "Base score: " + cvss.findAll('div')[0].findAll('a')[0].text.strip(), - vuln.findAll('p')[0].text, # description - striphtml(vuln.findAll('div')[0].text).strip(), # publication date - striphtml(vuln.findAll('div')[1].text).strip(), # last revised - ] + return { + "description": soup.body.find(attrs={"data-testid":"vuln-description"}).text.strip(), + "published": soup.body.find(attrs={"data-testid":"vuln-published-on"}).text.strip(), + "last_modified": soup.body.find(attrs={"data-testid":"vuln-last-modified-on"}).text.strip(), + "source": soup.body.find(attrs={"data-testid":"vuln-source"}).text.strip(), + + "base_score": float(soup.body.find(attrs={"data-testid":"vuln-cvssv3-base-score-link"}).text.strip()), + "severity": soup.body.find(attrs={"data-testid":"vuln-cvssv3-base-score-severity"}).text.strip(), + "impact_score": float(soup.body.find(attrs={"data-testid":"vuln-cvssv3-impact-score"}).text.strip()), + "exploitability_score": float(soup.body.find(attrs={"data-testid":"vuln-cvssv3-exploitability-score"}).text.strip()), + + "av": soup.body.find(attrs={"data-testid":"vuln-cvssv3-av"}).text.strip(), + "ac": soup.body.find(attrs={"data-testid":"vuln-cvssv3-ac"}).text.strip(), + "pr": soup.body.find(attrs={"data-testid":"vuln-cvssv3-pr"}).text.strip(), + "ui": soup.body.find(attrs={"data-testid":"vuln-cvssv3-ui"}).text.strip(), + "s": soup.body.find(attrs={"data-testid":"vuln-cvssv3-s"}).text.strip(), + "c": soup.body.find(attrs={"data-testid":"vuln-cvssv3-c"}).text.strip(), + "i": soup.body.find(attrs={"data-testid":"vuln-cvssv3-i"}).text.strip(), + "a": soup.body.find(attrs={"data-testid":"vuln-cvssv3-a"}).text.strip(), + } + + +def display_metrics(av, ac, pr, ui, s, c, i, a, **kwargs): + ret = [] + if av != "None": ret.append("Attack Vector: \x02%s\x0F" % av) + if ac != "None": ret.append("Attack Complexity: \x02%s\x0F" % ac) + if pr != "None": ret.append("Privileges Required: \x02%s\x0F" % pr) + if ui != "None": ret.append("User Interaction: \x02%s\x0F" % ui) + if s != "Unchanged": ret.append("Scope: \x02%s\x0F" % s) + if c != "None": ret.append("Confidentiality: \x02%s\x0F" % c) + if i != "None": ret.append("Integrity: \x02%s\x0F" % i) + if a != "None": ret.append("Availability: \x02%s\x0F" % a) + return ', '.join(ret) # MODULE INTERFACE #################################################### @@ -42,6 +67,8 @@ def get_cve_desc(msg): if cve_id[:3].lower() != 'cve': cve_id = 'cve-' + cve_id - res.append_message(get_cve(cve_id)) + cve = get_cve(cve_id) + metrics = display_metrics(**cve) + res.append_message("{cveid}: Base score: \x02{base_score} {severity}\x0F (impact: \x02{impact_score}\x0F, exploitability: \x02{exploitability_score}\x0F; {metrics}), from \x02{source}\x0F, last modified on \x02{last_modified}\x0F. {description}".format(cveid=cve_id, metrics=metrics, **cve)) return res From 3dbf8ed6ea28d53ba76c147779d070bc8589bc27 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 22 Jul 2017 10:49:38 +0200 Subject: [PATCH 22/57] tools/web: display socket timeout --- nemubot/tools/web.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nemubot/tools/web.py b/nemubot/tools/web.py index 0852664..9ced693 100644 --- a/nemubot/tools/web.py +++ b/nemubot/tools/web.py @@ -15,6 +15,7 @@ # along with this program. If not, see . from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit +import socket from nemubot.exception import IMException @@ -123,6 +124,8 @@ def getURLContent(url, body=None, timeout=7, header=None): o.path, body, header) + except socket.timeout as e: + raise IMException(e) except OSError as e: raise IMException(e.strerror) From 4d65524aadccf4ed020a597c6491f6e03b100e4b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 22 Jul 2017 10:53:08 +0200 Subject: [PATCH 23/57] tools/web: new option decode_error to decode non-200 page content (useful on REST API) --- nemubot/tools/web.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/nemubot/tools/web.py b/nemubot/tools/web.py index 9ced693..0394aac 100644 --- a/nemubot/tools/web.py +++ b/nemubot/tools/web.py @@ -68,13 +68,14 @@ def getPassword(url): # Get real pages -def getURLContent(url, body=None, timeout=7, header=None): +def getURLContent(url, body=None, timeout=7, header=None, decode_error=False): """Return page content corresponding to URL or None if any error occurs Arguments: url -- the URL to get body -- Data to send as POST content timeout -- maximum number of seconds to wait before returning an exception + decode_error -- raise exception on non-200 pages or ignore it """ o = urlparse(_getNormalizedURL(url), "http") @@ -166,7 +167,10 @@ def getURLContent(url, body=None, timeout=7, header=None): urljoin(url, res.getheader("Location")), body=body, timeout=timeout, - header=header) + header=header, + decode_error=decode_error) + elif decode_error: + return data.decode(charset).strip() else: raise IMException("A HTTP error occurs: %d - %s" % (res.status, http.client.responses[res.status])) From 2f2e989da638c93f2a3fa797edec13688d25768a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 26 Jul 2017 07:51:35 +0200 Subject: [PATCH 24/57] openroute: new module providing geocode and direction instructions Closing issue #46 --- modules/openroute.py | 158 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 modules/openroute.py diff --git a/modules/openroute.py b/modules/openroute.py new file mode 100644 index 0000000..440b05a --- /dev/null +++ b/modules/openroute.py @@ -0,0 +1,158 @@ +"""Lost? use our commands to find your way!""" + +# PYTHON STUFFS ####################################################### + +import re +import urllib.parse + +from nemubot.exception import IMException +from nemubot.hooks import hook +from nemubot.tools import web + +from more import Response + +# GLOBALS ############################################################# + +URL_DIRECTIONS_API = "https://api.openrouteservice.org/directions?api_key=%s&" +URL_GEOCODE_API = "https://api.openrouteservice.org/geocoding?api_key=%s&" + +waytype = [ + "unknown", + "state road", + "road", + "street", + "path", + "track", + "cycleway", + "footway", + "steps", + "ferry", + "construction", +] + + +# LOADING ############################################################# + +def load(context): + if not context.config or "apikey" not in context.config: + raise ImportError("You need an OpenRouteService API key in order to use this " + "module. Add it to the module configuration file:\n" + "\nRegister at https://developers.openrouteservice.org") + global URL_DIRECTIONS_API + URL_DIRECTIONS_API = URL_DIRECTIONS_API % context.config["apikey"] + global URL_GEOCODE_API + URL_GEOCODE_API = URL_GEOCODE_API % context.config["apikey"] + + +# MODULE CORE ######################################################### + +def approx_distance(lng): + if lng > 1111: + return "%f km" % (lng / 1000) + else: + return "%f m" % lng + + +def approx_duration(sec): + days = int(sec / 86400) + if days > 0: + return "%d days %f hours" % (days, (sec % 86400) / 3600) + hours = int((sec % 86400) / 3600) + if hours > 0: + return "%d hours %f minutes" % (hours, (sec % 3600) / 60) + minutes = (sec % 3600) / 60 + if minutes > 0: + return "%d minutes" % minutes + else: + return "%d seconds" % sec + + +def geocode(query, limit=7): + obj = web.getJSON(URL_GEOCODE_API + urllib.parse.urlencode({ + 'query': query, + 'limit': limit, + })) + + for f in obj["features"]: + yield f["geometry"]["coordinates"], f["properties"] + + +def firstgeocode(query): + for g in geocode(query, limit=1): + return g + + +def where(loc): + return "{name} {city} {state} {county} {country}".format(**loc) + + +def directions(coordinates, **kwargs): + kwargs['coordinates'] = '|'.join(coordinates) + + print(URL_DIRECTIONS_API + urllib.parse.urlencode(kwargs)) + return web.getJSON(URL_DIRECTIONS_API + urllib.parse.urlencode(kwargs), decode_error=True) + + +# MODULE INTERFACE #################################################### + +@hook.command("geocode", + help="Get GPS coordinates of a place", + help_usage={ + "PLACE": "Get GPS coordinates of PLACE" + }) +def cmd_geocode(msg): + res = Response(channel=msg.channel, nick=msg.frm, + nomore="No more geocode", count=" (%s more geocode)") + + for loc in geocode(' '.join(msg.args)): + res.append_message("%s is at %s,%s" % ( + where(loc[1]), + loc[0][1], loc[0][0], + )) + + return res + + +@hook.command("directions", + help="Get routing instructions", + help_usage={ + "POINT1 POINT2 ...": "Get routing instructions to go from POINT1 to the last POINTX via intermediates POINTX" + }, + keywords={ + "profile=PROF": "One of driving-car, driving-hgv, cycling-regular, cycling-road, cycling-safe, cycling-mountain, cycling-tour, cycling-electric, foot-walking, foot-hiking, wheelchair. Default: foot-walking", + "preference=PREF": "One of fastest, shortest, recommended. Default: recommended", + "lang=LANG": "default: en", + }) +def cmd_directions(msg): + drcts = directions(["{0},{1}".format(*firstgeocode(g)[0]) for g in msg.args], + profile=msg.kwargs["profile"] if "profile" in msg.kwargs else "foot-walking", + preference=msg.kwargs["preference"] if "preference" in msg.kwargs else "recommended", + units="m", + language=msg.kwargs["lang"] if "lang" in msg.kwargs else "en", + geometry=False, + instructions=True, + instruction_format="text") + if "error" in drcts and "message" in drcts["error"] and drcts["error"]["message"]: + raise IMException(drcts["error"]["message"]) + + if "routes" not in drcts or not drcts["routes"]: + raise IMException("No route available for this trip") + + myway = drcts["routes"][0] + myway["summary"]["strduration"] = approx_duration(myway["summary"]["duration"]) + myway["summary"]["strdistance"] = approx_distance(myway["summary"]["distance"]) + res = Response("Trip summary: {strdistance} in approximate {strduration}; elevation +{ascent} m -{descent} m".format(**myway["summary"]), channel=msg.channel, count=" (%d more steps)", nomore="You have arrived!") + + def formatSegments(segments): + for segment in segments: + for step in segment["steps"]: + step["strtype"] = waytype[step["type"]] + step["strduration"] = approx_duration(step["duration"]) + step["strdistance"] = approx_distance(step["distance"]) + yield "{instruction} for {strdistance} on {strtype} (approximate time: {strduration})".format(**step) + + if "segments" in myway: + res.append_message([m for m in formatSegments(myway["segments"])]) + + return res From d66d6c8ded60013a1bfd8d303e655b2444844914 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 27 Jul 2017 20:44:26 +0200 Subject: [PATCH 25/57] tools/xmlparser: implement writer --- nemubot/tools/test_xmlparser.py | 36 +++++++++++++++++++++++--- nemubot/tools/xmlparser/__init__.py | 15 +++++++++++ nemubot/tools/xmlparser/basic.py | 20 ++++++++++++++ nemubot/tools/xmlparser/genericnode.py | 8 ++++++ 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/nemubot/tools/test_xmlparser.py b/nemubot/tools/test_xmlparser.py index d7f5a9a..0feda73 100644 --- a/nemubot/tools/test_xmlparser.py +++ b/nemubot/tools/test_xmlparser.py @@ -1,5 +1,6 @@ import unittest +import io import xml.parsers.expat from nemubot.tools.xmlparser import XMLParser @@ -12,6 +13,11 @@ class StringNode(): def characters(self, content): self.string += content + def saveElement(self, store, tag="string"): + store.startElement(tag, {}) + store.characters(self.string) + store.endElement(tag) + class TestNode(): def __init__(self, option=None): @@ -22,6 +28,15 @@ class TestNode(): self.mystr = child.string return True + def saveElement(self, store, tag="test"): + store.startElement(tag, {"option": self.option}) + + strNode = StringNode() + strNode.string = self.mystr + strNode.saveElement(store) + + store.endElement(tag) + class Test2Node(): def __init__(self, option=None): @@ -33,6 +48,15 @@ class Test2Node(): self.mystrs.append(attrs["value"]) return True + def saveElement(self, store, tag="test"): + store.startElement(tag, {"option": self.option} if self.option is not None else {}) + + for mystr in self.mystrs: + store.startElement("string", {"value": mystr}) + store.endElement("string") + + store.endElement(tag) + class TestXMLParser(unittest.TestCase): @@ -44,9 +68,11 @@ class TestXMLParser(unittest.TestCase): p.CharacterDataHandler = mod.characters p.EndElementHandler = mod.endElement - p.Parse("toto", 1) + inputstr = "toto" + p.Parse(inputstr, 1) self.assertEqual(mod.root.string, "toto") + self.assertEqual(mod.saveDocument(header=False).getvalue(), inputstr) def test_parser2(self): @@ -57,10 +83,12 @@ class TestXMLParser(unittest.TestCase): p.CharacterDataHandler = mod.characters p.EndElementHandler = mod.endElement - p.Parse("toto", 1) + inputstr = 'toto' + p.Parse(inputstr, 1) self.assertEqual(mod.root.option, "123") self.assertEqual(mod.root.mystr, "toto") + self.assertEqual(mod.saveDocument(header=False).getvalue(), inputstr) def test_parser3(self): @@ -71,12 +99,14 @@ class TestXMLParser(unittest.TestCase): p.CharacterDataHandler = mod.characters p.EndElementHandler = mod.endElement - p.Parse("", 1) + inputstr = '' + p.Parse(inputstr, 1) self.assertEqual(mod.root.option, None) self.assertEqual(len(mod.root.mystrs), 2) self.assertEqual(mod.root.mystrs[0], "toto") self.assertEqual(mod.root.mystrs[1], "toto2") + self.assertEqual(mod.saveDocument(header=False, short_empty_elements=True).getvalue(), inputstr) if __name__ == '__main__': diff --git a/nemubot/tools/xmlparser/__init__.py b/nemubot/tools/xmlparser/__init__.py index abc5bb9..c8d393a 100644 --- a/nemubot/tools/xmlparser/__init__.py +++ b/nemubot/tools/xmlparser/__init__.py @@ -134,6 +134,21 @@ class XMLParser: return raise TypeError(name + " tag not expected in " + self.display_stack()) + def saveDocument(self, f=None, header=True, short_empty_elements=False): + if f is None: + import io + f = io.StringIO() + + import xml.sax.saxutils + gen = xml.sax.saxutils.XMLGenerator(f, "utf-8", short_empty_elements=short_empty_elements) + if header: + gen.startDocument() + self.root.saveElement(gen) + if header: + gen.endDocument() + + return f + def parse_file(filename): p = xml.parsers.expat.ParserCreate() diff --git a/nemubot/tools/xmlparser/basic.py b/nemubot/tools/xmlparser/basic.py index 8456629..86eac3c 100644 --- a/nemubot/tools/xmlparser/basic.py +++ b/nemubot/tools/xmlparser/basic.py @@ -44,6 +44,13 @@ class ListNode: return self.items.__repr__() + def saveElement(self, store, tag="list"): + store.startElement(tag, {}) + for i in self.items: + i.saveElement(store) + store.endElement(tag) + + class DictNode: """XML node representing a Python dictionnnary @@ -106,3 +113,16 @@ class DictNode: def __repr__(self): return self.items.__repr__() + + + def saveElement(self, store, tag="dict"): + store.startElement(tag, {}) + for k, v in self.items.items(): + store.startElement("item", {"key": k}) + if isinstance(v, str): + store.characters(v) + else: + for i in v: + i.saveElement(store) + store.endElement("item") + store.endElement(tag) diff --git a/nemubot/tools/xmlparser/genericnode.py b/nemubot/tools/xmlparser/genericnode.py index 9c29a23..425934c 100644 --- a/nemubot/tools/xmlparser/genericnode.py +++ b/nemubot/tools/xmlparser/genericnode.py @@ -53,6 +53,14 @@ class ParsingNode: return item in self.attrs + def saveElement(self, store, tag=None): + store.startElement(tag if tag is not None else self.tag, self.attrs) + for child in self.children: + child.saveElement(store) + store.characters(self.content) + store.endElement(tag if tag is not None else self.tag) + + class GenericNode(ParsingNode): """Consider all subtags as dictionnary From c8941201d28f9678aa9aa5555f92461793823819 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 28 Jul 2017 06:55:17 +0200 Subject: [PATCH 26/57] datastore/xml: handle entire file save and be closer with new nemubot XML API --- nemubot/datastore/xml.py | 13 ++++++++++++- nemubot/tools/xmlparser/node.py | 24 ++---------------------- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/nemubot/datastore/xml.py b/nemubot/datastore/xml.py index 46dca70..025c0c5 100644 --- a/nemubot/datastore/xml.py +++ b/nemubot/datastore/xml.py @@ -143,4 +143,15 @@ class XML(Abstract): if self.rotate: self._rotate(path) - return data.save(path) + import tempfile + _, tmpath = tempfile.mkstemp() + with open(tmpath, "w") as f: + import xml.sax.saxutils + gen = xml.sax.saxutils.XMLGenerator(f, "utf-8") + gen.startDocument() + data.saveElement(gen) + gen.endDocument() + + # Atomic save + import shutil + shutil.move(tmpath, path) diff --git a/nemubot/tools/xmlparser/node.py b/nemubot/tools/xmlparser/node.py index 965a475..7df255e 100644 --- a/nemubot/tools/xmlparser/node.py +++ b/nemubot/tools/xmlparser/node.py @@ -196,7 +196,7 @@ class ModuleState: if self.index_fieldname is not None: self.setIndex(self.index_fieldname, self.index_tagname) - def save_node(self, gen): + def saveElement(self, gen): """Serialize this node as a XML node""" from datetime import datetime attribs = {} @@ -215,29 +215,9 @@ class ModuleState: gen.startElement(self.name, attrs) for child in self.childs: - child.save_node(gen) + child.saveElement(gen) gen.endElement(self.name) except: logger.exception("Error occured when saving the following " "XML node: %s with %s", self.name, attrs) - - def save(self, filename): - """Save the current node as root node in a XML file - - Argument: - filename -- location of the file to create/erase - """ - - import tempfile - _, tmpath = tempfile.mkstemp() - with open(tmpath, "w") as f: - import xml.sax.saxutils - gen = xml.sax.saxutils.XMLGenerator(f, "utf-8") - gen.startDocument() - self.save_node(gen) - gen.endDocument() - - # Atomic save - import shutil - shutil.move(tmpath, filename) From fa79a730aec902329a63541a19df2e04a91a2599 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 29 Jul 2017 15:22:57 +0200 Subject: [PATCH 27/57] Store module into weakref --- nemubot/bot.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/nemubot/bot.py b/nemubot/bot.py index 6ab6c34..d07cc8e 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -20,6 +20,7 @@ from multiprocessing import JoinableQueue import threading import select import sys +import weakref from nemubot import __version__ from nemubot.consumer import Consumer, EventConsumer, MessageConsumer @@ -99,15 +100,15 @@ class Bot(threading.Thread): from more import Response res = Response(channel=msg.to_response) if len(msg.args) >= 1: - if msg.args[0] in self.modules: - if hasattr(self.modules[msg.args[0]], "help_full"): - hlp = self.modules[msg.args[0]].help_full() + if msg.args[0] in self.modules and self.modules[msg.args[0]]() is not None: + if hasattr(self.modules[msg.args[0]](), "help_full"): + hlp = self.modules[msg.args[0]]().help_full() if isinstance(hlp, Response): return hlp else: res.append_message(hlp) else: - res.append_message([str(h) for s,h in self.modules[msg.args[0]].__nemubot_context__.hooks], title="Available commands for module " + msg.args[0]) + res.append_message([str(h) for s,h in self.modules[msg.args[0]]().__nemubot_context__.hooks], title="Available commands for module " + msg.args[0]) elif msg.args[0][0] == "!": from nemubot.message.command import Command for h in self.treater._in_hooks(Command(msg.args[0][1:])): @@ -137,7 +138,7 @@ class Bot(threading.Thread): res.append_message(title="Pour plus de détails sur un module, " "envoyez \"!help nomdumodule\". Voici la liste" " de tous les modules disponibles localement", - message=["\x03\x02%s\x03\x02 (%s)" % (im, self.modules[im].__doc__) for im in self.modules if self.modules[im].__doc__]) + message=["\x03\x02%s\x03\x02 (%s)" % (im, self.modules[im]().__doc__) for im in self.modules if self.modules[im]() is not None and self.modules[im]().__doc__]) return res self.treater.hm.add_hook(nemubot.hooks.Command(_help_msg, "help"), "in", "Command") @@ -518,18 +519,20 @@ class Bot(threading.Thread): raise # Save a reference to the module - self.modules[module_name] = module + self.modules[module_name] = weakref.ref(module) + logger.info("Module '%s' successfully loaded.", module_name) def unload_module(self, name): """Unload a module""" - if name in self.modules: - self.modules[name].print("Unloading module %s" % name) + if name in self.modules and self.modules[name]() is not None: + module = self.modules[name]() + module.print("Unloading module %s" % name) # Call the user defined unload method - if hasattr(self.modules[name], "unload"): - self.modules[name].unload(self) - self.modules[name].__nemubot_context__.unload() + if hasattr(module, "unload"): + module.unload(self) + module.__nemubot_context__.unload() # Remove from the nemubot dict del self.modules[name] From d59328c273708dc11332a47619bdc68601737fd4 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 29 Jul 2017 15:25:44 +0200 Subject: [PATCH 28/57] Fix module unloading --- nemubot/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nemubot/bot.py b/nemubot/bot.py index d07cc8e..5651bcd 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -569,7 +569,7 @@ class Bot(threading.Thread): self.event_timer.cancel() logger.info("Save and unload all modules...") - for mod in self.modules.items(): + for mod in [m for m in self.modules.keys()]: self.unload_module(mod) logger.info("Close all servers connection...") From db3d0043da9686092a77d72eed9daddeb82774ae Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 8 Aug 2017 23:24:37 +0200 Subject: [PATCH 29/57] pkgs: new module to display quick information about common softwares --- modules/pkgs.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 modules/pkgs.py diff --git a/modules/pkgs.py b/modules/pkgs.py new file mode 100644 index 0000000..5a7b0a9 --- /dev/null +++ b/modules/pkgs.py @@ -0,0 +1,68 @@ +"""Get information about common software""" + +# PYTHON STUFFS ####################################################### + +import portage + +from nemubot import context +from nemubot.exception import IMException +from nemubot.hooks import hook + +from more import Response + +DB = None + +# MODULE CORE ######################################################### + +def get_db(): + global DB + if DB is None: + DB = portage.db[portage.root]["porttree"].dbapi + return DB + + +def package_info(pkgname): + pv = get_db().xmatch("match-all", pkgname) + if not pv: + raise IMException("No package named '%s' found" % pkgname) + + bv = get_db().xmatch("bestmatch-visible", pkgname) + pvsplit = portage.catpkgsplit(bv if bv else pv[-1]) + info = get_db().aux_get(bv if bv else pv[-1], ["DESCRIPTION", "HOMEPAGE", "LICENSE", "IUSE", "KEYWORDS"]) + + return { + "pkgname": '/'.join(pvsplit[:2]), + "category": pvsplit[0], + "shortname": pvsplit[1], + "lastvers": '-'.join(pvsplit[2:]) if pvsplit[3] != "r0" else pvsplit[2], + "othersvers": ['-'.join(portage.catpkgsplit(p)[2:]) for p in pv if p != bv], + "description": info[0], + "homepage": info[1], + "license": info[2], + "uses": info[3], + "keywords": info[4], + } + + +# MODULE INTERFACE #################################################### + +@hook.command("eix", + help="Get information about a package", + help_usage={ + "NAME": "Get information about a software NAME" + }) +def cmd_eix(msg): + if not len(msg.args): + raise IMException("please give me a package to search") + + def srch(term): + try: + yield package_info(term) + except portage.exception.AmbiguousPackageName as e: + for i in e.args[0]: + yield package_info(i) + + res = Response(channel=msg.channel, count=" (%d more packages)", nomore="No more package '%s'" % msg.args[0]) + for pi in srch(msg.args[0]): + res.append_message("\x03\x02{pkgname}:\x03\x02 {description} - {homepage} - {license} - last revisions: \x03\x02{lastvers}\x03\x02{ov}".format(ov=(", " + ', '.join(pi["othersvers"])) if pi["othersvers"] else "", **pi)) + return res From 3dcd2e653d31b41f99a30ac564986de935229775 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 9 Aug 2017 22:53:35 +0200 Subject: [PATCH 30/57] suivi: fix error handling of fedex parcel --- modules/suivi.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/suivi.py b/modules/suivi.py index a6f6ab4..24f5bf9 100644 --- a/modules/suivi.py +++ b/modules/suivi.py @@ -156,7 +156,9 @@ def get_fedex_info(fedex_id, lang="en_US"): if ("TrackPackagesResponse" in fedex_data and "packageList" in fedex_data["TrackPackagesResponse"] and - len(fedex_data["TrackPackagesResponse"]["packageList"]) + len(fedex_data["TrackPackagesResponse"]["packageList"]) and + not fedex_data["TrackPackagesResponse"]["errorList"][0]["code"] and + not fedex_data["TrackPackagesResponse"]["packageList"][0]["errorList"][0]["code"] ): return fedex_data["TrackPackagesResponse"]["packageList"][0] From e04ea98f26d272cbee78e7305ebfe2dbcb9db36e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 10 Aug 2017 00:55:13 +0200 Subject: [PATCH 31/57] suivi: support DHL --- modules/suivi.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/modules/suivi.py b/modules/suivi.py index 24f5bf9..75a065b 100644 --- a/modules/suivi.py +++ b/modules/suivi.py @@ -163,6 +163,15 @@ def get_fedex_info(fedex_id, lang="en_US"): return fedex_data["TrackPackagesResponse"]["packageList"][0] +def get_dhl_info(dhl_id, lang="en"): + dhl_parcelurl = "http://www.dhl.com/shipmentTracking?" + urllib.parse.urlencode({'AWB': dhl_id}) + + dhl_data = getJSON(dhl_parcelurl) + + if "results" in dhl_data and dhl_data["results"]: + return dhl_data["results"][0] + + # TRACKING HANDLERS ################################################### def handle_tnt(tracknum): @@ -231,6 +240,12 @@ def handle_fedex(tracknum): return ("{trackingCarrierDesc}: \x02{statusWithDetails}\x0F: in \x02{statusLocationCity}, {statusLocationCntryCD}\x0F, estimated delivery: {displayEstDeliveryDateTime}.".format(**info)) +def handle_dhl(tracknum): + info = get_dhl_info(tracknum) + if info: + return "DHL {label} {id}: \x02{description}\x0F".format(**info) + + TRACKING_HANDLERS = { 'laposte': handle_laposte, 'postnl': handle_postnl, @@ -239,6 +254,7 @@ TRACKING_HANDLERS = { 'coliprive': handle_coliprive, 'tnt': handle_tnt, 'fedex': handle_fedex, + 'dhl': handle_dhl, } From 9257abf9af9a0f51d763664db2b05948e30e767f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 10 Aug 2017 06:48:48 +0200 Subject: [PATCH 32/57] suivi: support USPS --- modules/suivi.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/modules/suivi.py b/modules/suivi.py index 75a065b..6ad13e9 100644 --- a/modules/suivi.py +++ b/modules/suivi.py @@ -126,6 +126,24 @@ def get_postnl_info(postnl_id): return (post_status.lower(), post_destination, post_date) +def get_usps_info(usps_id): + usps_parcelurl = "https://tools.usps.com/go/TrackConfirmAction_input?" + urllib.parse.urlencode({'qtc_tLabels1': usps_id}) + + usps_data = getURLContent(usps_parcelurl) + soup = BeautifulSoup(usps_data) + if (soup.find(class_="tracking_history") + and soup.find(class_="tracking_history").find(class_="row_notification") + and soup.find(class_="tracking_history").find(class_="row_top").find_all("td")): + notification = soup.find(class_="tracking_history").find(class_="row_notification").text.strip() + date = re.sub(r"\s+", " ", soup.find(class_="tracking_history").find(class_="row_top").find_all("td")[0].text.strip()) + status = soup.find(class_="tracking_history").find(class_="row_top").find_all("td")[1].text.strip() + last_location = soup.find(class_="tracking_history").find(class_="row_top").find_all("td")[2].text.strip() + + print(notification) + + return (notification, date, status, last_location) + + def get_fedex_info(fedex_id, lang="en_US"): data = urllib.parse.urlencode({ 'data': json.dumps({ @@ -206,6 +224,13 @@ def handle_postnl(tracknum): ")." % (tracknum, post_status, post_destination, post_date)) +def handle_usps(tracknum): + info = get_usps_info(tracknum) + if info: + notif, last_date, last_status, last_location = info + return ("USPS \x02{tracknum}\x0F is {last_status} in \x02{last_location}\x0F as of {last_date}: {notif}".format(tracknum=tracknum, notif=notif, last_date=last_date, last_status=last_status.lower(), last_location=last_location)) + + def handle_colissimo(tracknum): info = get_colissimo_info(tracknum) if info: @@ -255,6 +280,7 @@ TRACKING_HANDLERS = { 'tnt': handle_tnt, 'fedex': handle_fedex, 'dhl': handle_dhl, + 'usps': handle_usps, } From 2df449fd9669aed814a141dabe2132fdb92deced Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 4 Aug 2017 01:22:24 +0200 Subject: [PATCH 33/57] freetarifs: new module --- modules/freetarifs.py | 64 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 modules/freetarifs.py diff --git a/modules/freetarifs.py b/modules/freetarifs.py new file mode 100644 index 0000000..b96a30f --- /dev/null +++ b/modules/freetarifs.py @@ -0,0 +1,64 @@ +"""Inform about Free Mobile tarifs""" + +# PYTHON STUFFS ####################################################### + +import urllib.parse +from bs4 import BeautifulSoup + +from nemubot.exception import IMException +from nemubot.hooks import hook +from nemubot.tools import web + +from more import Response + + +# MODULE CORE ######################################################### + +ACT = { + "ff_toFixe": "Appel vers les fixes", + "ff_toMobile": "Appel vers les mobiles", + "ff_smsSendedToCountry": "SMS vers le pays", + "ff_mmsSendedToCountry": "MMS vers le pays", + "fc_callToFrance": "Appel vers la France", + "fc_smsToFrance": "SMS vers la france", + "fc_mmsSended": "MMS vers la france", + "fc_callToSameCountry": "Réception des appels", + "fc_callReceived": "Appel dans le pays", + "fc_smsReceived": "SMS (Réception)", + "fc_mmsReceived": "MMS (Réception)", + "fc_moDataFromCountry": "Data", +} + +def get_land_tarif(country, forfait="pkgFREE"): + url = "http://mobile.international.free.fr/?" + urllib.parse.urlencode({'pays': country}) + page = web.getURLContent(url) + soup = BeautifulSoup(page) + + fact = soup.find(class_=forfait) + + if fact is None: + raise IMException("Country or forfait not found.") + + res = {} + for s in ACT.keys(): + try: + res[s] = fact.find(attrs={"data-bind": "text: " + s}).text + " " + fact.find(attrs={"data-bind": "html: " + s + "Unit"}).text + except AttributeError: + res[s] = "inclus" + + return res + +@hook.command("freetarifs", + help="Show Free Mobile tarifs for given contries", + help_usage={"COUNTRY": "Show Free Mobile tarifs for given CONTRY"}, + keywords={ + "forfait=FORFAIT": "Related forfait between Free (default) and 2euro" + }) +def get_freetarif(msg): + res = Response(channel=msg.channel) + + for country in msg.args: + t = get_land_tarif(country.lower().capitalize(), "pkg" + (msg.kwargs["forfait"] if "forfait" in msg.kwargs else "FREE").upper()) + res.append_message(["\x02%s\x0F : %s" % (ACT[k], t[k]) for k in sorted(ACT.keys(), reverse=True)], title=country) + + return res From b2aa0cc5aa8808451b15bd6bf3a7ca8d7dd05c44 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 6 Aug 2017 12:27:19 +0200 Subject: [PATCH 34/57] disas: new module, aim to disassemble binary code. Closing #67 --- modules/disas.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 modules/disas.py diff --git a/modules/disas.py b/modules/disas.py new file mode 100644 index 0000000..7c17907 --- /dev/null +++ b/modules/disas.py @@ -0,0 +1,89 @@ +"""The Ultimate Disassembler Module""" + +# PYTHON STUFFS ####################################################### + +import capstone + +from nemubot.exception import IMException +from nemubot.hooks import hook + +from more import Response + + +# MODULE CORE ######################################################### + +ARCHITECTURES = { + "arm": capstone.CS_ARCH_ARM, + "arm64": capstone.CS_ARCH_ARM64, + "mips": capstone.CS_ARCH_MIPS, + "ppc": capstone.CS_ARCH_PPC, + "sparc": capstone.CS_ARCH_SPARC, + "sysz": capstone.CS_ARCH_SYSZ, + "x86": capstone.CS_ARCH_X86, + "xcore": capstone.CS_ARCH_XCORE, +} + +MODES = { + "arm": capstone.CS_MODE_ARM, + "thumb": capstone.CS_MODE_THUMB, + "mips32": capstone.CS_MODE_MIPS32, + "mips64": capstone.CS_MODE_MIPS64, + "mips32r6": capstone.CS_MODE_MIPS32R6, + "16": capstone.CS_MODE_16, + "32": capstone.CS_MODE_32, + "64": capstone.CS_MODE_64, + "le": capstone.CS_MODE_LITTLE_ENDIAN, + "be": capstone.CS_MODE_BIG_ENDIAN, + "micro": capstone.CS_MODE_MICRO, + "mclass": capstone.CS_MODE_MCLASS, + "v8": capstone.CS_MODE_V8, + "v9": capstone.CS_MODE_V9, +} + +# MODULE INTERFACE #################################################### + +@hook.command("disas", + help="Display assembly code", + help_usage={"CODE": "Display assembly code corresponding to the given CODE"}, + keywords={ + "arch=ARCH": "Specify the architecture of the code to disassemble (default: x86, choose between: %s)" % ', '.join(ARCHITECTURES.keys()), + "modes=MODE[,MODE]": "Specify hardware mode of the code to disassemble (default: 32, between: %s)" % ', '.join(MODES.keys()), + }) +def cmd_disas(msg): + if not len(msg.args): + raise IMException("please give me some code") + + # Determine the architecture + if "arch" in msg.kwargs: + if msg.kwargs["arch"] not in ARCHITECTURES: + raise IMException("unknown architectures '%s'" % msg.kwargs["arch"]) + architecture = ARCHITECTURES[msg.kwargs["arch"]] + else: + architecture = capstone.CS_ARCH_X86 + + # Determine hardware modes + modes = 0 + if "modes" in msg.kwargs: + for mode in msg.kwargs["modes"].split(','): + if mode not in MODES: + raise IMException("unknown mode '%s'" % mode) + modes += MODES[mode] + elif architecture == capstone.CS_ARCH_X86 or architecture == capstone.CS_ARCH_PPC: + modes = capstone.CS_MODE_32 + elif architecture == capstone.CS_ARCH_ARM or architecture == capstone.CS_ARCH_ARM64: + modes = capstone.CS_MODE_ARM + elif architecture == capstone.CS_ARCH_MIPS: + modes = capstone.CS_MODE_MIPS32 + + # Get the code + code = bytearray.fromhex(''.join([a.replace("0x", "") for a in msg.args])) + + # Setup capstone + md = capstone.Cs(architecture, modes) + + res = Response(channel=msg.channel, nomore="No more instruction") + + for isn in md.disasm(code, 0x1000): + res.append_message("%s %s" %(isn.mnemonic, isn.op_str), title="0x%x" % isn.address) + + return res From e947eccc48d189a362a3597d04c9cb65d5d27157 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 3 Aug 2017 21:28:56 +0200 Subject: [PATCH 35/57] cve: improve read of partial and inexistant CVE --- modules/cve.py | 66 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/modules/cve.py b/modules/cve.py index c470e29..6cdb339 100644 --- a/modules/cve.py +++ b/modules/cve.py @@ -5,6 +5,7 @@ from bs4 import BeautifulSoup from urllib.parse import quote +from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools.web import getURLContent, striphtml @@ -15,31 +16,44 @@ BASEURL_NIST = 'https://nvd.nist.gov/vuln/detail/' # MODULE CORE ######################################################### +VULN_DATAS = { + "alert-title": "vuln-warning-status-name", + "alert-content": "vuln-warning-banner-content", + + "description": "vuln-description", + "published": "vuln-published-on", + "last_modified": "vuln-last-modified-on", + "source": "vuln-source", + + "base_score": "vuln-cvssv3-base-score-link", + "severity": "vuln-cvssv3-base-score-severity", + "impact_score": "vuln-cvssv3-impact-score", + "exploitability_score": "vuln-cvssv3-exploitability-score", + + "av": "vuln-cvssv3-av", + "ac": "vuln-cvssv3-ac", + "pr": "vuln-cvssv3-pr", + "ui": "vuln-cvssv3-ui", + "s": "vuln-cvssv3-s", + "c": "vuln-cvssv3-c", + "i": "vuln-cvssv3-i", + "a": "vuln-cvssv3-a", +} + + def get_cve(cve_id): search_url = BASEURL_NIST + quote(cve_id.upper()) soup = BeautifulSoup(getURLContent(search_url)) - return { - "description": soup.body.find(attrs={"data-testid":"vuln-description"}).text.strip(), - "published": soup.body.find(attrs={"data-testid":"vuln-published-on"}).text.strip(), - "last_modified": soup.body.find(attrs={"data-testid":"vuln-last-modified-on"}).text.strip(), - "source": soup.body.find(attrs={"data-testid":"vuln-source"}).text.strip(), + vuln = {} - "base_score": float(soup.body.find(attrs={"data-testid":"vuln-cvssv3-base-score-link"}).text.strip()), - "severity": soup.body.find(attrs={"data-testid":"vuln-cvssv3-base-score-severity"}).text.strip(), - "impact_score": float(soup.body.find(attrs={"data-testid":"vuln-cvssv3-impact-score"}).text.strip()), - "exploitability_score": float(soup.body.find(attrs={"data-testid":"vuln-cvssv3-exploitability-score"}).text.strip()), + for vd in VULN_DATAS: + r = soup.body.find(attrs={"data-testid": VULN_DATAS[vd]}) + if r: + vuln[vd] = r.text.strip() - "av": soup.body.find(attrs={"data-testid":"vuln-cvssv3-av"}).text.strip(), - "ac": soup.body.find(attrs={"data-testid":"vuln-cvssv3-ac"}).text.strip(), - "pr": soup.body.find(attrs={"data-testid":"vuln-cvssv3-pr"}).text.strip(), - "ui": soup.body.find(attrs={"data-testid":"vuln-cvssv3-ui"}).text.strip(), - "s": soup.body.find(attrs={"data-testid":"vuln-cvssv3-s"}).text.strip(), - "c": soup.body.find(attrs={"data-testid":"vuln-cvssv3-c"}).text.strip(), - "i": soup.body.find(attrs={"data-testid":"vuln-cvssv3-i"}).text.strip(), - "a": soup.body.find(attrs={"data-testid":"vuln-cvssv3-a"}).text.strip(), - } + return vuln def display_metrics(av, ac, pr, ui, s, c, i, a, **kwargs): @@ -68,7 +82,19 @@ def get_cve_desc(msg): cve_id = 'cve-' + cve_id cve = get_cve(cve_id) - metrics = display_metrics(**cve) - res.append_message("{cveid}: Base score: \x02{base_score} {severity}\x0F (impact: \x02{impact_score}\x0F, exploitability: \x02{exploitability_score}\x0F; {metrics}), from \x02{source}\x0F, last modified on \x02{last_modified}\x0F. {description}".format(cveid=cve_id, metrics=metrics, **cve)) + if not cve: + raise IMException("CVE %s doesn't exists." % cve_id) + + if "alert-title" in cve or "alert-content" in cve: + alert = "\x02%s:\x0F %s " % (cve["alert-title"] if "alert-title" in cve else "", + cve["alert-content"] if "alert-content" in cve else "") + else: + alert = "" + + if "base_score" not in cve and "description" in cve: + res.append_message("{alert}From \x02{source}\x0F, last modified on \x02{last_modified}\x0F. {description}".format(alert=alert, **cve), title=cve_id) + else: + metrics = display_metrics(**cve) + res.append_message("{alert}Base score: \x02{base_score} {severity}\x0F (impact: \x02{impact_score}\x0F, exploitability: \x02{exploitability_score}\x0F; {metrics}), from \x02{source}\x0F, last modified on \x02{last_modified}\x0F. {description}".format(alert=alert, metrics=metrics, **cve), title=cve_id) return res From e3ebd7d05c38a648cfd20878fba2e87997eab077 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 2 Aug 2017 19:58:49 +0200 Subject: [PATCH 36/57] tools/web: new parameter to choose max content size to retrieve --- nemubot/tools/web.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/nemubot/tools/web.py b/nemubot/tools/web.py index 0394aac..164f5da 100644 --- a/nemubot/tools/web.py +++ b/nemubot/tools/web.py @@ -68,7 +68,8 @@ def getPassword(url): # Get real pages -def getURLContent(url, body=None, timeout=7, header=None, decode_error=False): +def getURLContent(url, body=None, timeout=7, header=None, decode_error=False, + max_size=524288): """Return page content corresponding to URL or None if any error occurs Arguments: @@ -76,6 +77,7 @@ def getURLContent(url, body=None, timeout=7, header=None, decode_error=False): body -- Data to send as POST content timeout -- maximum number of seconds to wait before returning an exception decode_error -- raise exception on non-200 pages or ignore it + max_size -- maximal size allow for the content """ o = urlparse(_getNormalizedURL(url), "http") @@ -135,7 +137,7 @@ def getURLContent(url, body=None, timeout=7, header=None, decode_error=False): size = int(res.getheader("Content-Length", 524288)) cntype = res.getheader("Content-Type") - if size > 524288 or (cntype is not None and cntype[:4] != "text" and cntype[:4] != "appl"): + if size > max_size or (cntype is not None and cntype[:4] != "text" and cntype[:4] != "appl"): raise IMException("Content too large to be retrieved") data = res.read(size) @@ -168,7 +170,8 @@ def getURLContent(url, body=None, timeout=7, header=None, decode_error=False): body=body, timeout=timeout, header=header, - decode_error=decode_error) + decode_error=decode_error, + max_size=max_size) elif decode_error: return data.decode(charset).strip() else: From 9b5a400ce9c26d4ef9c26bb641263c52f05795e1 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 30 Jul 2017 11:49:21 +0200 Subject: [PATCH 37/57] shodan: introducing new module to search on shodan --- modules/shodan.py | 104 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 modules/shodan.py diff --git a/modules/shodan.py b/modules/shodan.py new file mode 100644 index 0000000..4b2edae --- /dev/null +++ b/modules/shodan.py @@ -0,0 +1,104 @@ +"""Search engine for IoT""" + +# PYTHON STUFFS ####################################################### + +from datetime import datetime +import ipaddress +import urllib.parse + +from nemubot import context +from nemubot.exception import IMException +from nemubot.hooks import hook +from nemubot.tools import web + +from more import Response + + +# GLOBALS ############################################################# + +BASEURL = "https://api.shodan.io/shodan/" + + +# LOADING ############################################################# + +def load(context): + if not context.config or "apikey" not in context.config: + raise ImportError("You need a Shodan API key in order to use this " + "module. Add it to the module configuration file:\n" + "\nRegister at https://account.shodan.io/register") + + +# MODULE CORE ######################################################### + +def host_lookup(ip): + url = BASEURL + "host/" + urllib.parse.quote(ip) + "?" + urllib.parse.urlencode({'key': context.config["apikey"]}) + return web.getJSON(url) + + +def search_hosts(query): + url = BASEURL + "host/search?" + urllib.parse.urlencode({'query': query, 'key': context.config["apikey"]}) + return web.getJSON(url, max_size=4194304) + + +def print_ssl(ssl): + return ( + "SSL: " + + " ".join([v for v in ssl["versions"] if v[0] != "-"]) + + "; cipher used: " + ssl["cipher"]["name"] + + ("; certificate: " + ssl["cert"]["sig_alg"] + + " issued by: " + ssl["cert"]["issuer"]["CN"] + + " expires on: " + str(datetime.strptime(ssl["cert"]["expires"], "%Y%m%d%H%M%SZ")) if "cert" in ssl else "") + ) + +def print_service(svc): + ip = ipaddress.ip_address(svc["ip_str"]) + return ((svc["ip_str"] if ip.version == 4 else "[%s]" % svc["ip_str"]) + + ":{port}/{transport} ({module}):" + + (" {os}" if svc["os"] else "") + + (" {product}" if "product" in svc else "") + + (" {version}" if "version" in svc else "") + + (" {info}" if "info" in svc else "") + + (" Vulns: " + ", ".join(svc["opts"]["vulns"]) if "opts" in svc and "vulns" in svc["opts"] else "") + + (" " + print_ssl(svc["ssl"]) if "ssl" in svc else "") + + (" \x03\x1D" + svc["data"].replace("\r\n", "\n").split("\n")[0] + "\x03\x1D" if "data" in svc else "") + + (" " + svc["title"] if "title" in svc else "") + ).format(module=svc["_shodan"]["module"], **svc) + + +# MODULE INTERFACE #################################################### + +@hook.command("shodan", + help="Use shodan.io to get information on machines connected to Internet", + help_usage={ + "IP": "retrieve information about the given IP (can be v4 or v6)", + "TERM": "retrieve all hosts matching TERM somewhere in their exposed stuff" + }) +def shodan(msg): + if not msg.args: + raise IMException("indicate an IP or a term to search!") + + terms = " ".join(msg.args) + + try: + ip = ipaddress.ip_address(terms) + except ValueError: + ip = None + + if ip: + h = host_lookup(terms) + res = Response(channel=msg.channel, + title="%s" % ((h["ip_str"] if ip.version == 4 else "[%s]" % h["ip_str"]) + (" (" + ", ".join(h["hostnames"]) + ")") if h["hostnames"] else "")) + res.append_message("{isp} ({asn}) -> {city} ({country_code}), running {os}. Vulns: {vulns_str}. Open ports: {open_ports}. Last update: {last_update}".format( + open_ports=", ".join(map(lambda a: str(a), h["ports"])), vulns_str=", ".join(h["vulns"]) if "vulns" in h else None, **h).strip()) + for d in h["data"]: + res.append_message(print_service(d)) + + else: + q = search_hosts(terms) + res = Response(channel=msg.channel, + count=" (%%s/%s results)" % q["total"]) + for r in q["matches"]: + res.append_message(print_service(r)) + + return res From 9c2acb9840a7a68ef6fd635fd9a2dfe36a8b3460 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 26 Aug 2017 10:38:52 +0200 Subject: [PATCH 38/57] dig: new module --- modules/dig.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 modules/dig.py diff --git a/modules/dig.py b/modules/dig.py new file mode 100644 index 0000000..3db5581 --- /dev/null +++ b/modules/dig.py @@ -0,0 +1,36 @@ +"""DNS resolver""" + +# PYTHON STUFFS ####################################################### + +import dns.rdtypes.ANY +import dns.rdtypes.IN +import dns.resolver + +from nemubot.exception import IMException +from nemubot.hooks import hook + +from more import Response + + +# MODULE INTERFACE #################################################### + +@hook.command("dig") +def dig(msg): + ltype = "A" + ldomain = None + for a in msg.args: + if a in dns.rdtypes.IN.__all__ or a in dns.rdtypes.ANY.__all__: + ltype = a + else: + ldomain = a + + if not ldomain: + raise IMException("indicate a domain to resolve") + + answers = dns.resolver.query(ldomain, ltype) + + res = Response(channel=msg.channel, title=ldomain, count=" (%s others records)") + for rdata in answers: + res.append_message(type(rdata).__name__ + " " + rdata.to_text()) + + return res From 4819e17a4e587779f4cac7b78fdf1806f9af6007 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 26 Aug 2017 12:14:29 +0200 Subject: [PATCH 39/57] dig: better parse dig syntax @ and some + --- modules/dig.py | 72 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/modules/dig.py b/modules/dig.py index 3db5581..de7b2a3 100644 --- a/modules/dig.py +++ b/modules/dig.py @@ -2,8 +2,13 @@ # PYTHON STUFFS ####################################################### -import dns.rdtypes.ANY -import dns.rdtypes.IN +import ipaddress +import socket + +import dns.exception +import dns.name +import dns.rdataclass +import dns.rdatatype import dns.resolver from nemubot.exception import IMException @@ -14,23 +19,76 @@ from more import Response # MODULE INTERFACE #################################################### -@hook.command("dig") +@hook.command("dig", + help="Resolve domain name with a basic syntax similar to dig(1)") def dig(msg): + lclass = "IN" ltype = "A" + ledns = None + ltimeout = 6.0 ldomain = None + lnameservers = [] + lsearchlist = [] + loptions = [] for a in msg.args: - if a in dns.rdtypes.IN.__all__ or a in dns.rdtypes.ANY.__all__: + if a in dns.rdatatype._by_text: ltype = a + elif a in dns.rdataclass._by_text: + lclass = a + elif a[0] == "@": + try: + lnameservers.append(str(ipaddress.ip_address(a[1:]))) + except ValueError: + for r in socket.getaddrinfo(a[1:], 53, proto=socket.IPPROTO_UDP): + lnameservers.append(r[4][0]) + + elif a[0:8] == "+domain=": + lsearchlist.append(dns.name.from_unicode(a[8:])) + elif a[0:6] == "+edns=": + ledns = int(a[6:]) + elif a[0:6] == "+time=": + ltimeout = float(a[6:]) + elif a[0] == "+": + loptions.append(a[1:]) else: ldomain = a if not ldomain: raise IMException("indicate a domain to resolve") - answers = dns.resolver.query(ldomain, ltype) + resolv = dns.resolver.Resolver() + if ledns: + resolv.edns = ledns + resolv.lifetime = ltimeout + resolv.timeout = ltimeout + resolv.flags = ( + dns.flags.QR | dns.flags.RA | + dns.flags.AA if "aaonly" in loptions or "aaflag" in loptions else 0 | + dns.flags.AD if "adflag" in loptions else 0 | + dns.flags.CD if "cdflag" in loptions else 0 | + dns.flags.RD if "norecurse" not in loptions else 0 + ) + if lsearchlist: + resolv.search = lsearchlist + else: + resolv.search = [dns.name.from_text(".")] - res = Response(channel=msg.channel, title=ldomain, count=" (%s others records)") + if lnameservers: + resolv.nameservers = lnameservers + + try: + answers = resolv.query(ldomain, ltype, lclass, tcp="tcp" in loptions) + except dns.exception.DNSException as e: + raise IMException(str(e)) + + res = Response(channel=msg.channel, count=" (%s others entries)") for rdata in answers: - res.append_message(type(rdata).__name__ + " " + rdata.to_text()) + res.append_message("%s %s %s %s %s" % ( + answers.qname.to_text(), + answers.ttl if not "nottlid" in loptions else "", + dns.rdataclass.to_text(answers.rdclass) if not "nocl" in loptions else "", + dns.rdatatype.to_text(answers.rdtype), + rdata.to_text()) + ) return res From 496f7d63996c6f15b506a5762a0e789cb59f292d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 26 Aug 2017 16:56:05 +0200 Subject: [PATCH 40/57] whois: now able to use a CRI API dump --- modules/whois.py | 64 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/modules/whois.py b/modules/whois.py index 52344d1..fb6d250 100644 --- a/modules/whois.py +++ b/modules/whois.py @@ -1,5 +1,6 @@ # coding=utf-8 +import json import re from nemubot import context @@ -13,13 +14,26 @@ from more import Response from networking.page import headers PASSWD_FILE = None +# You can get one with: curl -b "sessionid=YOURSESSIONID" 'https://accounts.cri.epita.net/api/users/' > users.json +APIEXTRACT_FILE = None def load(context): global PASSWD_FILE if not context.config or "passwd" not in context.config: print("No passwd file given") + else: + PASSWD_FILE = context.config["passwd"] + print("passwd file loaded:", PASSWD_FILE) + + global APIEXTRACT_FILE + if not context.config or "apiextract" not in context.config: + print("No passwd file given") + else: + APIEXTRACT_FILE = context.config["apiextract"] + print("JSON users file loaded:", APIEXTRACT_FILE) + + if PASSWD_FILE is None and APIEXTRACT_FILE is None: return None - PASSWD_FILE = context.config["passwd"] if not context.data.hasNode("aliases"): context.data.addChild(ModuleState("aliases")) @@ -35,16 +49,26 @@ def load(context): class Login: - def __init__(self, line): - s = line.split(":") - self.login = s[0] - self.uid = s[2] - self.gid = s[3] - self.cn = s[4] - self.home = s[5] + def __init__(self, line=None, login=None, uidNumber=None, cn=None, promo=None, **kwargs): + if line is not None: + s = line.split(":") + self.login = s[0] + self.uid = s[2] + self.gid = s[3] + self.cn = s[4] + self.home = s[5] + else: + self.login = login + self.uid = uidNumber + self.promo = promo + self.cn = cn + self.gid = "epita" + promo def get_promo(self): - return self.home.split("/")[2].replace("_", " ") + if hasattr(self, "promo"): + return self.promo + if hasattr(self, "home"): + return self.home.split("/")[2].replace("_", " ") def get_photo(self): if self.login in context.data.getNode("pics").index: @@ -60,17 +84,25 @@ class Login: return None -def found_login(login, search=False): +def login_lookup(login, search=False): if login in context.data.getNode("aliases").index: login = context.data.getNode("aliases").index[login]["to"] + if APIEXTRACT_FILE: + with open(APIEXTRACT_FILE, encoding="utf-8") as f: + api = json.load(f) + for l in api: + if (not search and l["login"] == login) or (search and (("login" in l and l["login"].find(login) != -1) or ("cn" in l and l["cn"].find(login) != -1) or ("uid" in l and str(l["uid"]) == login))): + yield Login(**l) + login_ = login + (":" if not search else "") lsize = len(login_) - with open(PASSWD_FILE, encoding="iso-8859-15") as f: - for l in f.readlines(): - if l[:lsize] == login_: - yield Login(l.strip()) + if PASSWD_FILE: + with open(PASSWD_FILE, encoding="iso-8859-15") as f: + for l in f.readlines(): + if l[:lsize] == login_: + yield Login(l.strip()) def cmd_whois(msg): if len(msg.args) < 1: @@ -87,7 +119,7 @@ def cmd_whois(msg): res = Response(channel=msg.channel, count=" (%d more logins)", line_treat=format_response) for srch in msg.args: found = False - for l in found_login(srch, "lookup" in msg.kwargs): + for l in login_lookup(srch, "lookup" in msg.kwargs): found = True res.append_message((srch, l)) if not found: @@ -98,7 +130,7 @@ def cmd_whois(msg): def cmd_nicks(msg): if len(msg.args) < 1: raise IMException("Provide a login") - nick = found_login(msg.args[0]) + nick = login_lookup(msg.args[0]) if nick is None: nick = msg.args[0] else: From 27197b381d7bfc42fd5e3e865a0c2e3147b0c276 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 25 Aug 2017 23:53:10 +0200 Subject: [PATCH 41/57] tools/web: new option to remove callback from JSON files --- nemubot/tools/web.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nemubot/tools/web.py b/nemubot/tools/web.py index 164f5da..c3ba42a 100644 --- a/nemubot/tools/web.py +++ b/nemubot/tools/web.py @@ -193,7 +193,7 @@ def getXML(*args, **kwargs): return parseString(cnt) -def getJSON(*args, **kwargs): +def getJSON(*args, remove_callback=False, **kwargs): """Get content page and return JSON content Arguments: same as getURLContent @@ -204,6 +204,9 @@ def getJSON(*args, **kwargs): return None else: import json + if remove_callback: + import re + cnt = re.sub(r"^[^(]+\((.*)\)$", r"\1", cnt) return json.loads(cnt) From 55bb6a090cb1987bc7e7e8c4bc58839f53ec5644 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 26 Aug 2017 00:14:14 +0200 Subject: [PATCH 42/57] imdb: switch to ugly IMDB HTML parsing --- modules/imdb.py | 87 ++++++++++++++++++++++++------------------------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/modules/imdb.py b/modules/imdb.py index 2434a3c..bd1cadf 100644 --- a/modules/imdb.py +++ b/modules/imdb.py @@ -5,6 +5,8 @@ import re import urllib.parse +from bs4 import BeautifulSoup + from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web @@ -14,54 +16,46 @@ from more import Response # MODULE CORE ######################################################### -def get_movie(title=None, year=None, imdbid=None, fullplot=True, tomatoes=False): +def get_movie_by_id(imdbid): """Returns the information about the matching movie""" - # Built URL - url = "http://www.omdbapi.com/?" - if title is not None: - url += "t=%s&" % urllib.parse.quote(title) - if year is not None: - url += "y=%s&" % urllib.parse.quote(year) - if imdbid is not None: - url += "i=%s&" % urllib.parse.quote(imdbid) - if fullplot: - url += "plot=full&" - if tomatoes: - url += "tomatoes=true&" + url = "http://www.imdb.com/title/" + urllib.parse.quote(imdbid) + soup = BeautifulSoup(web.getURLContent(url)) - # Make the request - data = web.getJSON(url) + return { + "imdbID": imdbid, + "Title": soup.body.find(attrs={"itemprop": "name"}).next_element.strip(), + "Year": soup.body.find(id="titleYear").find("a").text.strip() if soup.body.find(id="titleYear") else ", ".join([y.text.strip() for y in soup.body.find(attrs={"class": "seasons-and-year-nav"}).find_all("div")[3].find_all("a")[:-1]]), + "Duration": soup.body.find_all(attrs={"itemprop": "duration"})[-1].text.strip(), + "imdbRating": soup.body.find(attrs={"itemprop": "ratingValue"}).text.strip(), + "imdbVotes": soup.body.find(attrs={"itemprop": "ratingCount"}).text.strip(), + "Plot": re.sub(r"\s+", " ", soup.body.find(id="titleStoryLine").find(attrs={"itemprop": "description"}).text).strip(), - # Return data - if "Error" in data: - raise IMException(data["Error"]) - - elif "Response" in data and data["Response"] == "True": - return data - - else: - raise IMException("An error occurs during movie search") + "Type": "TV Series" if soup.find(attrs={"class": "np_episode_guide"}) else "Movie", + "Country": ", ".join([c.find("a").text.strip() for c in soup.body.find(id="titleDetails").find_all(attrs={"class": "txt-block"}) if c.text.find("Country") != -1]), + "Released": soup.body.find(attrs={"itemprop": "datePublished"}).attrs["content"] if "content" in soup.body.find(attrs={"itemprop": "datePublished"}).attrs else "N\A", + "Genre": ", ".join([g.text.strip() for g in soup.body.find_all(attrs={"itemprop": "genre"})[:-1]]), + "Director": ", ".join([d.find(attrs={"itemprop": "name"}).text.strip() for d in soup.body.find_all(attrs={"itemprop": "director"})]), + "Writer": ", ".join([d.find(attrs={"itemprop": "name"}).text.strip() for d in soup.body.find_all(attrs={"itemprop": "creator"})]), + "Actors": ", ".join([d.find(attrs={"itemprop": "name"}).text.strip() for d in soup.body.find_all(attrs={"itemprop": "actors"})]), + } -def find_movies(title): +def find_movies(title, year=None): """Find existing movies matching a approximate title""" + title = title.lower() + # Built URL - url = "http://www.omdbapi.com/?s=%s" % urllib.parse.quote(title) + url = "https://v2.sg.media-imdb.com/suggests/%s/%s.json" % (urllib.parse.quote(title[0]), urllib.parse.quote(title.replace(" ", "_"))) # Make the request - data = web.getJSON(url) - - # Return data - if "Error" in data: - raise IMException(data["Error"]) - - elif "Search" in data: - return data + data = web.getJSON(url, remove_callback=True) + if year is None: + return data["d"] else: - raise IMException("An error occurs during movie search") + return [d for d in data["d"] if "y" in d and str(d["y"]) == year] # MODULE INTERFACE #################################################### @@ -79,23 +73,28 @@ def cmd_imdb(msg): title = ' '.join(msg.args) if re.match("^tt[0-9]{7}$", title) is not None: - data = get_movie(imdbid=title) + data = get_movie_by_id(imdbid=title) else: rm = re.match(r"^(.+)\s\(([0-9]{4})\)$", title) if rm is not None: - data = get_movie(title=rm.group(1), year=rm.group(2)) + data = find_movies(rm.group(1), year=rm.group(2)) else: - data = get_movie(title=title) + data = find_movies(title) + + if not data: + raise IMException("Movie/series not found") + + data = get_movie_by_id(data[0]["id"]) res = Response(channel=msg.channel, title="%s (%s)" % (data['Title'], data['Year']), nomore="No more information, more at http://www.imdb.com/title/%s" % data['imdbID']) - res.append_message("\x02rating\x0F: %s (%s votes); \x02plot\x0F: %s" % - (data['imdbRating'], data['imdbVotes'], data['Plot'])) + res.append_message("%s \x02genre:\x0F %s; \x02rating\x0F: %s (%s votes); \x02plot\x0F: %s" % + (data['Type'], data['Genre'], data['imdbRating'], data['imdbVotes'], data['Plot'])) - res.append_message("%s \x02from\x0F %s \x02released on\x0F %s; \x02genre:\x0F %s; \x02directed by:\x0F %s; \x02written by:\x0F %s; \x02main actors:\x0F %s" - % (data['Type'], data['Country'], data['Released'], data['Genre'], data['Director'], data['Writer'], data['Actors'])) + res.append_message("%s \x02from\x0F %s \x02released on\x0F %s; \x02directed by:\x0F %s; \x02written by:\x0F %s; \x02main actors:\x0F %s" + % (data['Type'], data['Country'], data['Released'], data['Director'], data['Writer'], data['Actors'])) return res @@ -111,7 +110,7 @@ def cmd_search(msg): data = find_movies(' '.join(msg.args)) movies = list() - for m in data['Search']: - movies.append("\x02%s\x0F (%s of %s)" % (m['Title'], m['Type'], m['Year'])) + for m in data: + movies.append("\x02%s\x0F%s with %s" % (m['l'], (" (" + str(m['y']) + ")") if "y" in m else "", m['s'])) return Response(movies, title="Titles found", channel=msg.channel) From 0cf1d372505f7cea949e6434c9f453764bf240ee Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 18 Jul 2017 06:32:48 +0200 Subject: [PATCH 43/57] Remove legacy msg.nick --- modules/alias.py | 6 +++--- modules/birthday.py | 12 ++++++------ modules/events.py | 14 +++++++------- modules/mapquest.py | 2 +- modules/reddit.py | 2 +- modules/rnd.py | 2 +- modules/sms.py | 16 ++++++++-------- modules/spell/__init__.py | 6 +++--- modules/virtualradar.py | 2 +- modules/weather.py | 2 +- modules/whois.py | 4 ++-- modules/worldcup.py | 2 +- nemubot/bot.py | 2 +- nemubot/channel.py | 18 +++++++++--------- nemubot/message/abstract.py | 6 ------ nemubot/server/IRC.py | 8 ++++---- nemubot/server/test_IRC.py | 2 +- 17 files changed, 50 insertions(+), 56 deletions(-) diff --git a/modules/alias.py b/modules/alias.py index 5053783..5aae6bb 100644 --- a/modules/alias.py +++ b/modules/alias.py @@ -185,7 +185,7 @@ def cmd_listvars(msg): def cmd_set(msg): if len(msg.args) < 2: raise IMException("!set take two args: the key and the value.") - set_variable(msg.args[0], " ".join(msg.args[1:]), msg.nick) + set_variable(msg.args[0], " ".join(msg.args[1:]), msg.frm) return Response("Variable $%s successfully defined." % msg.args[0], channel=msg.channel) @@ -222,13 +222,13 @@ def cmd_alias(msg): if alias.cmd in context.data.getNode("aliases").index: return Response("%s corresponds to %s" % (alias.cmd, context.data.getNode("aliases").index[alias.cmd]["origin"]), - channel=msg.channel, nick=msg.nick) + channel=msg.channel, nick=msg.frm) elif len(msg.args) > 1: create_alias(alias.cmd, " ".join(msg.args[1:]), channel=msg.channel, - creator=msg.nick) + creator=msg.frm) return Response("New alias %s successfully registered." % alias.cmd, channel=msg.channel) diff --git a/modules/birthday.py b/modules/birthday.py index cb850ac..7a9cdaa 100644 --- a/modules/birthday.py +++ b/modules/birthday.py @@ -27,7 +27,7 @@ def load(context): def findName(msg): if (not len(msg.args) or msg.args[0].lower() == "moi" or msg.args[0].lower() == "me"): - name = msg.nick.lower() + name = msg.frm.lower() else: name = msg.args[0].lower() @@ -77,7 +77,7 @@ def cmd_anniv(msg): else: return Response("désolé, je ne connais pas la date d'anniversaire" " de %s. Quand est-il né ?" % name, - msg.channel, msg.nick) + msg.channel, msg.frm) @hook.command("age", @@ -98,7 +98,7 @@ def cmd_age(msg): msg.channel) else: return Response("désolé, je ne connais pas l'âge de %s." - " Quand est-il né ?" % name, msg.channel, msg.nick) + " Quand est-il né ?" % name, msg.channel, msg.frm) return True @@ -113,11 +113,11 @@ def parseask(msg): if extDate is None or extDate.year > datetime.now().year: return Response("la date de naissance ne paraît pas valide...", msg.channel, - msg.nick) + msg.frm) else: nick = res.group(1) if nick == "my" or nick == "I" or nick == "i" or nick == "je" or nick == "mon" or nick == "ma": - nick = msg.nick + nick = msg.frm if nick.lower() in context.data.index: context.data.index[nick.lower()]["born"] = extDate else: @@ -129,6 +129,6 @@ def parseask(msg): return Response("ok, c'est noté, %s est né le %s" % (nick, extDate.strftime("%A %d %B %Y à %H:%M")), msg.channel, - msg.nick) + msg.frm) except: raise IMException("la date de naissance ne paraît pas valide.") diff --git a/modules/events.py b/modules/events.py index a35c28b..0cc5a44 100644 --- a/modules/events.py +++ b/modules/events.py @@ -69,7 +69,7 @@ def start_countdown(msg): strnd = ModuleState("strend") strnd["server"] = msg.server strnd["channel"] = msg.channel - strnd["proprio"] = msg.nick + strnd["proprio"] = msg.frm strnd["start"] = msg.date strnd["name"] = msg.args[0] context.data.addChild(strnd) @@ -145,17 +145,17 @@ def end_countdown(msg): raise IMException("quel événement terminer ?") if msg.args[0] in context.data.index: - if context.data.index[msg.args[0]]["proprio"] == msg.nick or (msg.cmd == "forceend" and msg.frm_owner): + if context.data.index[msg.args[0]]["proprio"] == msg.frm or (msg.cmd == "forceend" and msg.frm_owner): duration = countdown(msg.date - context.data.index[msg.args[0]].getDate("start")) context.del_event(context.data.index[msg.args[0]]["_id"]) context.data.delChild(context.data.index[msg.args[0]]) context.save() return Response("%s a duré %s." % (msg.args[0], duration), - channel=msg.channel, nick=msg.nick) + channel=msg.channel, nick=msg.frm) else: raise IMException("Vous ne pouvez pas terminer le compteur %s, créé par %s." % (msg.args[0], context.data.index[msg.args[0]]["proprio"])) else: - return Response("%s n'est pas un compteur connu."% (msg.args[0]), channel=msg.channel, nick=msg.nick) + return Response("%s n'est pas un compteur connu."% (msg.args[0]), channel=msg.channel, nick=msg.frm) @hook.command("eventslist") @@ -180,7 +180,7 @@ def parseanswer(msg): # Avoid message starting by ! which can be interpreted as command by other bots if msg.cmd[0] == "!": - res.nick = msg.nick + res.nick = msg.frm if context.data.index[msg.cmd].name == "strend": if context.data.index[msg.cmd].hasAttribute("end"): @@ -223,7 +223,7 @@ def parseask(msg): evt = ModuleState("event") evt["server"] = msg.server evt["channel"] = msg.channel - evt["proprio"] = msg.nick + evt["proprio"] = msg.frm evt["name"] = name.group(1) evt["start"] = extDate evt["msg_after"] = msg_after @@ -237,7 +237,7 @@ def parseask(msg): evt = ModuleState("event") evt["server"] = msg.server evt["channel"] = msg.channel - evt["proprio"] = msg.nick + evt["proprio"] = msg.frm evt["name"] = name.group(1) evt["msg_before"] = texts.group (2) context.data.addChild(evt) diff --git a/modules/mapquest.py b/modules/mapquest.py index 55b87c0..1caa41c 100644 --- a/modules/mapquest.py +++ b/modules/mapquest.py @@ -55,7 +55,7 @@ def cmd_geocode(msg): if not len(msg.args): raise IMException("indicate a name") - res = Response(channel=msg.channel, nick=msg.nick, + res = Response(channel=msg.channel, nick=msg.frm, nomore="No more geocode", count=" (%s more geocode)") for loc in geocode(' '.join(msg.args)): diff --git a/modules/reddit.py b/modules/reddit.py index 7d481b7..31f566c 100644 --- a/modules/reddit.py +++ b/modules/reddit.py @@ -64,7 +64,7 @@ def cmd_subreddit(msg): channel=msg.channel)) else: all_res.append(Response("%s is not a valid subreddit" % osub, - channel=msg.channel, nick=msg.nick)) + channel=msg.channel, nick=msg.frm)) return all_res diff --git a/modules/rnd.py b/modules/rnd.py index 5329b06..6044bd4 100644 --- a/modules/rnd.py +++ b/modules/rnd.py @@ -21,7 +21,7 @@ def cmd_choice(msg): return Response(random.choice(msg.args), channel=msg.channel, - nick=msg.nick) + nick=msg.frm) @hook.command("choicecmd") diff --git a/modules/sms.py b/modules/sms.py index 3a9727f..61e63d6 100644 --- a/modules/sms.py +++ b/modules/sms.py @@ -73,9 +73,9 @@ def cmd_sms(msg): fails.append( "%s: %s" % (u, test) ) if len(fails) > 0: - return Response("quelque chose ne s'est pas bien passé durant l'envoi du SMS : " + ", ".join(fails), msg.channel, msg.nick) + return Response("quelque chose ne s'est pas bien passé durant l'envoi du SMS : " + ", ".join(fails), msg.channel, msg.frm) else: - return Response("le SMS a bien été envoyé", msg.channel, msg.nick) + return Response("le SMS a bien été envoyé", msg.channel, msg.frm) apiuser_ask = re.compile(r"(utilisateur|user|numéro|numero|compte|abonne|abone|abonné|account)\s+(est|is)\s+(?P[0-9]{7,})", re.IGNORECASE) apikey_ask = re.compile(r"(clef|key|password|mot de passe?)\s+(?:est|is)?\s+(?P[a-zA-Z0-9]{10,})", re.IGNORECASE) @@ -94,18 +94,18 @@ def parseask(msg): test = send_sms("nemubot", apiuser, apikey, "Vous avez enregistré vos codes d'authentification dans nemubot, félicitation !") if test is not None: - return Response("je n'ai pas pu enregistrer tes identifiants : %s" % test, msg.channel, msg.nick) + return Response("je n'ai pas pu enregistrer tes identifiants : %s" % test, msg.channel, msg.frm) - if msg.nick in context.data.index: - context.data.index[msg.nick]["user"] = apiuser - context.data.index[msg.nick]["key"] = apikey + if msg.frm in context.data.index: + context.data.index[msg.frm]["user"] = apiuser + context.data.index[msg.frm]["key"] = apikey else: ms = ModuleState("phone") - ms.setAttribute("name", msg.nick) + ms.setAttribute("name", msg.frm) ms.setAttribute("user", apiuser) ms.setAttribute("key", apikey) ms.setAttribute("lastuse", 0) context.data.addChild(ms) context.save() return Response("ok, c'est noté. Je t'ai envoyé un SMS pour tester ;)", - msg.channel, msg.nick) + msg.channel, msg.frm) diff --git a/modules/spell/__init__.py b/modules/spell/__init__.py index a70b016..c15f5fc 100644 --- a/modules/spell/__init__.py +++ b/modules/spell/__init__.py @@ -64,15 +64,15 @@ def cmd_spell(msg): raise IMException("Je n'ai pas le dictionnaire `%s' :(" % lang) if r == True: - add_score(msg.nick, "correct") + add_score(msg.frm, "correct") res.append_message("l'orthographe de `%s' est correcte" % word) elif len(r) > 0: - add_score(msg.nick, "bad") + add_score(msg.frm, "bad") res.append_message(r, title="suggestions pour `%s'" % word) else: - add_score(msg.nick, "bad") + add_score(msg.frm, "bad") res.append_message("aucune suggestion pour `%s'" % word) return res diff --git a/modules/virtualradar.py b/modules/virtualradar.py index ffd5a67..d7448ce 100644 --- a/modules/virtualradar.py +++ b/modules/virtualradar.py @@ -80,7 +80,7 @@ def cmd_flight(msg): if not len(msg.args): raise IMException("please indicate a flight") - res = Response(channel=msg.channel, nick=msg.nick, + res = Response(channel=msg.channel, nick=msg.frm, nomore="No more flights", count=" (%s more flights)") for param in msg.args: diff --git a/modules/weather.py b/modules/weather.py index 8b3540e..8c9ca0e 100644 --- a/modules/weather.py +++ b/modules/weather.py @@ -220,4 +220,4 @@ def parseask(msg): context.data.addChild(ms) context.save() return Response("ok, j'ai bien noté les coordonnées de %s" % res.group("city"), - msg.channel, msg.nick) + msg.channel, msg.frm) diff --git a/modules/whois.py b/modules/whois.py index fb6d250..ae27ccc 100644 --- a/modules/whois.py +++ b/modules/whois.py @@ -152,7 +152,7 @@ def parseask(msg): nick = res.group(1) login = res.group(3) if nick == "my" or nick == "I" or nick == "i" or nick == "je" or nick == "mon" or nick == "ma": - nick = msg.nick + nick = msg.frm if nick in context.data.getNode("aliases").index: context.data.getNode("aliases").index[nick]["to"] = login else: @@ -164,4 +164,4 @@ def parseask(msg): return Response("ok, c'est noté, %s est %s" % (nick, login), channel=msg.channel, - nick=msg.nick) + nick=msg.frm) diff --git a/modules/worldcup.py b/modules/worldcup.py index 7b4f53d..ff3e0c4 100644 --- a/modules/worldcup.py +++ b/modules/worldcup.py @@ -32,7 +32,7 @@ def start_watch(msg): w = ModuleState("watch") w["server"] = msg.server w["channel"] = msg.channel - w["proprio"] = msg.nick + w["proprio"] = msg.frm w["start"] = datetime.now(timezone.utc) context.data.addChild(w) context.save() diff --git a/nemubot/bot.py b/nemubot/bot.py index 5651bcd..484c787 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -92,7 +92,7 @@ class Bot(threading.Thread): def in_echo(msg): from nemubot.message import Text - return Text(msg.nick + ": " + " ".join(msg.args), to=msg.to_response) + return Text(msg.frm + ": " + " ".join(msg.args), to=msg.to_response) self.treater.hm.add_hook(nemubot.hooks.Command(in_echo, "echo"), "in", "Command") def _help_msg(msg): diff --git a/nemubot/channel.py b/nemubot/channel.py index a070131..835c22f 100644 --- a/nemubot/channel.py +++ b/nemubot/channel.py @@ -52,11 +52,11 @@ class Channel: elif cmd == "MODE": self.mode(msg) elif cmd == "JOIN": - self.join(msg.nick) + self.join(msg.frm) elif cmd == "NICK": - self.nick(msg.nick, msg.text) + self.nick(msg.frm, msg.text) elif cmd == "PART" or cmd == "QUIT": - self.part(msg.nick) + self.part(msg.frm) elif cmd == "TOPIC": self.topic = self.text @@ -120,17 +120,17 @@ class Channel: else: self.password = msg.text[1] elif msg.text[0] == "+o": - self.people[msg.nick] |= 4 + self.people[msg.frm] |= 4 elif msg.text[0] == "-o": - self.people[msg.nick] &= ~4 + self.people[msg.frm] &= ~4 elif msg.text[0] == "+h": - self.people[msg.nick] |= 2 + self.people[msg.frm] |= 2 elif msg.text[0] == "-h": - self.people[msg.nick] &= ~2 + self.people[msg.frm] &= ~2 elif msg.text[0] == "+v": - self.people[msg.nick] |= 1 + self.people[msg.frm] |= 1 elif msg.text[0] == "-v": - self.people[msg.nick] &= ~1 + self.people[msg.frm] &= ~1 def parse332(self, msg): """Parse RPL_TOPIC message diff --git a/nemubot/message/abstract.py b/nemubot/message/abstract.py index 6ee43d5..3af0511 100644 --- a/nemubot/message/abstract.py +++ b/nemubot/message/abstract.py @@ -59,12 +59,6 @@ class Abstract: else: return None - @property - def nick(self): - # TODO: this is for legacy modules - return self.frm - - def accept(self, visitor): visitor.visit(self) diff --git a/nemubot/server/IRC.py b/nemubot/server/IRC.py index 7469abc..7adc484 100644 --- a/nemubot/server/IRC.py +++ b/nemubot/server/IRC.py @@ -174,10 +174,10 @@ class _IRC: for chname in msg.params[0].split(b","): if chname in self.channels: - if msg.nick == self.nick: + if msg.frm == self.nick: del self.channels[chname] - elif msg.nick in self.channels[chname].people: - del self.channels[chname].people[msg.nick] + elif msg.frm in self.channels[chname].people: + del self.channels[chname].people[msg.frm] self.hookscmd["PART"] = _on_part # Respond to 331/RPL_NOTOPIC,332/RPL_TOPIC,TOPIC def _on_topic(msg): @@ -227,7 +227,7 @@ class _IRC: else: res = "ERRMSG Unknown or unimplemented CTCP request" if res is not None: - self.write("NOTICE %s :\x01%s\x01" % (msg.nick, res)) + self.write("NOTICE %s :\x01%s\x01" % (msg.frm, res)) self.hookscmd["PRIVMSG"] = _on_ctcp diff --git a/nemubot/server/test_IRC.py b/nemubot/server/test_IRC.py index 552a1d3..37d7437 100644 --- a/nemubot/server/test_IRC.py +++ b/nemubot/server/test_IRC.py @@ -12,7 +12,7 @@ class TestIRCMessage(unittest.TestCase): def test_parsing(self): self.assertEqual(self.msg.prefix, "toto!titi@RZ-3je16g.re") - self.assertEqual(self.msg.nick, "toto") + self.assertEqual(self.msg.frm, "toto") self.assertEqual(self.msg.user, "titi") self.assertEqual(self.msg.host, "RZ-3je16g.re") From a29325cb19642357141e4aee34c5df193ea3b6ce Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 18 Jul 2017 06:39:17 +0200 Subject: [PATCH 44/57] Remove legacy msg.cmds --- nemubot/message/command.py | 5 ----- nemubot/message/response.py | 5 ----- 2 files changed, 10 deletions(-) diff --git a/nemubot/message/command.py b/nemubot/message/command.py index 6c208b2..ca87e4c 100644 --- a/nemubot/message/command.py +++ b/nemubot/message/command.py @@ -31,11 +31,6 @@ class Command(Abstract): def __str__(self): return self.cmd + " @" + ",@".join(self.args) - @property - def cmds(self): - # TODO: this is for legacy modules - return [self.cmd] + self.args - class OwnerCommand(Command): diff --git a/nemubot/message/response.py b/nemubot/message/response.py index fba864b..f9353ad 100644 --- a/nemubot/message/response.py +++ b/nemubot/message/response.py @@ -27,8 +27,3 @@ class Response(Abstract): def __str__(self): return self.cmd + " @" + ",@".join(self.args) - - @property - def cmds(self): - # TODO: this is for legacy modules - return [self.cmd] + self.args From e70a7f4fe0f75f002b18526bbea4f714084d2eff Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 18 Jul 2017 06:48:15 +0200 Subject: [PATCH 45/57] Remove legacy msg.text --- modules/birthday.py | 4 ++-- modules/events.py | 8 ++++---- modules/reddit.py | 11 +++++++++-- modules/sms.py | 10 +++++----- modules/urlreducer.py | 18 ++++++++++++++++-- modules/weather.py | 2 +- modules/whois.py | 2 +- 7 files changed, 38 insertions(+), 17 deletions(-) diff --git a/modules/birthday.py b/modules/birthday.py index 7a9cdaa..d8093b8 100644 --- a/modules/birthday.py +++ b/modules/birthday.py @@ -106,10 +106,10 @@ def cmd_age(msg): @hook.ask() def parseask(msg): - res = re.match(r"^(\S+)\s*('s|suis|est|is|was|were)?\s+(birthday|geburtstag|née? |nee? le|born on).*$", msg.text, re.I) + res = re.match(r"^(\S+)\s*('s|suis|est|is|was|were)?\s+(birthday|geburtstag|née? |nee? le|born on).*$", msg.message, re.I) if res is not None: try: - extDate = extractDate(msg.text) + extDate = extractDate(msg.message) if extDate is None or extDate.year > datetime.now().year: return Response("la date de naissance ne paraît pas valide...", msg.channel, diff --git a/modules/events.py b/modules/events.py index 0cc5a44..f6c6621 100644 --- a/modules/events.py +++ b/modules/events.py @@ -194,17 +194,17 @@ def parseanswer(msg): RGXP_ask = re.compile(r"^.*((create|new)\s+(a|an|a\s*new|an\s*other)?\s*(events?|commande?)|(nouvel(le)?|ajoute|cr[ée]{1,3})\s+(un)?\s*([eé]v[ée]nements?|commande?)).*$", re.I) -@hook.ask(match=lambda msg: RGXP_ask.match(msg.text)) +@hook.ask(match=lambda msg: RGXP_ask.match(msg.message)) def parseask(msg): - name = re.match("^.*!([^ \"'@!]+).*$", msg.text) + name = re.match("^.*!([^ \"'@!]+).*$", msg.message) if name is None: raise IMException("il faut que tu attribues une commande à l'événement.") if name.group(1) in context.data.index: raise IMException("un événement portant ce nom existe déjà.") - texts = re.match("^[^\"]*(avant|après|apres|before|after)?[^\"]*\"([^\"]+)\"[^\"]*((avant|après|apres|before|after)?.*\"([^\"]+)\".*)?$", msg.text, re.I) + texts = re.match("^[^\"]*(avant|après|apres|before|after)?[^\"]*\"([^\"]+)\"[^\"]*((avant|après|apres|before|after)?.*\"([^\"]+)\".*)?$", msg.message, re.I) if texts is not None and texts.group(3) is not None: - extDate = extractDate(msg.text) + extDate = extractDate(msg.message) if extDate is None or extDate == "": raise IMException("la date de l'événement est invalide !") diff --git a/modules/reddit.py b/modules/reddit.py index 31f566c..ae28999 100644 --- a/modules/reddit.py +++ b/modules/reddit.py @@ -71,8 +71,15 @@ def cmd_subreddit(msg): @hook.message() def parselisten(msg): - parseresponse(msg) - return None + global LAST_SUBS + + if hasattr(msg, "message") and msg.message and type(msg.message) == str: + urls = re.findall("www.reddit.com(/\w/\w+/?)", msg.message) + for url in urls: + for recv in msg.to: + if recv not in LAST_SUBS: + LAST_SUBS[recv] = list() + LAST_SUBS[recv].append(url) @hook.post() diff --git a/modules/sms.py b/modules/sms.py index 61e63d6..ca7e9f0 100644 --- a/modules/sms.py +++ b/modules/sms.py @@ -82,11 +82,11 @@ apikey_ask = re.compile(r"(clef|key|password|mot de passe?)\s+(?:est|is)?\s+(?P< @hook.ask() def parseask(msg): - if msg.text.find("Free") >= 0 and ( - msg.text.find("API") >= 0 or msg.text.find("api") >= 0) and ( - msg.text.find("SMS") >= 0 or msg.text.find("sms") >= 0): - resuser = apiuser_ask.search(msg.text) - reskey = apikey_ask.search(msg.text) + if msg.message.find("Free") >= 0 and ( + msg.message.find("API") >= 0 or msg.message.find("api") >= 0) and ( + msg.message.find("SMS") >= 0 or msg.message.find("sms") >= 0): + resuser = apiuser_ask.search(msg.message) + reskey = apikey_ask.search(msg.message) if resuser is not None and reskey is not None: apiuser = resuser.group("user") apikey = reskey.group("key") diff --git a/modules/urlreducer.py b/modules/urlreducer.py index bd5dc9a..8fedfe1 100644 --- a/modules/urlreducer.py +++ b/modules/urlreducer.py @@ -84,8 +84,22 @@ LAST_URLS = dict() @hook.message() def parselisten(msg): - parseresponse(msg) - return None + global LAST_URLS + if hasattr(msg, "message") and isinstance(msg.message, str): + urls = re.findall("([a-zA-Z0-9+.-]+:(?://)?(?:[^ :/]+:[0-9]+)?[^ :]+)", + msg.message) + for url in urls: + o = urlparse(web._getNormalizedURL(url), "http") + + # Skip short URLs + if (o.netloc == "" or o.netloc in PROVIDERS or + len(o.netloc) + len(o.path) < 17): + continue + + for recv in msg.to: + if recv not in LAST_URLS: + LAST_URLS[recv] = list() + LAST_URLS[recv].append(url) @hook.post() diff --git a/modules/weather.py b/modules/weather.py index 8c9ca0e..3f74b8e 100644 --- a/modules/weather.py +++ b/modules/weather.py @@ -203,7 +203,7 @@ gps_ask = re.compile(r"^\s*(?P.*\w)\s*(?:(?:se|est)\s+(?:trouve|situ[ée]* @hook.ask() def parseask(msg): - res = gps_ask.match(msg.text) + res = gps_ask.match(msg.message) if res is not None: city_name = res.group("city").lower() gps_lat = res.group("lat").replace(",", ".") diff --git a/modules/whois.py b/modules/whois.py index ae27ccc..00eb940 100644 --- a/modules/whois.py +++ b/modules/whois.py @@ -147,7 +147,7 @@ def cmd_nicks(msg): @hook.ask() def parseask(msg): - res = re.match(r"^(\S+)\s*('s|suis|est|is|was|were)\s+([a-zA-Z0-9_-]{3,8})$", msg.text, re.I) + res = re.match(r"^(\S+)\s*('s|suis|est|is|was|were)\s+([a-zA-Z0-9_-]{3,8})$", msg.message, re.I) if res is not None: nick = res.group(1) login = res.group(3) From d590282db8864295adec75bd5eaab92b25462311 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 18 Jul 2017 07:16:54 +0200 Subject: [PATCH 46/57] Refactor configuration loading --- nemubot/__main__.py | 60 ++++++++++++++++++++++++++++--- nemubot/bot.py | 76 ++++------------------------------------ nemubot/modulecontext.py | 2 +- 3 files changed, 63 insertions(+), 75 deletions(-) diff --git a/nemubot/__main__.py b/nemubot/__main__.py index e1576fb..8d51249 100644 --- a/nemubot/__main__.py +++ b/nemubot/__main__.py @@ -118,10 +118,10 @@ def main(): # Create bot context from nemubot import datastore - from nemubot.bot import Bot, sync_act + from nemubot.bot import Bot context = Bot(modules_paths=modules_paths, data_store=datastore.XML(args.data_path), - verbosity=args.verbose) + debug=args.verbose > 0) if args.no_connect: context.noautoconnect = True @@ -133,10 +133,34 @@ def main(): # Load requested configuration files for path in args.files: - if os.path.isfile(path): - sync_act("loadconf", path) - else: + if not os.path.isfile(path): logger.error("%s is not a readable file", path) + continue + + config = load_config(path) + + # Preset each server in this file + for server in config.servers: + srv = server.server(config) + # Add the server in the context + if context.add_server(srv): + logger.info("Server '%s' successfully added.", srv.name) + else: + logger.error("Can't add server '%s'.", srv.name) + + # Load module and their configuration + for mod in config.modules: + context.modules_configuration[mod.name] = mod + if mod.autoload: + try: + __import__(mod.name) + except: + logger.exception("Exception occurs when loading module" + " '%s'", mod.name) + + # Load files asked by the configuration file + args.files += config.includes + if args.module: for module in args.module: @@ -205,5 +229,31 @@ def main(): sys.exit(0) +def load_config(filename): + """Load a configuration file + + Arguments: + filename -- the path to the file to load + """ + + from nemubot.channel import Channel + from nemubot import config + from nemubot.tools.xmlparser import XMLParser + + try: + p = XMLParser({ + "nemubotconfig": config.Nemubot, + "server": config.Server, + "channel": Channel, + "module": config.Module, + "include": config.Include, + }) + return p.parse_file(filename) + except: + logger.exception("Can't load `%s'; this is not a valid nemubot " + "configuration file.", filename) + return None + + if __name__ == "__main__": main() diff --git a/nemubot/bot.py b/nemubot/bot.py index 484c787..0e58a9d 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -40,14 +40,14 @@ class Bot(threading.Thread): """Class containing the bot context and ensuring key goals""" def __init__(self, ip="127.0.0.1", modules_paths=list(), - data_store=datastore.Abstract(), verbosity=0): + data_store=datastore.Abstract(), debug=False): """Initialize the bot context Keyword arguments: ip -- The external IP of the bot (default: 127.0.0.1) modules_paths -- Paths to all directories where looking for modules data_store -- An instance of the nemubot datastore for bot's modules - verbosity -- verbosity level + debug -- enable debug """ super().__init__(name="Nemubot main") @@ -56,7 +56,7 @@ class Bot(threading.Thread): __version__, sys.version_info.major, sys.version_info.minor, sys.version_info.micro) - self.verbosity = verbosity + self.debug = debug self.stop = None # External IP for accessing this bot @@ -149,6 +149,10 @@ class Bot(threading.Thread): self.cnsr_thrd_size = -1 + def __del__(self): + self.datastore.close() + + def run(self): global sync_queue @@ -218,12 +222,6 @@ class Bot(threading.Thread): elif action == "launch_consumer": pass # This is treated after the loop - elif action == "loadconf": - for path in args: - logger.debug("Load configuration from %s", path) - self.load_file(path) - logger.info("Configurations successfully loaded") - sync_queue.task_done() @@ -240,64 +238,6 @@ class Bot(threading.Thread): - # Config methods - - def load_file(self, filename): - """Load a configuration file - - Arguments: - filename -- the path to the file to load - """ - - import os - - # Unexisting file, assume a name was passed, import the module! - if not os.path.isfile(filename): - return self.import_module(filename) - - from nemubot.channel import Channel - from nemubot import config - from nemubot.tools.xmlparser import XMLParser - - try: - p = XMLParser({ - "nemubotconfig": config.Nemubot, - "server": config.Server, - "channel": Channel, - "module": config.Module, - "include": config.Include, - }) - config = p.parse_file(filename) - except: - logger.exception("Can't load `%s'; this is not a valid nemubot " - "configuration file." % filename) - return False - - # Preset each server in this file - for server in config.servers: - srv = server.server(config) - # Add the server in the context - if self.add_server(srv, server.autoconnect): - logger.info("Server '%s' successfully added." % srv.name) - else: - logger.error("Can't add server '%s'." % srv.name) - - # Load module and their configuration - for mod in config.modules: - self.modules_configuration[mod.name] = mod - if mod.autoload: - try: - __import__(mod.name) - except: - logger.exception("Exception occurs when loading module" - " '%s'", mod.name) - - - # Load files asked by the configuration file - for load in config.includes: - self.load_file(load.path) - - # Events methods def add_event(self, evt, eid=None, module_src=None): @@ -581,8 +521,6 @@ class Bot(threading.Thread): for cnsr in k: cnsr.stop = True - self.datastore.close() - if self.stop is False or sync_queue is not None: self.stop = True sync_act("end") diff --git a/nemubot/modulecontext.py b/nemubot/modulecontext.py index 877b8de..70e4b6f 100644 --- a/nemubot/modulecontext.py +++ b/nemubot/modulecontext.py @@ -108,7 +108,7 @@ class ModuleContext(_ModuleContext): self.config = context.modules_configuration[self.module_name] self.context = context - self.debug = context.verbosity > 0 + self.debug = context.debug def load_data(self): From 5aef66160149a344abdac42d335642648f122aa7 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 27 Aug 2017 18:22:53 +0200 Subject: [PATCH 47/57] Virtualy move all nemubot modules into nemubot.module.* hierarchy, to avoid conflict with system/vendor modules --- modules/alias.py | 2 +- modules/birthday.py | 2 +- modules/bonneannee.py | 4 +--- modules/books.py | 2 +- modules/cat.py | 2 +- modules/conjugaison.py | 2 +- modules/ctfs.py | 2 +- modules/cve.py | 2 +- modules/ddg.py | 2 +- modules/dig.py | 2 +- modules/disas.py | 2 +- modules/events.py | 2 +- modules/freetarifs.py | 2 +- modules/github.py | 2 +- modules/grep.py | 2 +- modules/imdb.py | 2 +- modules/jsonbot.py | 2 +- modules/man.py | 2 +- modules/mapquest.py | 2 +- modules/mediawiki.py | 2 +- modules/networking/__init__.py | 2 +- modules/networking/watchWebsite.py | 2 +- modules/networking/whois.py | 2 +- modules/news.py | 2 +- modules/openroute.py | 2 +- modules/pkgs.py | 2 +- modules/ratp.py | 2 +- modules/reddit.py | 2 +- modules/rnd.py | 2 +- modules/sap.py | 2 +- modules/shodan.py | 2 +- modules/sleepytime.py | 2 +- modules/sms.py | 2 +- modules/spell/__init__.py | 2 +- modules/suivi.py | 2 +- modules/syno.py | 2 +- modules/tpb.py | 2 +- modules/translate.py | 2 +- modules/urbandict.py | 2 +- modules/velib.py | 2 +- modules/virtualradar.py | 4 ++-- modules/weather.py | 4 ++-- modules/whois.py | 4 ++-- modules/wolframalpha.py | 2 +- modules/worldcup.py | 2 +- modules/youtube-title.py | 2 +- nemubot/__main__.py | 4 ++-- nemubot/bot.py | 13 +++++++------ nemubot/importer.py | 18 +++++++++--------- nemubot/module/__init__.py | 7 +++++++ {modules => nemubot/module}/more.py | 0 nemubot/modulecontext.py | 2 +- 52 files changed, 75 insertions(+), 69 deletions(-) create mode 100644 nemubot/module/__init__.py rename {modules => nemubot/module}/more.py (100%) diff --git a/modules/alias.py b/modules/alias.py index 5aae6bb..a246d2c 100644 --- a/modules/alias.py +++ b/modules/alias.py @@ -12,7 +12,7 @@ from nemubot.message import Command from nemubot.tools.human import guess from nemubot.tools.xmlparser.node import ModuleState -from more import Response +from nemubot.module.more import Response # LOADING ############################################################# diff --git a/modules/birthday.py b/modules/birthday.py index d8093b8..e1406d4 100644 --- a/modules/birthday.py +++ b/modules/birthday.py @@ -13,7 +13,7 @@ from nemubot.tools.countdown import countdown_format from nemubot.tools.date import extractDate from nemubot.tools.xmlparser.node import ModuleState -from more import Response +from nemubot.module.more import Response # LOADING ############################################################# diff --git a/modules/bonneannee.py b/modules/bonneannee.py index b3b3934..ab9ac7f 100644 --- a/modules/bonneannee.py +++ b/modules/bonneannee.py @@ -4,12 +4,10 @@ from datetime import datetime, timezone -from nemubot import context -from nemubot.event import ModuleEvent from nemubot.hooks import hook from nemubot.tools.countdown import countdown_format -from more import Response +from nemubot.module.more import Response # GLOBALS ############################################################# diff --git a/modules/books.py b/modules/books.py index df48056..5ab404b 100644 --- a/modules/books.py +++ b/modules/books.py @@ -9,7 +9,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response # LOADING ############################################################# diff --git a/modules/cat.py b/modules/cat.py index 0619cee..5eb3e19 100644 --- a/modules/cat.py +++ b/modules/cat.py @@ -7,7 +7,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.message import Command, DirectAsk, Text -from more import Response +from nemubot.module.more import Response # MODULE CORE ######################################################### diff --git a/modules/conjugaison.py b/modules/conjugaison.py index 25fe242..42d78c6 100644 --- a/modules/conjugaison.py +++ b/modules/conjugaison.py @@ -11,7 +11,7 @@ from nemubot.hooks import hook from nemubot.tools import web from nemubot.tools.web import striphtml -from more import Response +from nemubot.module.more import Response # GLOBALS ############################################################# diff --git a/modules/ctfs.py b/modules/ctfs.py index 1526cbc..169ee46 100644 --- a/modules/ctfs.py +++ b/modules/ctfs.py @@ -6,7 +6,7 @@ from bs4 import BeautifulSoup from nemubot.hooks import hook from nemubot.tools.web import getURLContent, striphtml -from more import Response +from nemubot.module.more import Response # GLOBALS ############################################################# diff --git a/modules/cve.py b/modules/cve.py index 6cdb339..b9cf1c3 100644 --- a/modules/cve.py +++ b/modules/cve.py @@ -9,7 +9,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools.web import getURLContent, striphtml -from more import Response +from nemubot.module.more import Response BASEURL_NIST = 'https://nvd.nist.gov/vuln/detail/' diff --git a/modules/ddg.py b/modules/ddg.py index d94bd61..089409b 100644 --- a/modules/ddg.py +++ b/modules/ddg.py @@ -8,7 +8,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response # MODULE CORE ######################################################### diff --git a/modules/dig.py b/modules/dig.py index de7b2a3..bec0a87 100644 --- a/modules/dig.py +++ b/modules/dig.py @@ -14,7 +14,7 @@ import dns.resolver from nemubot.exception import IMException from nemubot.hooks import hook -from more import Response +from nemubot.module.more import Response # MODULE INTERFACE #################################################### diff --git a/modules/disas.py b/modules/disas.py index 7c17907..cb80ef3 100644 --- a/modules/disas.py +++ b/modules/disas.py @@ -7,7 +7,7 @@ import capstone from nemubot.exception import IMException from nemubot.hooks import hook -from more import Response +from nemubot.module.more import Response # MODULE CORE ######################################################### diff --git a/modules/events.py b/modules/events.py index f6c6621..9814aa2 100644 --- a/modules/events.py +++ b/modules/events.py @@ -12,7 +12,7 @@ from nemubot.tools.countdown import countdown_format, countdown from nemubot.tools.date import extractDate from nemubot.tools.xmlparser.node import ModuleState -from more import Response +from nemubot.module.more import Response def help_full (): diff --git a/modules/freetarifs.py b/modules/freetarifs.py index b96a30f..49ad8a6 100644 --- a/modules/freetarifs.py +++ b/modules/freetarifs.py @@ -9,7 +9,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response # MODULE CORE ######################################################### diff --git a/modules/github.py b/modules/github.py index ddd0851..5f9a7d9 100644 --- a/modules/github.py +++ b/modules/github.py @@ -9,7 +9,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response # MODULE CORE ######################################################### diff --git a/modules/grep.py b/modules/grep.py index 6a26c02..5c25c7d 100644 --- a/modules/grep.py +++ b/modules/grep.py @@ -9,7 +9,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.message import Command, Text -from more import Response +from nemubot.module.more import Response # MODULE CORE ######################################################### diff --git a/modules/imdb.py b/modules/imdb.py index bd1cadf..d5ff158 100644 --- a/modules/imdb.py +++ b/modules/imdb.py @@ -11,7 +11,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response # MODULE CORE ######################################################### diff --git a/modules/jsonbot.py b/modules/jsonbot.py index fe25187..3126dc1 100644 --- a/modules/jsonbot.py +++ b/modules/jsonbot.py @@ -1,7 +1,7 @@ from nemubot.hooks import hook from nemubot.exception import IMException from nemubot.tools import web -from more import Response +from nemubot.module.more import Response import json nemubotversion = 3.4 diff --git a/modules/man.py b/modules/man.py index f45e30d..f60e0cf 100644 --- a/modules/man.py +++ b/modules/man.py @@ -8,7 +8,7 @@ import os from nemubot.hooks import hook -from more import Response +from nemubot.module.more import Response # GLOBALS ############################################################# diff --git a/modules/mapquest.py b/modules/mapquest.py index 1caa41c..5662a49 100644 --- a/modules/mapquest.py +++ b/modules/mapquest.py @@ -9,7 +9,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response # GLOBALS ############################################################# diff --git a/modules/mediawiki.py b/modules/mediawiki.py index cb3d1da..be608ca 100644 --- a/modules/mediawiki.py +++ b/modules/mediawiki.py @@ -11,7 +11,7 @@ from nemubot.tools import web nemubotversion = 3.4 -from more import Response +from nemubot.module.more import Response # MEDIAWIKI REQUESTS ################################################## diff --git a/modules/networking/__init__.py b/modules/networking/__init__.py index f0df094..3b939ab 100644 --- a/modules/networking/__init__.py +++ b/modules/networking/__init__.py @@ -8,7 +8,7 @@ import re from nemubot.exception import IMException from nemubot.hooks import hook -from more import Response +from nemubot.module.more import Response from . import isup from . import page diff --git a/modules/networking/watchWebsite.py b/modules/networking/watchWebsite.py index 4945981..adedbee 100644 --- a/modules/networking/watchWebsite.py +++ b/modules/networking/watchWebsite.py @@ -12,7 +12,7 @@ from nemubot.tools.xmlparser.node import ModuleState logger = logging.getLogger("nemubot.module.networking.watchWebsite") -from more import Response +from nemubot.module.more import Response from . import page diff --git a/modules/networking/whois.py b/modules/networking/whois.py index d3d30b1..787cd17 100644 --- a/modules/networking/whois.py +++ b/modules/networking/whois.py @@ -6,7 +6,7 @@ import urllib from nemubot.exception import IMException from nemubot.tools.web import getJSON -from more import Response +from nemubot.module.more import Response URL_AVAIL = "https://www.whoisxmlapi.com/whoisserver/WhoisService?cmd=GET_DN_AVAILABILITY&domainName=%%s&outputFormat=json&username=%s&password=%s" URL_WHOIS = "http://www.whoisxmlapi.com/whoisserver/WhoisService?da=2&domainName=%%s&outputFormat=json&userName=%s&password=%s" diff --git a/modules/news.py b/modules/news.py index a8fb8de..40daa92 100644 --- a/modules/news.py +++ b/modules/news.py @@ -12,7 +12,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response from nemubot.tools.feed import Feed, AtomEntry diff --git a/modules/openroute.py b/modules/openroute.py index 440b05a..c280dec 100644 --- a/modules/openroute.py +++ b/modules/openroute.py @@ -9,7 +9,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response # GLOBALS ############################################################# diff --git a/modules/pkgs.py b/modules/pkgs.py index 5a7b0a9..386946f 100644 --- a/modules/pkgs.py +++ b/modules/pkgs.py @@ -8,7 +8,7 @@ from nemubot import context from nemubot.exception import IMException from nemubot.hooks import hook -from more import Response +from nemubot.module.more import Response DB = None diff --git a/modules/ratp.py b/modules/ratp.py index 7f4b211..06f5f1d 100644 --- a/modules/ratp.py +++ b/modules/ratp.py @@ -4,7 +4,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook -from more import Response +from nemubot.module.more import Response from nextstop import ratp diff --git a/modules/reddit.py b/modules/reddit.py index ae28999..2de7612 100644 --- a/modules/reddit.py +++ b/modules/reddit.py @@ -10,7 +10,7 @@ from nemubot.tools import web nemubotversion = 3.4 -from more import Response +from nemubot.module.more import Response def help_full(): diff --git a/modules/rnd.py b/modules/rnd.py index 6044bd4..d1c6fe7 100644 --- a/modules/rnd.py +++ b/modules/rnd.py @@ -9,7 +9,7 @@ from nemubot import context from nemubot.exception import IMException from nemubot.hooks import hook -from more import Response +from nemubot.module.more import Response # MODULE INTERFACE #################################################### diff --git a/modules/sap.py b/modules/sap.py index 8691d6a..a6168a2 100644 --- a/modules/sap.py +++ b/modules/sap.py @@ -12,7 +12,7 @@ from nemubot.tools import web nemubotversion = 4.0 -from more import Response +from nemubot.module.more import Response def help_full(): diff --git a/modules/shodan.py b/modules/shodan.py index 4b2edae..9c158c6 100644 --- a/modules/shodan.py +++ b/modules/shodan.py @@ -11,7 +11,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response # GLOBALS ############################################################# diff --git a/modules/sleepytime.py b/modules/sleepytime.py index 715b3b9..f7fb626 100644 --- a/modules/sleepytime.py +++ b/modules/sleepytime.py @@ -10,7 +10,7 @@ from nemubot.hooks import hook nemubotversion = 3.4 -from more import Response +from nemubot.module.more import Response def help_full(): diff --git a/modules/sms.py b/modules/sms.py index ca7e9f0..7db172b 100644 --- a/modules/sms.py +++ b/modules/sms.py @@ -16,7 +16,7 @@ from nemubot.tools.xmlparser.node import ModuleState nemubotversion = 3.4 -from more import Response +from nemubot.module.more import Response def load(context): context.data.setIndex("name", "phone") diff --git a/modules/spell/__init__.py b/modules/spell/__init__.py index c15f5fc..da16a80 100644 --- a/modules/spell/__init__.py +++ b/modules/spell/__init__.py @@ -10,7 +10,7 @@ from nemubot.tools.xmlparser.node import ModuleState from .pyaspell import Aspell from .pyaspell import AspellError -from more import Response +from nemubot.module.more import Response # LOADING ############################################################# diff --git a/modules/suivi.py b/modules/suivi.py index 6ad13e9..4bc079e 100644 --- a/modules/suivi.py +++ b/modules/suivi.py @@ -10,7 +10,7 @@ import re from nemubot.hooks import hook from nemubot.exception import IMException from nemubot.tools.web import getURLContent, getJSON -from more import Response +from nemubot.module.more import Response # POSTAGE SERVICE PARSERS ############################################ diff --git a/modules/syno.py b/modules/syno.py index 4bdc990..bda0456 100644 --- a/modules/syno.py +++ b/modules/syno.py @@ -9,7 +9,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response # LOADING ############################################################# diff --git a/modules/tpb.py b/modules/tpb.py index ce98b04..a752324 100644 --- a/modules/tpb.py +++ b/modules/tpb.py @@ -8,7 +8,7 @@ from nemubot.tools.web import getJSON nemubotversion = 4.0 -from more import Response +from nemubot.module.more import Response URL_TPBAPI = None diff --git a/modules/translate.py b/modules/translate.py index 9d50966..906ba93 100644 --- a/modules/translate.py +++ b/modules/translate.py @@ -8,7 +8,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response # GLOBALS ############################################################# diff --git a/modules/urbandict.py b/modules/urbandict.py index e90c096..a897fad 100644 --- a/modules/urbandict.py +++ b/modules/urbandict.py @@ -8,7 +8,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response # MODULE CORE ######################################################### diff --git a/modules/velib.py b/modules/velib.py index 8ef6833..71c472c 100644 --- a/modules/velib.py +++ b/modules/velib.py @@ -9,7 +9,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response # LOADING ############################################################# diff --git a/modules/virtualradar.py b/modules/virtualradar.py index d7448ce..9382d3b 100644 --- a/modules/virtualradar.py +++ b/modules/virtualradar.py @@ -10,8 +10,8 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response -import mapquest +from nemubot.module.more import Response +from nemubot.module import mapquest # GLOBALS ############################################################# diff --git a/modules/weather.py b/modules/weather.py index 3f74b8e..bee0d20 100644 --- a/modules/weather.py +++ b/modules/weather.py @@ -11,11 +11,11 @@ from nemubot.hooks import hook from nemubot.tools import web from nemubot.tools.xmlparser.node import ModuleState -import mapquest +from nemubot.module import mapquest nemubotversion = 4.0 -from more import Response +from nemubot.module.more import Response URL_DSAPI = "https://api.darksky.net/forecast/%s/%%s,%%s?lang=%%s&units=%%s" diff --git a/modules/whois.py b/modules/whois.py index 00eb940..d6106dd 100644 --- a/modules/whois.py +++ b/modules/whois.py @@ -10,8 +10,8 @@ from nemubot.tools.xmlparser.node import ModuleState nemubotversion = 3.4 -from more import Response -from networking.page import headers +from nemubot.module.more import Response +from nemubot.module.networking.page import headers PASSWD_FILE = None # You can get one with: curl -b "sessionid=YOURSESSIONID" 'https://accounts.cri.epita.net/api/users/' > users.json diff --git a/modules/wolframalpha.py b/modules/wolframalpha.py index e6bf86c..b7cc7fb 100644 --- a/modules/wolframalpha.py +++ b/modules/wolframalpha.py @@ -10,7 +10,7 @@ from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools import web -from more import Response +from nemubot.module.more import Response # LOADING ############################################################# diff --git a/modules/worldcup.py b/modules/worldcup.py index ff3e0c4..b12ca30 100644 --- a/modules/worldcup.py +++ b/modules/worldcup.py @@ -15,7 +15,7 @@ from nemubot.tools.xmlparser.node import ModuleState nemubotversion = 3.4 -from more import Response +from nemubot.module.more import Response API_URL="http://worldcup.sfg.io/%s" diff --git a/modules/youtube-title.py b/modules/youtube-title.py index fe62cda..41b613a 100644 --- a/modules/youtube-title.py +++ b/modules/youtube-title.py @@ -4,7 +4,7 @@ import re, json, subprocess from nemubot.exception import IMException from nemubot.hooks import hook from nemubot.tools.web import _getNormalizedURL, getURLContent -from more import Response +from nemubot.module.more import Response """Get information of youtube videos""" diff --git a/nemubot/__main__.py b/nemubot/__main__.py index 8d51249..b79d90e 100644 --- a/nemubot/__main__.py +++ b/nemubot/__main__.py @@ -153,7 +153,7 @@ def main(): context.modules_configuration[mod.name] = mod if mod.autoload: try: - __import__(mod.name) + __import__("nemubot.module." + mod.name) except: logger.exception("Exception occurs when loading module" " '%s'", mod.name) @@ -164,7 +164,7 @@ def main(): if args.module: for module in args.module: - __import__(module) + __import__("nemubot.module." + module) if args.socketfile: from nemubot.server.socket import UnixSocketListener diff --git a/nemubot/bot.py b/nemubot/bot.py index 0e58a9d..0aaced2 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -97,18 +97,19 @@ class Bot(threading.Thread): def _help_msg(msg): """Parse and response to help messages""" - from more import Response + from nemubot.module.more import Response res = Response(channel=msg.to_response) if len(msg.args) >= 1: - if msg.args[0] in self.modules and self.modules[msg.args[0]]() is not None: - if hasattr(self.modules[msg.args[0]](), "help_full"): - hlp = self.modules[msg.args[0]]().help_full() + if "nemubot.module." + msg.args[0] in self.modules and self.modules["nemubot.module." + msg.args[0]]() is not None: + mname = "nemubot.module." + msg.args[0] + if hasattr(self.modules[mname](), "help_full"): + hlp = self.modules[mname]().help_full() if isinstance(hlp, Response): return hlp else: res.append_message(hlp) else: - res.append_message([str(h) for s,h in self.modules[msg.args[0]]().__nemubot_context__.hooks], title="Available commands for module " + msg.args[0]) + res.append_message([str(h) for s,h in self.modules[mname]().__nemubot_context__.hooks], title="Available commands for module " + msg.args[0]) elif msg.args[0][0] == "!": from nemubot.message.command import Command for h in self.treater._in_hooks(Command(msg.args[0][1:])): @@ -138,7 +139,7 @@ class Bot(threading.Thread): res.append_message(title="Pour plus de détails sur un module, " "envoyez \"!help nomdumodule\". Voici la liste" " de tous les modules disponibles localement", - message=["\x03\x02%s\x03\x02 (%s)" % (im, self.modules[im]().__doc__) for im in self.modules if self.modules[im]() is not None and self.modules[im]().__doc__]) + message=["\x03\x02%s\x03\x02 (%s)" % (im.replace("nemubot.module.", ""), self.modules[im]().__doc__) for im in self.modules if self.modules[im]() is not None and self.modules[im]().__doc__]) return res self.treater.hm.add_hook(nemubot.hooks.Command(_help_msg, "help"), "in", "Command") diff --git a/nemubot/importer.py b/nemubot/importer.py index eaf1535..674ab40 100644 --- a/nemubot/importer.py +++ b/nemubot/importer.py @@ -29,16 +29,16 @@ class ModuleFinder(Finder): self.add_module = add_module def find_module(self, fullname, path=None): - # Search only for new nemubot modules (packages init) - if path is None: + if path is not None and fullname.startswith("nemubot.module."): + module_name = fullname.split(".", 2)[2] for mpath in self.modules_paths: - if os.path.isfile(os.path.join(mpath, fullname + ".py")): + if os.path.isfile(os.path.join(mpath, module_name + ".py")): return ModuleLoader(self.add_module, fullname, - os.path.join(mpath, fullname + ".py")) - elif os.path.isfile(os.path.join(os.path.join(mpath, fullname), "__init__.py")): + os.path.join(mpath, module_name + ".py")) + elif os.path.isfile(os.path.join(os.path.join(mpath, module_name), "__init__.py")): return ModuleLoader(self.add_module, fullname, os.path.join( - os.path.join(mpath, fullname), + os.path.join(mpath, module_name), "__init__.py")) return None @@ -53,17 +53,17 @@ class ModuleLoader(SourceFileLoader): def _load(self, module, name): # Add the module to the global modules list self.add_module(module) - logger.info("Module '%s' successfully loaded.", name) + logger.info("Module '%s' successfully imported from %s.", name.split(".", 2)[2], self.path) return module # Python 3.4 def exec_module(self, module): - super(ModuleLoader, self).exec_module(module) + super().exec_module(module) self._load(module, module.__spec__.name) # Python 3.3 def load_module(self, fullname): - module = super(ModuleLoader, self).load_module(fullname) + module = super().load_module(fullname) return self._load(module, module.__name__) diff --git a/nemubot/module/__init__.py b/nemubot/module/__init__.py new file mode 100644 index 0000000..33f0e41 --- /dev/null +++ b/nemubot/module/__init__.py @@ -0,0 +1,7 @@ +# +# This directory aims to store nemubot core modules. +# +# Custom modules should be placed into a separate directory. +# By default, this is the directory modules in your current directory. +# Use the --modules-path argument to define a custom directory for your modules. +# diff --git a/modules/more.py b/nemubot/module/more.py similarity index 100% rename from modules/more.py rename to nemubot/module/more.py diff --git a/nemubot/modulecontext.py b/nemubot/modulecontext.py index 70e4b6f..d6291c4 100644 --- a/nemubot/modulecontext.py +++ b/nemubot/modulecontext.py @@ -20,7 +20,7 @@ class _ModuleContext: self.module = module if module is not None: - self.module_name = module.__spec__.name if hasattr(module, "__spec__") else module.__name__ + self.module_name = (module.__spec__.name if hasattr(module, "__spec__") else module.__name__).replace("nemubot.module.", "") else: self.module_name = "" From 350e0f5f596da4eac2db80a86bfc9629598aba3f Mon Sep 17 00:00:00 2001 From: nemunaire Date: Sun, 20 Aug 2017 21:17:08 +0200 Subject: [PATCH 48/57] datastore: support custom knodes instead of nemubotstate --- nemubot/datastore/abstract.py | 6 +++++- nemubot/datastore/xml.py | 24 +++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/nemubot/datastore/abstract.py b/nemubot/datastore/abstract.py index 96e2c0d..aeaecc6 100644 --- a/nemubot/datastore/abstract.py +++ b/nemubot/datastore/abstract.py @@ -32,16 +32,20 @@ class Abstract: def close(self): return - def load(self, module): + def load(self, module, knodes): """Load data for the given module Argument: module -- the module name of data to load + knodes -- the schema to use to load the datas Return: The loaded data """ + if knodes is not None: + return None + return self.new() def save(self, module, data): diff --git a/nemubot/datastore/xml.py b/nemubot/datastore/xml.py index 025c0c5..aa6cbd0 100644 --- a/nemubot/datastore/xml.py +++ b/nemubot/datastore/xml.py @@ -83,27 +83,38 @@ class XML(Abstract): return os.path.join(self.basedir, module + ".xml") - def load(self, module): + def load(self, module, knodes): """Load data for the given module Argument: module -- the module name of data to load + knodes -- the schema to use to load the datas """ data_file = self._get_data_file_path(module) + if knodes is None: + from nemubot.tools.xmlparser import parse_file + def _true_load(path): + return parse_file(path) + + else: + from nemubot.tools.xmlparser import XMLParser + p = XMLParser(knodes) + def _true_load(path): + return p.parse_file(path) + # Try to load original file if os.path.isfile(data_file): - from nemubot.tools.xmlparser import parse_file try: - return parse_file(data_file) + return _true_load(data_file) except xml.parsers.expat.ExpatError: # Try to load from backup for i in range(10): path = data_file + "." + str(i) if os.path.isfile(path): try: - cnt = parse_file(path) + cnt = _true_load(path) logger.warn("Restoring from backup: %s", path) @@ -112,7 +123,7 @@ class XML(Abstract): continue # Default case: initialize a new empty datastore - return Abstract.load(self, module) + return super().load(module, knodes) def _rotate(self, path): """Backup given path @@ -143,6 +154,9 @@ class XML(Abstract): if self.rotate: self._rotate(path) + if data is None: + return + import tempfile _, tmpath = tempfile.mkstemp() with open(tmpath, "w") as f: From f26d95963e4bc09e3c50695bd575f198075b7a2d Mon Sep 17 00:00:00 2001 From: nemunaire Date: Fri, 1 Sep 2017 20:45:58 +0200 Subject: [PATCH 49/57] xmlparser: make DictNode more usable --- nemubot/tools/xmlparser/basic.py | 33 ++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/nemubot/tools/xmlparser/basic.py b/nemubot/tools/xmlparser/basic.py index 86eac3c..dadff23 100644 --- a/nemubot/tools/xmlparser/basic.py +++ b/nemubot/tools/xmlparser/basic.py @@ -77,12 +77,12 @@ class DictNode: def endElement(self, name): - if name is None or self._cur is None: + if name is not None or self._cur is None: return key, cnt = self._cur if isinstance(cnt, list) and len(cnt) == 1: - self.items[key] = cnt + self.items[key] = cnt[0] else: self.items[key] = cnt @@ -122,7 +122,32 @@ class DictNode: if isinstance(v, str): store.characters(v) else: - for i in v: - i.saveElement(store) + if hasattr(v, "__iter__"): + for i in v: + i.saveElement(store) + else: + v.saveElement(store) store.endElement("item") store.endElement(tag) + + + def __contain__(self, i): + return i in self.items + + def __getitem__(self, i): + return self.items[i] + + def __setitem__(self, i, c): + self.items[i] = c + + def __delitem__(self, k): + del self.items[k] + + def __iter__(self): + return self.items.__iter__() + + def keys(self): + return self.items.keys() + + def items(self): + return self.items.items() From c6b5aab917d1579cbdd3a5ab948b920a8dba60c9 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 18 Jul 2017 07:18:06 +0200 Subject: [PATCH 50/57] Start using asyncio for signals --- README.md | 2 +- nemubot/__main__.py | 33 +++++++++------------------------ nemubot/bot.py | 9 ++++++++- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 1d40faf..c1d8fd2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ An extremely modulable IRC bot, built around XML configuration files! Requirements ------------ -*nemubot* requires at least Python 3.3 to work. +*nemubot* requires at least Python 3.4 to work, as it uses `asyncio`. Connecting to SSL server requires [this patch](http://bugs.python.org/issue27629). diff --git a/nemubot/__main__.py b/nemubot/__main__.py index b79d90e..232240c 100644 --- a/nemubot/__main__.py +++ b/nemubot/__main__.py @@ -178,25 +178,13 @@ def main(): daemonize(args.socketfile, not args.no_attach) # Signals handling - def sigtermhandler(signum, frame): + def sigtermhandler(): """On SIGTERM and SIGINT, quit nicely""" context.quit() - signal.signal(signal.SIGINT, sigtermhandler) - signal.signal(signal.SIGTERM, sigtermhandler) + context.loop.add_signal_handler(signal.SIGINT, sigtermhandler) + context.loop.add_signal_handler(signal.SIGTERM, sigtermhandler) - def sighuphandler(signum, frame): - """On SIGHUP, perform a deep reload""" - nonlocal context - - logger.debug("SIGHUP receive, iniate reload procedure...") - - # Reload configuration file - for path in args.files: - if os.path.isfile(path): - sync_act("loadconf", path) - signal.signal(signal.SIGHUP, sighuphandler) - - def sigusr1handler(signum, frame): + def sigusr1handler(): """On SIGHUSR1, display stacktraces""" import threading, traceback for threadId, stack in sys._current_frames().items(): @@ -208,24 +196,21 @@ def main(): logger.debug("########### Thread %s:\n%s", thName, "".join(traceback.format_stack(stack))) - signal.signal(signal.SIGUSR1, sigusr1handler) + context.loop.add_signal_handler(signal.SIGUSR1, sigusr1handler) # Store PID to pidfile if args.pidfile is not None: with open(args.pidfile, "w+") as f: f.write(str(os.getpid())) - # context can change when performing an hotswap, always join the latest context - oldcontext = None - while oldcontext != context: - oldcontext = context - context.start() - context.join() + context.start() + context.loop.run_forever() + context.join() # Wait for consumers logger.info("Waiting for other threads shuts down...") if args.debug: - sigusr1handler(0, None) + sigusr1handler() sys.exit(0) diff --git a/nemubot/bot.py b/nemubot/bot.py index 0aaced2..09a8277 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import asyncio from datetime import datetime, timezone import logging from multiprocessing import JoinableQueue @@ -40,7 +41,7 @@ class Bot(threading.Thread): """Class containing the bot context and ensuring key goals""" def __init__(self, ip="127.0.0.1", modules_paths=list(), - data_store=datastore.Abstract(), debug=False): + data_store=datastore.Abstract(), debug=False, loop=None): """Initialize the bot context Keyword arguments: @@ -59,6 +60,9 @@ class Bot(threading.Thread): self.debug = debug self.stop = None + # + self.loop = loop if loop is not None else asyncio.get_event_loop() + # External IP for accessing this bot import ipaddress self.ip = ipaddress.ip_address(ip) @@ -522,6 +526,9 @@ class Bot(threading.Thread): for cnsr in k: cnsr.stop = True + logger.info("Closing event loop") + self.loop.stop() + if self.stop is False or sync_queue is not None: self.stop = True sync_act("end") From fcff53d96411f433db73e7d6ede833f0ff746b55 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 19 Jul 2017 07:51:19 +0200 Subject: [PATCH 51/57] In debug mode, display the last stack element to be able to trace --- nemubot/server/abstract.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nemubot/server/abstract.py b/nemubot/server/abstract.py index fd25c2d..1c05447 100644 --- a/nemubot/server/abstract.py +++ b/nemubot/server/abstract.py @@ -16,6 +16,7 @@ import logging import queue +import traceback from nemubot.bot import sync_act @@ -84,7 +85,7 @@ class AbstractServer: """ self._sending_queue.put(self.format(message)) - self.logger.debug("Message '%s' appended to write queue", message) + self.logger.debug("Message '%s' appended to write queue coming from %s:%d in %s", message, *traceback.extract_stack(limit=3)[0][:3]) sync_act("sckt", "write", self.fileno()) From 2d9a533dc42e64158cd494e7a925cd33a470e647 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 19 Jul 2017 23:32:01 +0200 Subject: [PATCH 52/57] Enable asyncio debug mode --- nemubot/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nemubot/__main__.py b/nemubot/__main__.py index 232240c..2338090 100644 --- a/nemubot/__main__.py +++ b/nemubot/__main__.py @@ -204,6 +204,7 @@ def main(): f.write(str(os.getpid())) context.start() + context.loop.set_debug(args.verbose > 0) context.loop.run_forever() context.join() From 69dcd53937e62a1d7c929ec5c7ab9bc515c1e282 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 20 Jul 2017 23:34:20 +0200 Subject: [PATCH 53/57] Start a huge refactor of events --- modules/events.py | 18 ++--- nemubot/bot.py | 147 ++++++-------------------------------- nemubot/consumer.py | 9 +-- nemubot/event/__init__.py | 37 ++++------ nemubot/modulecontext.py | 55 ++++++++++---- 5 files changed, 87 insertions(+), 179 deletions(-) diff --git a/modules/events.py b/modules/events.py index 9814aa2..ad493a4 100644 --- a/modules/events.py +++ b/modules/events.py @@ -26,10 +26,8 @@ def load(context): for evt in context.data.index.keys(): if context.data.index[evt].hasAttribute("end"): event = ModuleEvent(call=fini, call_data=dict(strend=context.data.index[evt])) - event._end = context.data.index[evt].getDate("end") - idt = context.add_event(event) - if idt is not None: - context.data.index[evt]["_id"] = idt + event.schedule(context.data.index[evt].getDate("end")) + context.add_event(event) def fini(d, strend): @@ -100,8 +98,8 @@ def start_countdown(msg): strnd["end"] = datetime(now.year, now.month, now.day, hou, minu, sec, timezone.utc) else: strnd["end"] = datetime(now.year, now.month, now.day + 1, hou, minu, sec, timezone.utc) - evt._end = strnd.getDate("end") - strnd["_id"] = context.add_event(evt) + evt.schedule(strnd.getDate("end")) + context.add_event(evt) except: context.data.delChild(strnd) raise IMException("Mauvais format de date pour l'événement %s. Il n'a pas été créé." % msg.args[0]) @@ -121,10 +119,8 @@ def start_countdown(msg): strnd["end"] += timedelta(days=int(t)*365) else: strnd["end"] += timedelta(seconds=int(t)) - evt._end = strnd.getDate("end") - eid = context.add_event(evt) - if eid is not None: - strnd["_id"] = eid + evt.schedule(strnd.getDate("end")) + context.add_event(evt) context.save() if "end" in strnd: @@ -147,7 +143,7 @@ def end_countdown(msg): if msg.args[0] in context.data.index: if context.data.index[msg.args[0]]["proprio"] == msg.frm or (msg.cmd == "forceend" and msg.frm_owner): duration = countdown(msg.date - context.data.index[msg.args[0]].getDate("start")) - context.del_event(context.data.index[msg.args[0]]["_id"]) + context.del_event(context.data.index[msg.args[0]]) context.data.delChild(context.data.index[msg.args[0]]) context.save() return Response("%s a duré %s." % (msg.args[0], duration), diff --git a/nemubot/bot.py b/nemubot/bot.py index 09a8277..8038b5a 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -19,6 +19,7 @@ from datetime import datetime, timezone import logging from multiprocessing import JoinableQueue import threading +import traceback import select import sys import weakref @@ -78,10 +79,6 @@ class Bot(threading.Thread): self.modules = dict() self.modules_configuration = dict() - # Events - self.events = list() - self.event_timer = None - # Own hooks from nemubot.treatment import MessageTreater self.treater = MessageTreater() @@ -245,7 +242,7 @@ class Bot(threading.Thread): # Events methods - def add_event(self, evt, eid=None, module_src=None): + def add_event(self, evt): """Register an event and return its identifiant for futur update Return: @@ -254,129 +251,31 @@ class Bot(threading.Thread): Argument: evt -- The event object to add - - Keyword arguments: - eid -- The desired event ID (object or string UUID) - module_src -- The module to which the event is attached to """ - if hasattr(self, "stop") and self.stop: - logger.warn("The bot is stopped, can't register new events") - return + if hasattr(evt, "handle") and evt.handle is not None: + raise Exception("Try to launch an already launched event.") - import uuid + def _end_event_timer(event): + """Function called at the end of the event timer""" - # Generate the event id if no given - if eid is None: - eid = uuid.uuid1() - - # Fill the id field of the event - if type(eid) is uuid.UUID: - evt.id = str(eid) - else: - # Ok, this is quiet useless... - try: - evt.id = str(uuid.UUID(eid)) - except ValueError: - evt.id = eid - - # TODO: mutex here plz - - # Add the event in its place - t = evt.current - i = 0 # sentinel - for i in range(0, len(self.events)): - if self.events[i].current > t: - break - self.events.insert(i, evt) - - if i == 0: - # First event changed, reset timer - self._update_event_timer() - if len(self.events) <= 0 or self.events[i] != evt: - # Our event has been executed and removed from queue - return None - - # Register the event in the source module - if module_src is not None: - module_src.__nemubot_context__.events.append(evt.id) - evt.module_src = module_src - - logger.info("New event registered in %d position: %s", i, t) - return evt.id - - - def del_event(self, evt, module_src=None): - """Find and remove an event from list - - Return: - True if the event has been found and removed, False else - - Argument: - evt -- The ModuleEvent object to remove or just the event identifier - - Keyword arguments: - module_src -- The module to which the event is attached to (ignored if evt is a ModuleEvent) - """ - - logger.info("Removing event: %s from %s", evt, module_src) - - from nemubot.event import ModuleEvent - if type(evt) is ModuleEvent: - id = evt.id - module_src = evt.module_src - else: - id = evt - - if len(self.events) > 0 and id == self.events[0].id: - self.events.remove(self.events[0]) - self._update_event_timer() - if module_src is not None: - module_src.__nemubot_context__.events.remove(id) - return True - - for evt in self.events: - if evt.id == id: - self.events.remove(evt) - - if module_src is not None: - module_src.__nemubot_context__.events.remove(evt.id) - return True - return False - - - def _update_event_timer(self): - """(Re)launch the timer to end with the closest event""" - - # Reset the timer if this is the first item - if self.event_timer is not None: - self.event_timer.cancel() - - if len(self.events): - try: - remaining = self.events[0].time_left.total_seconds() - except: - logger.exception("An error occurs during event time calculation:") - self.events.pop(0) - return self._update_event_timer() - - logger.debug("Update timer: next event in %d seconds", remaining) - self.event_timer = threading.Timer(remaining if remaining > 0 else 0, self._end_event_timer) - self.event_timer.start() - - else: - logger.debug("Update timer: no timer left") - - - def _end_event_timer(self): - """Function called at the end of the event timer""" - - while len(self.events) > 0 and datetime.now(timezone.utc) >= self.events[0].current: - evt = self.events.pop(0) - self.cnsr_queue.put_nowait(EventConsumer(evt)) + logger.debug("Trigering event") + event.handle = None + self.cnsr_queue.put_nowait(EventConsumer(event)) sync_act("launch_consumer") - self._update_event_timer() + evt.start(self.loop) + + @asyncio.coroutine + def _add_event(): + return self.loop.call_at(evt._next, _end_event_timer, evt) + future = asyncio.run_coroutine_threadsafe(_add_event(), loop=self.loop) + evt.handle = future.result() + + logger.debug("New event registered in %ss", evt._next - self.loop.time()) + + return evt.handle + # Consumers methods @@ -509,10 +408,6 @@ class Bot(threading.Thread): def quit(self): """Save and unload modules and disconnect servers""" - if self.event_timer is not None: - logger.info("Stop the event timer...") - self.event_timer.cancel() - logger.info("Save and unload all modules...") for mod in [m for m in self.modules.keys()]: self.unload_module(mod) diff --git a/nemubot/consumer.py b/nemubot/consumer.py index 2765aff..792fc9b 100644 --- a/nemubot/consumer.py +++ b/nemubot/consumer.py @@ -88,13 +88,8 @@ class EventConsumer: logger.exception("Error during event end") # Reappend the event in the queue if it has next iteration - if self.evt.next is not None: - context.add_event(self.evt, eid=self.evt.id) - - # Or remove reference of this event - elif (hasattr(self.evt, "module_src") and - self.evt.module_src is not None): - self.evt.module_src.__nemubot_context__.events.remove(self.evt.id) + if self.evt.next(): + context.add_event(self.evt) diff --git a/nemubot/event/__init__.py b/nemubot/event/__init__.py index 981cf4b..52a721c 100644 --- a/nemubot/event/__init__.py +++ b/nemubot/event/__init__.py @@ -65,37 +65,28 @@ class ModuleEvent: # Store times self.offset = timedelta(seconds=offset) # Time to wait before the first check self.interval = timedelta(seconds=interval) - self._end = None # Cache + self._next = None # Cache # How many times do this event? self.times = times - @property - def current(self): - """Return the date of the near check""" - if self.times != 0: - if self._end is None: - self._end = datetime.now(timezone.utc) + self.offset + self.interval - return self._end - return None - @property + def start(self, loop): + if self._next is None: + self._next = loop.time() + self.offset.total_seconds() + self.interval.total_seconds() + + + def schedule(self, end): + self.interval = timedelta(seconds=0) + self.offset = end - datetime.now(timezone.utc) + + def next(self): - """Return the date of the next check""" if self.times != 0: - if self._end is None: - return self.current - elif self._end < datetime.now(timezone.utc): - self._end += self.interval - return self._end - return None + self._next += self.interval.total_seconds() + return True + return False - @property - def time_left(self): - """Return the time left before/after the near check""" - if self.current is not None: - return self.current - datetime.now(timezone.utc) - return timedelta.max def check(self): """Run a check and realized the event if this is time""" diff --git a/nemubot/modulecontext.py b/nemubot/modulecontext.py index d6291c4..b3b793c 100644 --- a/nemubot/modulecontext.py +++ b/nemubot/modulecontext.py @@ -14,6 +14,19 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import asyncio + +class _FakeHandle: + + def __init__(self, true_handle, callback): + self.handle = true_handle + self.callback = callback + + def cancel(self): + self.handle.cancel() + if self.callback: + return self.callback() + class _ModuleContext: def __init__(self, module=None): @@ -24,8 +37,8 @@ class _ModuleContext: else: self.module_name = "" - self.hooks = list() self.events = list() + self.hooks = list() self.debug = False from nemubot.config.module import Module @@ -36,6 +49,7 @@ class _ModuleContext: from nemubot.tools.xmlparser import module_state return module_state.ModuleState("nemubotstate") + def add_hook(self, hook, *triggers): from nemubot.hooks import Abstract as AbstractHook assert isinstance(hook, AbstractHook), hook @@ -46,19 +60,17 @@ class _ModuleContext: assert isinstance(hook, AbstractHook), hook self.hooks.remove((triggers, hook)) + def subtreat(self, msg): return None - def add_event(self, evt, eid=None): - return self.events.append((evt, eid)) + + def add_event(self, evt): + return self.events.append(evt) def del_event(self, evt): - for i in self.events: - e, eid = i - if e == evt: - self.events.remove(i) - return True - return False + return self.events.remove(evt) + def send_response(self, server, res): self.module.logger.info("Send response: %s", res) @@ -114,6 +126,10 @@ class ModuleContext(_ModuleContext): def load_data(self): return self.context.datastore.load(self.module_name) + def save(self): + self.context.datastore.save(self.module_name, self.data) + + def add_hook(self, hook, *triggers): from nemubot.hooks import Abstract as AbstractHook assert isinstance(hook, AbstractHook), hook @@ -126,14 +142,29 @@ class ModuleContext(_ModuleContext): self.hooks.remove((triggers, hook)) return self.context.treater.hm.del_hooks(*triggers, hook=hook) + def subtreat(self, msg): yield from self.context.treater.treat_msg(msg) - def add_event(self, evt, eid=None): - return self.context.add_event(evt, eid, module_src=self.module) + + def add_event(self, evt): + if evt in self.events: + return None + + def _cancel_event(): + logger.debug("Cancel event") + evt.handle = None + return super().del_event(evt) + + hd = self.context.add_event(evt) + evt.handle = _FakeHandle(hd, _cancel_event) + + return super().add_event(evt) def del_event(self, evt): - return self.context.del_event(evt, module_src=self.module) + # Call to super().del_event is done in the _FakeHandle.cancel + return evt.handle.cancel() + def send_response(self, server, res): if server in self.context.servers: From 30c81c1c4b72e8b87109c21a99bae15d4e6ee01d Mon Sep 17 00:00:00 2001 From: nemunaire Date: Fri, 1 Sep 2017 20:39:40 +0200 Subject: [PATCH 54/57] Use new asyncio based events --- modules/bonneannee.py | 6 ++--- nemubot/bot.py | 26 ++++++++++++++++++++ nemubot/modulecontext.py | 51 +++++++++++++++++++++++++++++++++------- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/modules/bonneannee.py b/modules/bonneannee.py index ab9ac7f..ede30ef 100644 --- a/modules/bonneannee.py +++ b/modules/bonneannee.py @@ -37,10 +37,8 @@ def load(context): chan = sayon["channel"] context.send_response(srv, Response(txt, chan)) - d = datetime(yrn, 1, 1, 0, 0, 0, 0, - timezone.utc) - datetime.now(timezone.utc) - context.add_event(ModuleEvent(interval=0, offset=d.total_seconds(), - call=bonneannee)) + context.call_at(datetime(yrn, 1, 1, 0, 0, 0, 0, timezone.utc), + bonneannee) # MODULE INTERFACE #################################################### diff --git a/nemubot/bot.py b/nemubot/bot.py index 8038b5a..0638e95 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -64,6 +64,13 @@ class Bot(threading.Thread): # self.loop = loop if loop is not None else asyncio.get_event_loop() + # Those events are used to ensure there is always one event in the next 24h, else overflow can occurs on loop timeout + def event_sentinel(offset=43210): + logger.debug("Defining new event sentinelle in %ss", 43210 + offset) + self.loop.call_later(43210 + offset, event_sentinel) + event_sentinel(0) + event_sentinel(43210) + # External IP for accessing this bot import ipaddress self.ip = ipaddress.ip_address(ip) @@ -242,6 +249,25 @@ class Bot(threading.Thread): # Events methods + @asyncio.coroutine + def _call_at(self, when, *args, **kwargs): + @asyncio.coroutine + def _add_event(): + return self.loop.call_at(when, *args, **kwargs) + future = yield from asyncio.run_coroutine_threadsafe(_add_event(), loop=self.loop) + logger.debug("New event registered, scheduled in %ss", when - self.loop.time()) + return future.result() + + + def call_at(self, when, *args, **kwargs): + delay = (when - datetime.now(timezone.utc)).total_seconds() + return self._call_at(self.loop.time() + delay, *args, **kwargs) + + + def call_delay(self, delay, *args, **kwargs): + return self._call_at(self.loop.time() + delay, *args, **kwargs) + + def add_event(self, evt): """Register an event and return its identifiant for futur update diff --git a/nemubot/modulecontext.py b/nemubot/modulecontext.py index b3b793c..bf9b54e 100644 --- a/nemubot/modulecontext.py +++ b/nemubot/modulecontext.py @@ -16,6 +16,13 @@ import asyncio + +class _TinyEvent: + + def __init__(self, handle): + self.handle = handle + + class _FakeHandle: def __init__(self, true_handle, callback): @@ -27,9 +34,10 @@ class _FakeHandle: if self.callback: return self.callback() + class _ModuleContext: - def __init__(self, module=None): + def __init__(self, module=None, knodes=None): self.module = module if module is not None: @@ -43,6 +51,7 @@ class _ModuleContext: from nemubot.config.module import Module self.config = Module(self.module_name) + self._knodes = knodes def load_data(self): @@ -65,8 +74,13 @@ class _ModuleContext: return None + def set_knodes(self, knodes): + self._knodes = knodes + + def add_event(self, evt): - return self.events.append(evt) + self.events.append(evt) + return evt def del_event(self, evt): return self.events.remove(evt) @@ -88,6 +102,15 @@ class _ModuleContext: self._data = self.load_data() return self._data + @data.setter + def data(self, data): + self._data = data + return self._data + + @data.deleter + def data(self): + self._data = None + def unload(self): """Perform actions for unloading the module""" @@ -97,7 +120,7 @@ class _ModuleContext: self.del_hook(h, *s) # Remove registered events - for evt, eid, module_src in self.events: + for evt in self.events: self.del_event(evt) self.save() @@ -124,7 +147,7 @@ class ModuleContext(_ModuleContext): def load_data(self): - return self.context.datastore.load(self.module_name) + return self.context.datastore.load(self.module_name, self._knodes) def save(self): self.context.datastore.save(self.module_name, self.data) @@ -147,20 +170,32 @@ class ModuleContext(_ModuleContext): yield from self.context.treater.treat_msg(msg) - def add_event(self, evt): + def _add_event(self, evt, call_add, *args, **kwargs): if evt in self.events: return None def _cancel_event(): - logger.debug("Cancel event") + self.module.logger.debug("Cancel event") evt.handle = None - return super().del_event(evt) + return super(ModuleContext, self).del_event(evt) - hd = self.context.add_event(evt) + hd = call_add(*args, **kwargs) evt.handle = _FakeHandle(hd, _cancel_event) return super().add_event(evt) + + def add_event(self, evt): + return self._add_event(evt, self.context.add_event, evt) + + def call_at(self, *args, **kwargs): + evt = _TinyEvent(None) + return self._add_event(evt, self.context.call_at, *args, **kwargs) + + def call_later(self, *args, **kwargs): + evt = _TinyEvent(None) + return self._add_event(evt, self.context.call_later, *args, **kwargs) + def del_event(self, evt): # Call to super().del_event is done in the _FakeHandle.cancel return evt.handle.cancel() From 2a7502e8e856f6d0f3a0e02e7e23e8960be3f9c7 Mon Sep 17 00:00:00 2001 From: nemunaire Date: Fri, 1 Sep 2017 20:43:40 +0200 Subject: [PATCH 55/57] WIP Try to fix asyncio events add during asyncio event execution --- nemubot/bot.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/nemubot/bot.py b/nemubot/bot.py index 0638e95..e400e34 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -291,12 +291,7 @@ class Bot(threading.Thread): sync_act("launch_consumer") evt.start(self.loop) - - @asyncio.coroutine - def _add_event(): - return self.loop.call_at(evt._next, _end_event_timer, evt) - future = asyncio.run_coroutine_threadsafe(_add_event(), loop=self.loop) - evt.handle = future.result() + evt.handle = call_at(evt._next, _end_event_timer, evt) logger.debug("New event registered in %ss", evt._next - self.loop.time()) @@ -344,6 +339,17 @@ class Bot(threading.Thread): def add_module(self, module): """Add a module to the context, if already exists, unload the old one before""" + + import nemubot.hooks + + self.loop.call_soon_threadsafe(self._add_module, + module, + nemubot.hooks.hook.last_registered) + + nemubot.hooks.hook.last_registered = [] + + + def _add_module(self, module, registered_functions): module_name = module.__spec__.name if hasattr(module, "__spec__") else module.__name__ if hasattr(self, "stop") and self.stop: @@ -375,10 +381,8 @@ class Bot(threading.Thread): module.__dict__[attr] = module.__nemubot_context__ # Register decorated functions - import nemubot.hooks - for s, h in nemubot.hooks.hook.last_registered: + for s, h in registered_functions: module.__nemubot_context__.add_hook(h, *s if isinstance(s, list) else s) - nemubot.hooks.hook.last_registered = [] # Launch the module if hasattr(module, "load"): From ce4140ade83cd43c9008d8f7c89bf5635113d56a Mon Sep 17 00:00:00 2001 From: nemunaire Date: Fri, 1 Sep 2017 20:46:58 +0200 Subject: [PATCH 56/57] WIP Simplify ModuleEvent with functools package --- nemubot/event/__init__.py | 71 +++++---------------------------------- 1 file changed, 8 insertions(+), 63 deletions(-) diff --git a/nemubot/event/__init__.py b/nemubot/event/__init__.py index 52a721c..8ec036c 100644 --- a/nemubot/event/__init__.py +++ b/nemubot/event/__init__.py @@ -21,49 +21,29 @@ class ModuleEvent: """Representation of a event initiated by a bot module""" - def __init__(self, call=None, call_data=None, func=None, func_data=None, - cmp=None, cmp_data=None, interval=60, offset=0, times=1): + def __init__(self, call=None, cmp=None, interval=60, offset=0, times=1): """Initialize the event Keyword arguments: call -- Function to call when the event is realized - call_data -- Argument(s) (single or dict) to pass as argument - func -- Function called to check - func_data -- Argument(s) (single or dict) to pass as argument OR if no func, initial data to watch cmp -- Boolean function called to check changes - cmp_data -- Argument(s) (single or dict) to pass as argument OR if no cmp, data compared to previous interval -- Time in seconds between each check (default: 60) offset -- Time in seconds added to interval before the first check (default: 0) times -- Number of times the event has to be realized before being removed; -1 for no limit (default: 1) """ - # What have we to check? - self.func = func - self.func_data = func_data - # How detect a change? self.cmp = cmp - self.cmp_data = None - if cmp_data is not None: - self.cmp_data = cmp_data - elif self.func is not None: - if self.func_data is None: - self.cmp_data = self.func() - elif isinstance(self.func_data, dict): - self.cmp_data = self.func(**self.func_data) - else: - self.cmp_data = self.func(self.func_data) # What should we call when? self.call = call - if call_data is not None: - self.call_data = call_data - else: - self.call_data = func_data - # Store times - self.offset = timedelta(seconds=offset) # Time to wait before the first check + # Time to wait before the first check + if isinstance(offset, timedelta): + self.offset = offset + else: + self.offset = timedelta(seconds=offset) self.interval = timedelta(seconds=interval) self._next = None # Cache @@ -91,41 +71,6 @@ class ModuleEvent: def check(self): """Run a check and realized the event if this is time""" - # Get initial data - if self.func is None: - d_init = self.func_data - elif self.func_data is None: - d_init = self.func() - elif isinstance(self.func_data, dict): - d_init = self.func(**self.func_data) - else: - d_init = self.func(self.func_data) - - # then compare with current data - if self.cmp is None: - if self.cmp_data is None: - rlz = True - else: - rlz = (d_init != self.cmp_data) - elif self.cmp_data is None: - rlz = self.cmp(d_init) - elif isinstance(self.cmp_data, dict): - rlz = self.cmp(d_init, **self.cmp_data) - else: - rlz = self.cmp(d_init, self.cmp_data) - - if rlz: + if self.cmp(): self.times -= 1 - - # Call attended function - if self.call_data is None: - if d_init is None: - self.call() - else: - self.call(d_init) - elif isinstance(self.call_data, dict): - self.call(d_init, **self.call_data) - elif d_init is None: - self.call(self.call_data) - else: - self.call(d_init, self.call_data) + self.call() From 26f301d6b46facdccd32a1de51c7551cd1c1286b Mon Sep 17 00:00:00 2001 From: nemunaire Date: Fri, 1 Sep 2017 20:47:38 +0200 Subject: [PATCH 57/57] events: Use the new data parser, knodes based --- modules/events.py | 172 +++++++++++++++++++++++++++++----------------- 1 file changed, 110 insertions(+), 62 deletions(-) diff --git a/modules/events.py b/modules/events.py index ad493a4..0e3d4a1 100644 --- a/modules/events.py +++ b/modules/events.py @@ -1,7 +1,9 @@ """Create countdowns and reminders""" -import re +import calendar from datetime import datetime, timedelta, timezone +from functools import partial +import re from nemubot import context from nemubot.exception import IMException @@ -10,29 +12,84 @@ from nemubot.hooks import hook from nemubot.message import Command from nemubot.tools.countdown import countdown_format, countdown from nemubot.tools.date import extractDate -from nemubot.tools.xmlparser.node import ModuleState +from nemubot.tools.xmlparser.basic import DictNode from nemubot.module.more import Response +class Event: + + def __init__(self, server, channel, creator, start_time, end_time=None): + self._server = server + self._channel = channel + self._creator = creator + self._start = datetime.utcfromtimestamp(float(start_time)).replace(tzinfo=timezone.utc) if not isinstance(start_time, datetime) else start_time + self._end = datetime.utcfromtimestamp(float(end_time)).replace(tzinfo=timezone.utc) if end_time else None + self._evt = None + + + def __del__(self): + if self._evt is not None: + context.del_event(self._evt) + self._evt = None + + + def saveElement(self, store, tag="event"): + attrs = { + "server": str(self._server), + "channel": str(self._channel), + "creator": str(self._creator), + "start_time": str(calendar.timegm(self._start.timetuple())), + } + if self._end: + attrs["end_time"] = str(calendar.timegm(self._end.timetuple())) + store.startElement(tag, attrs) + store.endElement(tag) + + @property + def creator(self): + return self._creator + + @property + def start(self): + return self._start + + @property + def end(self): + return self._end + + @end.setter + def end(self, c): + self._end = c + + @end.deleter + def end(self): + self._end = None + + def help_full (): - return "This module store a lot of events: ny, we, " + (", ".join(context.datas.index.keys() if hasattr(context, "datas") else [])) + "\n!eventslist: gets list of timer\n!start /something/: launch a timer" + return "This module store a lot of events: ny, we, " + (", ".join(context.datas.keys()) if hasattr(context, "datas") else "") + "\n!eventslist: gets list of timer\n!start /something/: launch a timer" def load(context): - #Define the index - context.data.setIndex("name") + context.set_knodes({ + "dict": DictNode, + "event": Event, + }) - for evt in context.data.index.keys(): - if context.data.index[evt].hasAttribute("end"): - event = ModuleEvent(call=fini, call_data=dict(strend=context.data.index[evt])) - event.schedule(context.data.index[evt].getDate("end")) - context.add_event(event) + if context.data is None: + context.data = DictNode() + + # Relaunch all timers + for kevt in context.data: + if context.data[kevt].end: + context.data[kevt]._evt = context.call_at(context.data[kevt].end, partial(fini, kevt, context.data[kevt])) -def fini(d, strend): - context.send_response(strend["server"], Response("%s arrivé à échéance." % strend["name"], channel=strend["channel"], nick=strend["proprio"])) - context.data.delChild(context.data.index[strend["name"]]) +def fini(name, evt): + context.send_response(evt._server, Response("%s arrivé à échéance." % name, channel=evt._channel, nick=evt.creator)) + evt._evt = None + del context.data[name] context.save() @@ -61,18 +118,10 @@ def start_countdown(msg): """!start /something/: launch a timer""" if len(msg.args) < 1: raise IMException("indique le nom d'un événement à chronométrer") - if msg.args[0] in context.data.index: + if msg.args[0] in context.data: raise IMException("%s existe déjà." % msg.args[0]) - strnd = ModuleState("strend") - strnd["server"] = msg.server - strnd["channel"] = msg.channel - strnd["proprio"] = msg.frm - strnd["start"] = msg.date - strnd["name"] = msg.args[0] - context.data.addChild(strnd) - - evt = ModuleEvent(call=fini, call_data=dict(strend=strnd)) + evt = Event(server=msg.server, channel=msg.channel, creator=msg.frm, start_time=msg.date) if len(msg.args) > 1: result1 = re.findall("([0-9]+)([smhdjwyaSMHDJWYA])?", msg.args[1]) @@ -90,48 +139,48 @@ def start_countdown(msg): if result2 is None or result2.group(4) is None: yea = now.year else: yea = int(result2.group(4)) if result2 is not None and result3 is not None: - strnd["end"] = datetime(yea, int(result2.group(3)), int(result2.group(2)), hou, minu, sec, timezone.utc) + evt.end = datetime(yea, int(result2.group(3)), int(result2.group(2)), hou, minu, sec, timezone.utc) elif result2 is not None: - strnd["end"] = datetime(int(result2.group(4)), int(result2.group(3)), int(result2.group(2)), 0, 0, 0, timezone.utc) + evt.end = datetime(int(result2.group(4)), int(result2.group(3)), int(result2.group(2)), 0, 0, 0, timezone.utc) elif result3 is not None: if hou * 3600 + minu * 60 + sec > now.hour * 3600 + now.minute * 60 + now.second: - strnd["end"] = datetime(now.year, now.month, now.day, hou, minu, sec, timezone.utc) + evt.end = datetime(now.year, now.month, now.day, hou, minu, sec, timezone.utc) else: - strnd["end"] = datetime(now.year, now.month, now.day + 1, hou, minu, sec, timezone.utc) - evt.schedule(strnd.getDate("end")) - context.add_event(evt) + evt.end = datetime(now.year, now.month, now.day + 1, hou, minu, sec, timezone.utc) except: - context.data.delChild(strnd) raise IMException("Mauvais format de date pour l'événement %s. Il n'a pas été créé." % msg.args[0]) elif result1 is not None and len(result1) > 0: - strnd["end"] = msg.date + evt.end = msg.date for (t, g) in result1: if g is None or g == "" or g == "m" or g == "M": - strnd["end"] += timedelta(minutes=int(t)) + evt.end += timedelta(minutes=int(t)) elif g == "h" or g == "H": - strnd["end"] += timedelta(hours=int(t)) + evt.end += timedelta(hours=int(t)) elif g == "d" or g == "D" or g == "j" or g == "J": - strnd["end"] += timedelta(days=int(t)) + evt.end += timedelta(days=int(t)) elif g == "w" or g == "W": - strnd["end"] += timedelta(days=int(t)*7) + evt.end += timedelta(days=int(t)*7) elif g == "y" or g == "Y" or g == "a" or g == "A": - strnd["end"] += timedelta(days=int(t)*365) + evt.end += timedelta(days=int(t)*365) else: - strnd["end"] += timedelta(seconds=int(t)) - evt.schedule(strnd.getDate("end")) - context.add_event(evt) + evt.end += timedelta(seconds=int(t)) + context.data[msg.args[0]] = evt context.save() - if "end" in strnd: + + if evt.end is not None: + context.add_event(ModuleEvent(partial(fini, msg.args[0], evt), + offset=evt.end - datetime.now(timezone.utc), + interval=0)) return Response("%s commencé le %s et se terminera le %s." % (msg.args[0], msg.date.strftime("%A %d %B %Y à %H:%M:%S"), - strnd.getDate("end").strftime("%A %d %B %Y à %H:%M:%S")), - nick=msg.frm) + evt.end.strftime("%A %d %B %Y à %H:%M:%S")), + channel=msg.channel) else: return Response("%s commencé le %s"% (msg.args[0], msg.date.strftime("%A %d %B %Y à %H:%M:%S")), - nick=msg.frm) + channel=msg.channel) @hook.command("end") @@ -140,16 +189,15 @@ def end_countdown(msg): if len(msg.args) < 1: raise IMException("quel événement terminer ?") - if msg.args[0] in context.data.index: - if context.data.index[msg.args[0]]["proprio"] == msg.frm or (msg.cmd == "forceend" and msg.frm_owner): - duration = countdown(msg.date - context.data.index[msg.args[0]].getDate("start")) - context.del_event(context.data.index[msg.args[0]]) - context.data.delChild(context.data.index[msg.args[0]]) + if msg.args[0] in context.data: + if context.data[msg.args[0]].creator == msg.frm or (msg.cmd == "forceend" and msg.frm_owner): + duration = countdown(msg.date - context.data[msg.args[0]].start) + del context.data[msg.args[0]] context.save() return Response("%s a duré %s." % (msg.args[0], duration), channel=msg.channel, nick=msg.frm) else: - raise IMException("Vous ne pouvez pas terminer le compteur %s, créé par %s." % (msg.args[0], context.data.index[msg.args[0]]["proprio"])) + raise IMException("Vous ne pouvez pas terminer le compteur %s, créé par %s." % (msg.args[0], context.data[msg.args[0]].creator)) else: return Response("%s n'est pas un compteur connu."% (msg.args[0]), channel=msg.channel, nick=msg.frm) @@ -158,19 +206,19 @@ def end_countdown(msg): def liste(msg): """!eventslist: gets list of timer""" if len(msg.args): - res = list() + res = Response(channel=msg.channel) for user in msg.args: - cmptr = [x["name"] for x in context.data.index.values() if x["proprio"] == user] + cmptr = [k for k in context.data if context.data[k].creator == user] if len(cmptr) > 0: - res.append("Compteurs créés par %s : %s" % (user, ", ".join(cmptr))) + res.append_message(cmptr, title="Events created by %s" % user) else: - res.append("%s n'a pas créé de compteur" % user) - return Response(" ; ".join(res), channel=msg.channel) + res.append_message("%s doesn't have any counting events" % user) + return res else: - return Response("Compteurs connus : %s." % ", ".join(context.data.index.keys()), channel=msg.channel) + return Response(list(context.data.keys()), channel=msg.channel, title="Known events") -@hook.command(match=lambda msg: isinstance(msg, Command) and msg.cmd in context.data.index) +@hook.command(match=lambda msg: isinstance(msg, Command) and msg.cmd in context.data) def parseanswer(msg): res = Response(channel=msg.channel) @@ -178,13 +226,13 @@ def parseanswer(msg): if msg.cmd[0] == "!": res.nick = msg.frm - if context.data.index[msg.cmd].name == "strend": - if context.data.index[msg.cmd].hasAttribute("end"): - res.append_message("%s commencé il y a %s et se terminera dans %s." % (msg.cmd, countdown(msg.date - context.data.index[msg.cmd].getDate("start")), countdown(context.data.index[msg.cmd].getDate("end") - msg.date))) + if msg.cmd in context.data: + if context.data[msg.cmd].end: + res.append_message("%s commencé il y a %s et se terminera dans %s." % (msg.cmd, countdown(msg.date - context.data[msg.cmd].start), countdown(context.data[msg.cmd].end - msg.date))) else: - res.append_message("%s commencé il y a %s." % (msg.cmd, countdown(msg.date - context.data.index[msg.cmd].getDate("start")))) + res.append_message("%s commencé il y a %s." % (msg.cmd, countdown(msg.date - context.data[msg.cmd].start))) else: - res.append_message(countdown_format(context.data.index[msg.cmd].getDate("start"), context.data.index[msg.cmd]["msg_before"], context.data.index[msg.cmd]["msg_after"])) + res.append_message(countdown_format(context.data[msg.cmd].start, context.data[msg.cmd]["msg_before"], context.data[msg.cmd]["msg_after"])) return res @@ -195,7 +243,7 @@ def parseask(msg): name = re.match("^.*!([^ \"'@!]+).*$", msg.message) if name is None: raise IMException("il faut que tu attribues une commande à l'événement.") - if name.group(1) in context.data.index: + if name.group(1) in context.data: raise IMException("un événement portant ce nom existe déjà.") texts = re.match("^[^\"]*(avant|après|apres|before|after)?[^\"]*\"([^\"]+)\"[^\"]*((avant|après|apres|before|after)?.*\"([^\"]+)\".*)?$", msg.message, re.I)