diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index dccc156..0000000 --- a/.drone.yml +++ /dev/null @@ -1,26 +0,0 @@ ---- -kind: pipeline -type: docker -name: default-arm64 - -platform: - os: linux - arch: arm64 - -steps: - - name: build - image: python:3.11-alpine - commands: - - pip install --no-cache-dir -r requirements.txt - - pip install . - - - name: docker - image: plugins/docker - settings: - repo: nemunaire/nemubot - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - username: - from_secret: docker_username - password: - from_secret: docker_password diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..23cf4a0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "modules/nextstop/external"] + path = modules/nextstop/external + url = git://github.com/nbr23/NextStop.git diff --git a/.travis.yml b/.travis.yml index 8efd20f..d109d2a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,8 @@ language: python python: + - 3.3 - 3.4 - 3.5 - - 3.6 - - 3.7 - nightly install: - pip install -r requirements.txt diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index b830622..0000000 --- a/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM python:3.11-alpine - -WORKDIR /usr/src/app - -COPY requirements.txt /usr/src/app/ -RUN apk add --no-cache bash build-base capstone-dev mandoc-doc man-db w3m youtube-dl aspell aspell-fr py3-matrix-nio && \ - pip install --no-cache-dir --ignore-installed -r requirements.txt && \ - pip install bs4 capstone dnspython openai && \ - apk del build-base capstone-dev && \ - ln -s /var/lib/nemubot/home /home/nemubot - -VOLUME /var/lib/nemubot - -COPY . /usr/src/app/ - -RUN ./setup.py install - -WORKDIR /var/lib/nemubot -USER guest -ENTRYPOINT [ "python", "-m", "nemubot", "-d", "-P", "", "-M", "/usr/src/app/modules" ] -CMD [ "-D", "/var/lib/nemubot" ] \ No newline at end of file diff --git a/README.md b/README.md index 6977c9f..e93cbaf 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,17 @@ -nemubot -======= +# *nemubot* An extremely modulable IRC bot, built around XML configuration files! -Requirements ------------- +## Requirements *nemubot* requires at least Python 3.3 to work. Some modules (like `cve`, `nextstop` or `laposte`) require the -[BeautifulSoup module](https://www.crummy.com/software/BeautifulSoup/), +[BeautifulSoup module](http://www.crummy.com/software/BeautifulSoup/), but the core and framework has no dependency. -Installation ------------- +## Documentation -Use the `setup.py` file: `python setup.py install`. - -### VirtualEnv setup - -The easiest way to do this is through a virtualenv: - -```sh -virtualenv venv -. venv/bin/activate -python setup.py install -``` - -### Create a new configuration file - -There is a sample configuration file, called `bot_sample.xml`. You can -create your own configuration file from it. - - -Usage ------ - -Don't forget to activate your virtualenv in further terminals, if you -use it. - -To launch the bot, run: - -```sh -nemubot bot.xml -``` - -Where `bot.xml` is your configuration file. +Have a look to the wiki at https://github.com/nemunaire/nemubot/wiki diff --git a/bin/nemubot b/bin/nemubot index c248802..97746f1 100755 --- a/bin/nemubot +++ b/bin/nemubot @@ -1,7 +1,8 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python3.3 +# -*- coding: utf-8 -*- # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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 diff --git a/bot_sample.xml b/bot_sample.xml index ed1a41f..ce821d2 100644 --- a/bot_sample.xml +++ b/bot_sample.xml @@ -1,11 +1,11 @@ - + diff --git a/modules/alias.py b/modules/alias.py index c432a85..8d67000 100644 --- a/modules/alias.py +++ b/modules/alias.py @@ -3,16 +3,17 @@ # PYTHON STUFFS ####################################################### import re +import sys from datetime import datetime, timezone +import shlex from nemubot import context -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.message import Command -from nemubot.tools.human import guess from nemubot.tools.xmlparser.node import ModuleState -from nemubot.module.more import Response +from more import Response # LOADING ############################################################# @@ -76,7 +77,7 @@ def get_variable(name, msg=None): elif name in context.data.getNode("variables").index: return context.data.getNode("variables").index[name]["value"] else: - return None + return "" def list_variables(user=None): @@ -108,12 +109,12 @@ def set_variable(name, value, creator): context.save() -def replace_variables(cnts, msg): +def replace_variables(cnts, msg=None): """Replace variables contained in the content Arguments: cnt -- content where search variables - msg -- Message where pick some variables + msg -- optional message where pick some variables """ unsetCnt = list() @@ -122,12 +123,12 @@ def replace_variables(cnts, msg): resultCnt = list() for cnt in cnts: - for res, name, default in re.findall("\\$\{(([a-zA-Z0-9:]+)(?:-([^}]+))?)\}", cnt): - rv = re.match("([0-9]+)(:([0-9]*))?", name) + for res in re.findall("\\$\{(?P[a-zA-Z0-9:]+)\}", cnt): + rv = re.match("([0-9]+)(:([0-9]*))?", res) if rv is not None: varI = int(rv.group(1)) - 1 - if varI >= len(msg.args): - cnt = cnt.replace("${%s}" % res, default, 1) + if varI > len(msg.args): + cnt = cnt.replace("${%s}" % res, "", 1) elif rv.group(2) is not None: if rv.group(3) is not None and len(rv.group(3)): varJ = int(rv.group(3)) - 1 @@ -142,12 +143,11 @@ def replace_variables(cnts, msg): cnt = cnt.replace("${%s}" % res, msg.args[varI], 1) unsetCnt.append(varI) else: - cnt = cnt.replace("${%s}" % res, get_variable(name) or default, 1) + cnt = cnt.replace("${%s}" % res, get_variable(res), 1) resultCnt.append(cnt) - # Remove used content for u in sorted(set(unsetCnt), reverse=True): - msg.args.pop(u) + k = msg.args.pop(u) return resultCnt @@ -156,7 +156,7 @@ def replace_variables(cnts, msg): ## Variables management -@hook.command("listvars", +@hook("cmd_hook", "listvars", help="list defined variables for substitution in input commands", help_usage={ None: "List all known variables", @@ -179,20 +179,20 @@ def cmd_listvars(msg): return Response("There is currently no variable stored.", channel=msg.channel) -@hook.command("set", +@hook("cmd_hook", "set", help="Create or set variables for substitution in input commands", help_usage={"KEY VALUE": "Define the variable named KEY and fill it with VALUE as content"}) 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.frm) + raise IRCException("!set take two args: the key and the value.") + set_variable(msg.args[0], " ".join(msg.args[1:]), msg.nick) return Response("Variable $%s successfully defined." % msg.args[0], channel=msg.channel) ## Alias management -@hook.command("listalias", +@hook("cmd_hook", "listalias", help="List registered aliases", help_usage={ None: "List all registered aliases", @@ -206,42 +206,27 @@ def cmd_listalias(msg): return Response("There is no alias currently.", channel=msg.channel) -@hook.command("alias", - help="Display or define the replacement command for a given alias", - help_usage={ - "ALIAS": "Extends the given alias", - "ALIAS COMMAND [ARGS ...]": "Create a new alias named ALIAS as replacement to the given COMMAND and ARGS", - }) +@hook("cmd_hook", "alias", + help="Display the replacement command for a given alias") def cmd_alias(msg): if not len(msg.args): - raise IMException("!alias takes as argument an alias to extend.") - - alias = context.subparse(msg, msg.args[0]) - if alias is None or not isinstance(alias, Command): - raise IMException("%s is not a valid alias" % msg.args[0]) - - 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.frm) - - elif len(msg.args) > 1: - create_alias(alias.cmd, - " ".join(msg.args[1:]), - channel=msg.channel, - creator=msg.frm) - return Response("New alias %s successfully registered." % alias.cmd, - channel=msg.channel) - - else: - wym = [m for m in guess(alias.cmd, context.data.getNode("aliases").index)] - raise IMException(msg.args[0] + " is not an alias." + (" Would you mean: %s?" % ", ".join(wym) if len(wym) else "")) + raise IRCException("!alias takes as argument an alias to extend.") + res = list() + for alias in msg.args: + if alias[0] == "!": + alias = alias[1:] + if alias in context.data.getNode("aliases").index: + res.append("!%s correspond to %s" % (alias, context.data.getNode("aliases").index[alias]["origin"])) + else: + res.append("!%s is not an alias" % alias) + return Response(res, channel=msg.channel, nick=msg.nick) -@hook.command("unalias", +@hook("cmd_hook", "unalias", help="Remove a previously created alias") def cmd_unalias(msg): if not len(msg.args): - raise IMException("Which alias would you want to remove?") + raise IRCException("Which alias would you want to remove?") res = list() for alias in msg.args: if alias[0] == "!" and len(alias) > 1: @@ -258,20 +243,39 @@ def cmd_unalias(msg): ## Alias replacement -@hook.add(["pre","Command"]) +@hook("pre_Command") def treat_alias(msg): - if context.data.getNode("aliases") is not None and msg.cmd in context.data.getNode("aliases").index: - origin = context.data.getNode("aliases").index[msg.cmd]["origin"] - rpl_msg = context.subparse(msg, origin) - if isinstance(rpl_msg, Command): - rpl_msg.args = replace_variables(rpl_msg.args, msg) - rpl_msg.args += msg.args - rpl_msg.kwargs.update(msg.kwargs) - elif len(msg.args) or len(msg.kwargs): - raise IMException("This kind of alias doesn't take any argument (haven't you forgotten the '!'?).") + if msg.cmd in context.data.getNode("aliases").index: + txt = context.data.getNode("aliases").index[msg.cmd]["origin"] + # TODO: for legacy compatibility + if txt[0] == "!": + txt = txt[1:] + try: + args = shlex.split(txt) + except ValueError: + args = txt.split(' ') + nmsg = Command(args[0], replace_variables(args[1:], msg) + msg.args, **msg.export_args()) # Avoid infinite recursion - if not isinstance(rpl_msg, Command) or msg.cmd != rpl_msg.cmd: - return rpl_msg + if msg.cmd != nmsg.cmd: + # Also return origin message, if it can be treated as well + return [msg, nmsg] return msg + + +@hook("ask_default") +def parseask(msg): + if re.match(".*(register|set|cr[ée]{2}|new|nouvel(le)?) alias.*", msg.text) is not None: + result = re.match(".*alias !?([^ ]+) ?(pour|for|=|:) ?(.+)$", msg.text) + if result.group(1) in context.data.getNode("aliases").index: + raise IRCException("this alias is already defined.") + else: + create_alias(result.group(1), + result.group(3), + channel=msg.channel, + creator=msg.nick) + res = Response("New alias %s successfully registered." % + result.group(1), channel=msg.channel) + return res + return None diff --git a/modules/birthday.py b/modules/birthday.py index e1406d4..34d2c28 100644 --- a/modules/birthday.py +++ b/modules/birthday.py @@ -7,13 +7,13 @@ import sys from datetime import date, datetime from nemubot import context -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools.countdown import countdown_format from nemubot.tools.date import extractDate from nemubot.tools.xmlparser.node import ModuleState -from nemubot.module.more import Response +from more import Response # LOADING ############################################################# @@ -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.frm.lower() + name = msg.nick.lower() else: name = msg.args[0].lower() @@ -46,7 +46,7 @@ def findName(msg): ## Commands -@hook.command("anniv", +@hook("cmd_hook", "anniv", help="gives the remaining time before the anniversary of known people", help_usage={ None: "Calculate the time remaining before your birthday", @@ -77,10 +77,10 @@ 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.frm) + msg.channel, msg.nick) -@hook.command("age", +@hook("cmd_hook", "age", help="Calculate age of known people", help_usage={ None: "Calculate your age", @@ -98,26 +98,26 @@ 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.frm) + " Quand est-il né ?" % name, msg.channel, msg.nick) return True ## Input parsing -@hook.ask() +@hook("ask_default") 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.message, re.I) + 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) if res is not None: try: - extDate = extractDate(msg.message) + extDate = extractDate(msg.text) if extDate is None or extDate.year > datetime.now().year: return Response("la date de naissance ne paraît pas valide...", msg.channel, - msg.frm) + msg.nick) 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.frm + nick = msg.nick 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.frm) + msg.nick) except: - raise IMException("la date de naissance ne paraît pas valide.") + raise IRCException("la date de naissance ne paraît pas valide.") diff --git a/modules/bonneannee.py b/modules/bonneannee.py index 1829bce..18ba637 100644 --- a/modules/bonneannee.py +++ b/modules/bonneannee.py @@ -4,11 +4,12 @@ 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 nemubot.module.more import Response +from more import Response # GLOBALS ############################################################# @@ -46,9 +47,9 @@ def load(context): # MODULE INTERFACE #################################################### -@hook.command("newyear", +@hook("cmd_hook", "newyear", help="Display the remaining time before the next new year") -@hook.command(str(yrn), +@hook("cmd_hook", str(yrn), help="Display the remaining time before %d" % yrn) def cmd_newyear(msg): return Response(countdown_format(datetime(yrn, 1, 1, 0, 0, 1, 0, @@ -58,7 +59,7 @@ def cmd_newyear(msg): channel=msg.channel) -@hook.command(data=yrn, regexp="^[0-9]{4}$", +@hook("cmd_rgxp", data=yrn, regexp="^[0-9]{4}$", help="Calculate time remaining/passed before/since the requested year") def cmd_timetoyear(msg, cur): yr = int(msg.cmd) diff --git a/modules/books.py b/modules/books.py index 5ab404b..260267e 100644 --- a/modules/books.py +++ b/modules/books.py @@ -5,17 +5,17 @@ import urllib from nemubot import context -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web -from nemubot.module.more import Response +from more import Response # LOADING ############################################################# def load(context): - if not context.config or "goodreadskey" not in context.config: + if not context.config or not context.config.getAttribute("goodreadskey"): raise ImportError("You need a Goodreads API key in order to use this " "module. Add it to the module configuration file:\n" "\n" @@ -28,8 +28,8 @@ def get_book(title): """Retrieve a book from its title""" response = web.getXML("https://www.goodreads.com/book/title.xml?key=%s&title=%s" % (context.config["goodreadskey"], urllib.parse.quote(title))) - if response is not None and len(response.getElementsByTagName("book")): - return response.getElementsByTagName("book")[0] + if response is not None and response.hasNode("book"): + return response.getNode("book") else: return None @@ -38,8 +38,8 @@ def search_books(title): """Get a list of book matching given title""" response = web.getXML("https://www.goodreads.com/search.xml?key=%s&q=%s" % (context.config["goodreadskey"], urllib.parse.quote(title))) - if response is not None and len(response.getElementsByTagName("search")): - return response.getElementsByTagName("search")[0].getElementsByTagName("results")[0].getElementsByTagName("work") + if response is not None and response.hasNode("search"): + return response.getNode("search").getNode("results").getNodes("work") else: return [] @@ -48,43 +48,43 @@ def search_author(name): """Looking for an author""" response = web.getXML("https://www.goodreads.com/api/author_url/%s?key=%s" % (urllib.parse.quote(name), context.config["goodreadskey"])) - if response is not None and len(response.getElementsByTagName("author")) and response.getElementsByTagName("author")[0].hasAttribute("id"): + if response is not None and response.hasNode("author") and response.getNode("author").hasAttribute("id"): response = web.getXML("https://www.goodreads.com/author/show/%s.xml?key=%s" % - (urllib.parse.quote(response.getElementsByTagName("author")[0].getAttribute("id")), context.config["goodreadskey"])) - if response is not None and len(response.getElementsByTagName("author")): - return response.getElementsByTagName("author")[0] + (urllib.parse.quote(response.getNode("author")["id"]), context.config["goodreadskey"])) + if response is not None and response.hasNode("author"): + return response.getNode("author") return None # MODULE INTERFACE #################################################### -@hook.command("book", +@hook("cmd_hook", "book", help="Get information about a book from its title", help_usage={ "TITLE": "Get information about a book titled TITLE" }) def cmd_book(msg): if not len(msg.args): - raise IMException("please give me a title to search") + raise IRCException("please give me a title to search") book = get_book(" ".join(msg.args)) if book is None: - raise IMException("unable to find book named like this") + raise IRCException("unable to find book named like this") res = Response(channel=msg.channel) - res.append_message("%s, written by %s: %s" % (book.getElementsByTagName("title")[0].firstChild.nodeValue, - book.getElementsByTagName("author")[0].getElementsByTagName("name")[0].firstChild.nodeValue, - web.striphtml(book.getElementsByTagName("description")[0].firstChild.nodeValue if book.getElementsByTagName("description")[0].firstChild else ""))) + res.append_message("%s, writed by %s: %s" % (book.getNode("title").getContent(), + book.getNode("authors").getNode("author").getNode("name").getContent(), + web.striphtml(book.getNode("description").getContent()))) return res -@hook.command("search_books", +@hook("cmd_hook", "search_books", help="Search book's title", help_usage={ "APPROX_TITLE": "Search for a book approximately titled APPROX_TITLE" }) def cmd_books(msg): if not len(msg.args): - raise IMException("please give me a title to search") + raise IRCException("please give me a title to search") title = " ".join(msg.args) res = Response(channel=msg.channel, @@ -92,24 +92,21 @@ def cmd_books(msg): count=" (%d more books)") for book in search_books(title): - res.append_message("%s, writed by %s" % (book.getElementsByTagName("best_book")[0].getElementsByTagName("title")[0].firstChild.nodeValue, - book.getElementsByTagName("best_book")[0].getElementsByTagName("author")[0].getElementsByTagName("name")[0].firstChild.nodeValue)) + res.append_message("%s, writed by %s" % (book.getNode("best_book").getNode("title").getContent(), + book.getNode("best_book").getNode("author").getNode("name").getContent())) return res -@hook.command("author_books", +@hook("cmd_hook", "author_books", help="Looking for books writen by a given author", help_usage={ "AUTHOR": "Looking for books writen by AUTHOR" }) def cmd_author(msg): if not len(msg.args): - raise IMException("please give me an author to search") + raise IRCException("please give me an author to search") - name = " ".join(msg.args) - ath = search_author(name) - if ath is None: - raise IMException("%s does not appear to be a published author." % name) - return Response([b.getElementsByTagName("title")[0].firstChild.nodeValue for b in ath.getElementsByTagName("book")], + ath = search_author(" ".join(msg.args)) + return Response([b.getNode("title").getContent() for b in ath.getNode("books").getNodes("book")], channel=msg.channel, - title=ath.getElementsByTagName("name")[0].firstChild.nodeValue) + title=ath.getNode("name").getContent()) diff --git a/modules/cat.py b/modules/cat.py deleted file mode 100644 index 5eb3e19..0000000 --- a/modules/cat.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Concatenate commands""" - -# PYTHON STUFFS ####################################################### - -from nemubot import context -from nemubot.exception import IMException -from nemubot.hooks import hook -from nemubot.message import Command, DirectAsk, Text - -from nemubot.module.more import Response - - -# MODULE CORE ######################################################### - -def cat(msg, *terms): - res = Response(channel=msg.to_response, server=msg.server) - for term in terms: - m = context.subparse(msg, term) - if isinstance(m, Command) or isinstance(m, DirectAsk): - for r in context.subtreat(m): - if isinstance(r, Response): - for t in range(len(r.messages)): - res.append_message(r.messages[t], - title=r.rawtitle if not isinstance(r.rawtitle, list) else r.rawtitle[t]) - - elif isinstance(r, Text): - res.append_message(r.message) - - elif isinstance(r, str): - res.append_message(r) - - else: - res.append_message(term) - - return res - - -# MODULE INTERFACE #################################################### - -@hook.command("cat", - help="Concatenate responses of commands given as argument", - help_usage={"!SUBCMD [!SUBCMD [...]]": "Concatenate response of subcommands"}, - keywords={ - "merge": "Merge messages into the same", - }) -def cmd_cat(msg): - if len(msg.args) < 1: - raise IMException("No subcommand to concatenate") - - r = cat(msg, *msg.args) - - if "merge" in msg.kwargs and len(r.messages) > 1: - r.messages = [ r.messages ] - - return r diff --git a/modules/conjugaison.py b/modules/conjugaison.py index c953da3..fdde315 100644 --- a/modules/conjugaison.py +++ b/modules/conjugaison.py @@ -6,12 +6,12 @@ from collections import defaultdict import re from urllib.parse import quote -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web from nemubot.tools.web import striphtml -from nemubot.module.more import Response +from more import Response # GLOBALS ############################################################# @@ -36,7 +36,7 @@ for k, v in s: # MODULE CORE ######################################################### def get_conjug(verb, stringTens): - url = ("https://leconjugueur.lefigaro.fr/conjugaison/verbe/%s.html" % + url = ("http://leconjugueur.lefigaro.fr/conjugaison/verbe/%s.html" % quote(verb.encode("ISO-8859-1"))) page = web.getURLContent(url) @@ -51,10 +51,10 @@ def compute_line(line, stringTens): try: idTemps = d[stringTens] except: - raise IMException("le temps demandé n'existe pas") + raise IRCException("le temps demandé n'existe pas") if len(idTemps) == 0: - raise IMException("le temps demandé n'existe pas") + raise IRCException("le temps demandé n'existe pas") index = line.index('
from " + self.ddgres["DefinitionSource"] - - - @property - def relatedTopics(self): - if "RelatedTopics" in self.ddgres: - for rt in self.ddgres["RelatedTopics"]: - if "Text" in rt: - yield rt["Text"] + " <" + rt["FirstURL"] + ">" - elif "Topics" in rt: - yield rt["Name"] + ": " + "; ".join([srt["Text"] + " <" + srt["FirstURL"] + ">" for srt in rt["Topics"]]) - - - @property - def redirect(self): - if "Redirect" not in self.ddgres or not self.ddgres["Redirect"]: - return None - return self.ddgres["Redirect"] - - - @property - def entity(self): - if "Entity" not in self.ddgres or not self.ddgres["Entity"]: - return None - return self.ddgres["Entity"] - - - @property - def heading(self): - if "Heading" not in self.ddgres or not self.ddgres["Heading"]: - return " ".join(self.terms) - return self.ddgres["Heading"] - - - @property - def result(self): - if "Results" in self.ddgres: - for res in self.ddgres["Results"]: - yield res["Text"] + " <" + res["FirstURL"] + ">" - - - @property - def answer(self): - if "Answer" not in self.ddgres or not self.ddgres["Answer"]: - return None - return web.striphtml(self.ddgres["Answer"]) - - - @property - def abstract(self): - if "Abstract" not in self.ddgres or not self.ddgres["Abstract"]: - return None - return self.ddgres["AbstractText"] + " <" + self.ddgres["AbstractURL"] + "> from " + self.ddgres["AbstractSource"] - - -# MODULE INTERFACE #################################################### - -@hook.command("define") -def define(msg): - if not len(msg.args): - raise IMException("Indicate a term to define") - - s = do_search(msg.args) - - if not s.definition: - raise IMException("no definition found for '%s'." % " ".join(msg.args)) - - return Response(s.definition, channel=msg.channel) - -@hook.command("search") -def search(msg): - if not len(msg.args): - raise IMException("Indicate a term to search") - - s = do_search(msg.args) - - res = Response(channel=msg.channel, nomore="No more results", - count=" (%d more results)") - - res.append_message(s.redirect) - res.append_message(s.answer) - res.append_message(s.abstract) - res.append_message([r for r in s.result]) - - for rt in s.relatedTopics: - res.append_message(rt) - - res.append_message(s.definition) - - return res diff --git a/modules/ddg/DDGSearch.py b/modules/ddg/DDGSearch.py new file mode 100644 index 0000000..174e4a5 --- /dev/null +++ b/modules/ddg/DDGSearch.py @@ -0,0 +1,71 @@ +# coding=utf-8 + +from urllib.parse import quote + +from nemubot.tools import web +from nemubot.tools.xmlparser import parse_string + + +class DDGSearch: + + def __init__(self, terms, safeoff=False): + self.terms = terms + + self.ddgres = web.getXML( + "https://api.duckduckgo.com/?q=%s&format=xml&no_redirect=1%s" % + (quote(terms), "&kp=-1" if safeoff else ""), + timeout=10) + + @property + def type(self): + if self.ddgres and self.ddgres.hasNode("Type"): + return self.ddgres.getFirstNode("Type").getContent() + else: + return "" + + @property + def definition(self): + if self.ddgres.hasNode("Definition"): + return self.ddgres.getFirstNode("Definition").getContent() + else: + return "Sorry, no definition found for %s" % self.terms + + @property + def relatedTopics(self): + try: + for rt in self.ddgres.getFirstNode("RelatedTopics").getNodes("RelatedTopic"): + yield rt.getFirstNode("Text").getContent() + except: + pass + + @property + def redirect(self): + try: + return self.ddgres.getFirstNode("Redirect").getContent() + except: + return None + + @property + def result(self): + try: + node = self.ddgres.getFirstNode("Results").getFirstNode("Result") + return node.getFirstNode("Text").getContent() + ": " + node.getFirstNode("FirstURL").getContent() + except: + return None + + @property + def answer(self): + try: + return web.striphtml(self.ddgres.getFirstNode("Answer").getContent()) + except: + return None + + @property + def abstract(self): + try: + if self.ddgres.getNode("Abstract").getContent() != "": + return self.ddgres.getNode("Abstract").getContent() + " <" + self.ddgres.getNode("AbstractURL").getContent() + ">" + else: + return None + except: + return None diff --git a/modules/ddg/UrbanDictionnary.py b/modules/ddg/UrbanDictionnary.py new file mode 100644 index 0000000..25faf39 --- /dev/null +++ b/modules/ddg/UrbanDictionnary.py @@ -0,0 +1,30 @@ +# coding=utf-8 + +from urllib.parse import quote + +from nemubot.tools import web + + +class UrbanDictionnary: + + def __init__(self, terms): + self.terms = terms + + self.udres = web.getJSON( + "http://api.urbandictionary.com/v0/define?term=%s" % quote(terms), + timeout=10) + + @property + def result_type(self): + if self.udres and "result_type" in self.udres: + return self.udres["result_type"] + else: + return "" + + @property + def definitions(self): + if self.udres and "list" in self.udres: + for d in self.udres["list"]: + yield d["definition"] + "\n" + d["example"] + else: + yield "Sorry, no definition found for %s" % self.terms diff --git a/modules/ddg/__init__.py b/modules/ddg/__init__.py new file mode 100644 index 0000000..e7cfe89 --- /dev/null +++ b/modules/ddg/__init__.py @@ -0,0 +1,70 @@ +# coding=utf-8 + +"""Search around various search engine or knowledges database""" + +import imp + +from nemubot import context +from nemubot.exception import IRCException +from nemubot.hooks import hook + +nemubotversion = 3.4 + +from more import Response + +from . import DDGSearch +from . import UrbanDictionnary + +@hook("cmd_hook", "define") +def define(msg): + if not len(msg.args): + raise IRCException("Indicate a term to define") + + s = DDGSearch.DDGSearch(' '.join(msg.args)) + + return Response(s.definition, channel=msg.channel) + + +@hook("cmd_hook", "search") +def search(msg): + if not len(msg.args): + raise IRCException("Indicate a term to search") + + if "!safeoff" in msg.args: + msg.args.remove("!safeoff") + safeoff = True + else: + safeoff = False + + s = DDGSearch.DDGSearch(' '.join(msg.args), safeoff) + + res = Response(channel=msg.channel, nomore="No more results", + count=" (%d more results)") + + res.append_message(s.redirect) + res.append_message(s.abstract) + res.append_message(s.result) + res.append_message(s.answer) + + for rt in s.relatedTopics: + res.append_message(rt) + + res.append_message(s.definition) + + return res + + +@hook("cmd_hook", "urbandictionnary") +def udsearch(msg): + if not len(msg.args): + raise IRCException("Indicate a term to search") + + s = UrbanDictionnary.UrbanDictionnary(' '.join(msg.args)) + + res = Response(channel=msg.channel, nomore="No more results", + count=" (%d more definitions)") + + for d in s.definitions: + res.append_message(d.replace("\n", " ")) + + return res diff --git a/modules/dig.py b/modules/dig.py deleted file mode 100644 index bec0a87..0000000 --- a/modules/dig.py +++ /dev/null @@ -1,94 +0,0 @@ -"""DNS resolver""" - -# PYTHON STUFFS ####################################################### - -import ipaddress -import socket - -import dns.exception -import dns.name -import dns.rdataclass -import dns.rdatatype -import dns.resolver - -from nemubot.exception import IMException -from nemubot.hooks import hook - -from nemubot.module.more import Response - - -# MODULE INTERFACE #################################################### - -@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.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") - - 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(".")] - - 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("%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 diff --git a/modules/disas.py b/modules/disas.py deleted file mode 100644 index cb80ef3..0000000 --- a/modules/disas.py +++ /dev/null @@ -1,89 +0,0 @@ -"""The Ultimate Disassembler Module""" - -# PYTHON STUFFS ####################################################### - -import capstone - -from nemubot.exception import IMException -from nemubot.hooks import hook - -from nemubot.module.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 diff --git a/modules/events.py b/modules/events.py index acac196..39ac787 100644 --- a/modules/events.py +++ b/modules/events.py @@ -1,99 +1,49 @@ +# coding=utf-8 + """Create countdowns and reminders""" -import calendar -from datetime import datetime, timedelta, timezone -from functools import partial +import imp import re +import sys +from datetime import datetime, timedelta, timezone +import time +import threading +import traceback from nemubot import context -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.event import ModuleEvent 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.basic import DictNode +from nemubot.tools.xmlparser.node import ModuleState -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 +nemubotversion = 3.4 +from more import Response def help_full (): - 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" - + 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" def load(context): - context.set_knodes({ - "dict": DictNode, - "event": Event, - }) + #Define the index + context.data.setIndex("name") - if context.data is None: - context.set_default(DictNode()) - - # Relaunch all timers - for kevt in context.data: - if context.data[kevt].end: - context.data[kevt]._evt = context.add_event(ModuleEvent(partial(fini, kevt, context.data[kevt]), offset=context.data[kevt].end - datetime.now(timezone.utc), interval=0)) + 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 -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] +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"]]) context.save() - -@hook.command("goûter") +@hook("cmd_hook", "goûter") def cmd_gouter(msg): ndate = datetime.now(timezone.utc) ndate = datetime(ndate.year, ndate.month, ndate.day, 16, 42, 0, 0, timezone.utc) @@ -102,8 +52,7 @@ def cmd_gouter(msg): "Nous avons %s de retard pour le goûter :("), channel=msg.channel) - -@hook.command("week-end") +@hook("cmd_hook", "week-end") def cmd_we(msg): ndate = datetime.now(timezone.utc) + timedelta(5 - datetime.today().weekday()) ndate = datetime(ndate.year, ndate.month, ndate.day, 0, 0, 1, 0, timezone.utc) @@ -112,16 +61,23 @@ def cmd_we(msg): "Youhou, on est en week-end depuis %s."), channel=msg.channel) - -@hook.command("start") +@hook("cmd_hook", "start") 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: - raise IMException("%s existe déjà." % msg.args[0]) + raise IRCException("indique le nom d'un événement à chronométrer") + if msg.args[0] in context.data.index: + raise IRCException("%s existe déjà." % msg.args[0]) - evt = Event(server=msg.server, channel=msg.channel, creator=msg.frm, start_time=msg.date) + strnd = ModuleState("strend") + strnd["server"] = msg.server + strnd["channel"] = msg.channel + strnd["proprio"] = msg.nick + strnd["start"] = msg.date + strnd["name"] = msg.args[0] + context.data.addChild(strnd) + + evt = ModuleEvent(call=fini, call_data=dict(strend=strnd)) if len(msg.args) > 1: result1 = re.findall("([0-9]+)([smhdjwyaSMHDJWYA])?", msg.args[1]) @@ -139,158 +95,156 @@ 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: - evt.end = datetime(yea, int(result2.group(3)), int(result2.group(2)), hou, minu, sec, timezone.utc) + strnd["end"] = datetime(yea, int(result2.group(3)), int(result2.group(2)), hou, minu, sec, timezone.utc) elif result2 is not None: - evt.end = datetime(int(result2.group(4)), int(result2.group(3)), int(result2.group(2)), 0, 0, 0, timezone.utc) + strnd["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: - evt.end = datetime(now.year, now.month, now.day, hou, minu, sec, timezone.utc) + strnd["end"] = datetime(now.year, now.month, now.day, hou, minu, sec, timezone.utc) else: - evt.end = datetime(now.year, now.month, now.day + 1, hou, minu, sec, timezone.utc) + 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) except: - raise IMException("Mauvais format de date pour l'événement %s. Il n'a pas été créé." % msg.args[0]) + context.data.delChild(strnd) + raise IRCException("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: - evt.end = msg.date + strnd["end"] = msg.date for (t, g) in result1: if g is None or g == "" or g == "m" or g == "M": - evt.end += timedelta(minutes=int(t)) + strnd["end"] += timedelta(minutes=int(t)) elif g == "h" or g == "H": - evt.end += timedelta(hours=int(t)) + strnd["end"] += timedelta(hours=int(t)) elif g == "d" or g == "D" or g == "j" or g == "J": - evt.end += timedelta(days=int(t)) + strnd["end"] += timedelta(days=int(t)) elif g == "w" or g == "W": - evt.end += timedelta(days=int(t)*7) + strnd["end"] += timedelta(days=int(t)*7) elif g == "y" or g == "Y" or g == "a" or g == "A": - evt.end += timedelta(days=int(t)*365) + strnd["end"] += timedelta(days=int(t)*365) else: - evt.end += timedelta(seconds=int(t)) + strnd["end"] += timedelta(seconds=int(t)) + evt._end = strnd.getDate("end") + eid = context.add_event(evt) + if eid is not None: + strnd["_id"] = eid - else: - raise IMException("Mauvais format de date pour l'événement %s. Il n'a pas été créé." % msg.args[0]) - - context.data[msg.args[0]] = evt context.save() - - 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)) + if "end" in strnd: return Response("%s commencé le %s et se terminera le %s." % (msg.args[0], msg.date.strftime("%A %d %B %Y à %H:%M:%S"), - evt.end.strftime("%A %d %B %Y à %H:%M:%S")), - channel=msg.channel) + strnd.getDate("end").strftime("%A %d %B %Y à %H:%M:%S")), + nick=msg.frm) else: return Response("%s commencé le %s"% (msg.args[0], msg.date.strftime("%A %d %B %Y à %H:%M:%S")), - channel=msg.channel) + nick=msg.frm) - -@hook.command("end") -@hook.command("forceend") +@hook("cmd_hook", "end") +@hook("cmd_hook", "forceend") def end_countdown(msg): if len(msg.args) < 1: - raise IMException("quel événement terminer ?") + raise IRCException("quel événement terminer ?") - 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]] + 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): + 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.frm) + channel=msg.channel, nick=msg.nick) else: - raise IMException("Vous ne pouvez pas terminer le compteur %s, créé par %s." % (msg.args[0], context.data[msg.args[0]].creator)) + raise IRCException("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.frm) + return Response("%s n'est pas un compteur connu."% (msg.args[0]), channel=msg.channel, nick=msg.nick) - -@hook.command("eventslist") +@hook("cmd_hook", "eventslist") def liste(msg): """!eventslist: gets list of timer""" if len(msg.args): - res = Response(channel=msg.channel) + res = list() for user in msg.args: - cmptr = [k for k in context.data if context.data[k].creator == user] + cmptr = [x["name"] for x in context.data.index.values() if x["proprio"] == user] if len(cmptr) > 0: - res.append_message(cmptr, title="Events created by %s" % user) + res.append("Compteurs créés par %s : %s" % (user, ", ".join(cmptr))) else: - res.append_message("%s doesn't have any counting events" % user) - return res + res.append("%s n'a pas créé de compteur" % user) + return Response(" ; ".join(res), channel=msg.channel) else: - return Response(list(context.data.keys()), channel=msg.channel, title="Known events") + return Response("Compteurs connus : %s." % ", ".join(context.data.index.keys()), channel=msg.channel) - -@hook.command(match=lambda msg: isinstance(msg, Command) and msg.cmd in context.data) +@hook("cmd_default") def parseanswer(msg): - res = Response(channel=msg.channel) + if msg.cmd in context.data.index: + res = Response(channel=msg.channel) - # Avoid message starting by ! which can be interpreted as command by other bots - if msg.cmd[0] == "!": - res.nick = msg.frm + # Avoid message starting by ! which can be interpreted as command by other bots + if msg.cmd[0] == "!": + res.nick = msg.nick - 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))) + 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))) + else: + res.append_message("%s commencé il y a %s." % (msg.cmd, countdown(msg.date - context.data.index[msg.cmd].getDate("start")))) else: - 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[msg.cmd].start, context.data[msg.cmd]["msg_before"], context.data[msg.cmd]["msg_after"])) - return res - + 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"])) + return res 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.message)) +@hook("ask_default") 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: - raise IMException("un événement portant ce nom existe déjà.") + if RGXP_ask.match(msg.text) is not None: + name = re.match("^.*!([^ \"'@!]+).*$", msg.text) + if name is None: + raise IRCException("il faut que tu attribues une commande à l'événement.") + if name.group(1) in context.data.index: + raise IRCException("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) - if texts is not None and texts.group(3) is not None: - extDate = extractDate(msg.message) - if extDate is None or extDate == "": - raise IMException("la date de l'événement est invalide !") + texts = re.match("^[^\"]*(avant|après|apres|before|after)?[^\"]*\"([^\"]+)\"[^\"]*((avant|après|apres|before|after)?.*\"([^\"]+)\".*)?$", msg.text, re.I) + if texts is not None and texts.group(3) is not None: + extDate = extractDate(msg.text) + if extDate is None or extDate == "": + raise IRCException("la date de l'événement est invalide !") - if texts.group(1) is not None and (texts.group(1) == "après" or texts.group(1) == "apres" or texts.group(1) == "after"): - msg_after = texts.group(2) - msg_before = texts.group(5) - if (texts.group(4) is not None and (texts.group(4) == "après" or texts.group(4) == "apres" or texts.group(4) == "after")) or texts.group(1) is None: - msg_before = texts.group(2) - msg_after = texts.group(5) + if texts.group(1) is not None and (texts.group(1) == "après" or texts.group(1) == "apres" or texts.group(1) == "after"): + msg_after = texts.group (2) + msg_before = texts.group (5) + if (texts.group(4) is not None and (texts.group(4) == "après" or texts.group(4) == "apres" or texts.group(4) == "after")) or texts.group(1) is None: + msg_before = texts.group (2) + msg_after = texts.group (5) - if msg_before.find("%s") == -1 or msg_after.find("%s") == -1: - raise IMException("Pour que l'événement soit valide, ajouter %s à" - " l'endroit où vous voulez que soit ajouté le" - " compte à rebours.") + if msg_before.find("%s") == -1 or msg_after.find("%s") == -1: + raise IRCException("Pour que l'événement soit valide, ajouter %s à" + " l'endroit où vous voulez que soit ajouté le" + " compte à rebours.") - evt = ModuleState("event") - evt["server"] = msg.server - evt["channel"] = msg.channel - evt["proprio"] = msg.frm - evt["name"] = name.group(1) - evt["start"] = extDate - evt["msg_after"] = msg_after - evt["msg_before"] = msg_before - context.data.addChild(evt) - context.save() - return Response("Nouvel événement !%s ajouté avec succès." % name.group(1), - channel=msg.channel) + evt = ModuleState("event") + evt["server"] = msg.server + evt["channel"] = msg.channel + evt["proprio"] = msg.nick + evt["name"] = name.group(1) + evt["start"] = extDate + evt["msg_after"] = msg_after + evt["msg_before"] = msg_before + context.data.addChild(evt) + context.save() + return Response("Nouvel événement !%s ajouté avec succès." % name.group(1), + channel=msg.channel) - elif texts is not None and texts.group(2) is not None: - evt = ModuleState("event") - evt["server"] = msg.server - evt["channel"] = msg.channel - evt["proprio"] = msg.frm - evt["name"] = name.group(1) - evt["msg_before"] = texts.group (2) - context.data.addChild(evt) - context.save() - return Response("Nouvelle commande !%s ajoutée avec succès." % name.group(1), - channel=msg.channel) + elif texts is not None and texts.group (2) is not None: + evt = ModuleState("event") + evt["server"] = msg.server + evt["channel"] = msg.channel + evt["proprio"] = msg.nick + evt["name"] = name.group(1) + evt["msg_before"] = texts.group (2) + context.data.addChild(evt) + context.save() + return Response("Nouvelle commande !%s ajoutée avec succès." % name.group(1), + channel=msg.channel) - else: - raise IMException("Veuillez indiquez les messages d'attente et d'après événement entre guillemets.") + else: + raise IRCException("Veuillez indiquez les messages d'attente et d'après événement entre guillemets.") diff --git a/modules/framalink.py b/modules/framalink.py new file mode 100644 index 0000000..3ed1214 --- /dev/null +++ b/modules/framalink.py @@ -0,0 +1,127 @@ +"""URL reducer module""" + +# PYTHON STUFFS ####################################################### + +import re +import json +from urllib.parse import urlparse +from urllib.parse import quote + +from nemubot.exception import IRCException +from nemubot.hooks import hook +from nemubot.message import Text +from nemubot.tools import web + + +# MODULE FUCNTIONS #################################################### + +def default_reducer(url, data): + snd_url = url + quote(data, "/:%@&=?") + return web.getURLContent(snd_url) + +def framalink_reducer(url, data): + json_data = json.loads(web.getURLContent(url, "lsturl=" + + quote(data, "/:%@&=?"), + header={"Content-Type": "application/x-www-form-urlencoded"})) + return json_data['short'] + +# MODULE VARIABLES #################################################### + +PROVIDERS = { + "tinyurl": (default_reducer, "http://tinyurl.com/api-create.php?url="), + "ycc": (default_reducer, "http://ycc.fr/redirection/create/"), + "framalink": (framalink_reducer, "https://frama.link/a?format=json") +} +DEFAULT_PROVIDER = "framalink" + +PROVIDERS_NETLOC = [urlparse(web.getNormalizedURL(url), "http").netloc for f, url in PROVIDERS.values()] + +# LOADING ############################################################# + +def load(context): + global DEFAULT_PROVIDER + + if "provider" in context.config: + if context.config["provider"] == "custom": + PROVIDERS["custom"] = context.config["provider_url"] + DEFAULT_PROVIDER = context.config["provider"] + + +# MODULE CORE ######################################################### + +def reduce(url): + """Ask the url shortner website to reduce given URL + + Argument: + url -- the URL to reduce + """ + return PROVIDERS[DEFAULT_PROVIDER][0](PROVIDERS[DEFAULT_PROVIDER][1], url) + +def gen_response(res, msg, srv): + if res is None: + raise IRCException("bad URL : %s" % srv) + else: + return Text("URL for %s: %s" % (srv, res), server=None, + to=msg.to_response) + + +## URL stack + +LAST_URLS = dict() + + +@hook("msg_default") +def parselisten(msg): + parseresponse(msg) + return None + + +@hook("all_post") +def parseresponse(msg): + global LAST_URLS + if hasattr(msg, "text") and msg.text: + urls = re.findall("([a-zA-Z0-9+.-]+:(?://)?[^ :]+)", msg.text) + 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.receivers: + if recv not in LAST_URLS: + LAST_URLS[recv] = list() + LAST_URLS[recv].append(url) + return msg + + +# MODULE INTERFACE #################################################### + +@hook("cmd_hook", "framalink", + help="Reduce any given URL", + help_usage={None: "Reduce the last URL said on the channel", + "URL [URL ...]": "Reduce the given URL(s)"}) +def cmd_reduceurl(msg): + minify = list() + + if not len(msg.args): + global LAST_URLS + if msg.channel in LAST_URLS and len(LAST_URLS[msg.channel]) > 0: + minify.append(LAST_URLS[msg.channel].pop()) + else: + raise IRCException("I have no more URL to reduce.") + + if len(msg.args) > 4: + raise IRCException("I cannot reduce as much URL at once.") + else: + minify += msg.args + + res = list() + for url in minify: + o = urlparse(web.getNormalizedURL(url), "http") + minief_url = reduce(url) + if o.netloc == "": + res.append(gen_response(minief_url, msg, o.scheme)) + else: + res.append(gen_response(minief_url, msg, o.netloc)) + return res diff --git a/modules/freetarifs.py b/modules/freetarifs.py deleted file mode 100644 index 49ad8a6..0000000 --- a/modules/freetarifs.py +++ /dev/null @@ -1,64 +0,0 @@ -"""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 nemubot.module.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 diff --git a/modules/github.py b/modules/github.py index 5f9a7d9..b8aa9d2 100644 --- a/modules/github.py +++ b/modules/github.py @@ -1,38 +1,40 @@ -"""Repositories, users or issues on GitHub""" +# coding=utf-8 -# PYTHON STUFFS ####################################################### +"""Repositories, users or issues on GitHub""" import re from urllib.parse import quote -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web -from nemubot.module.more import Response +nemubotversion = 3.4 + +from more import Response -# MODULE CORE ######################################################### +def help_full(): + return ("!github /repo/: Display information about /repo/.\n" + "!github_user /user/: Display information about /user/.") + def info_repos(repo): return web.getJSON("https://api.github.com/search/repositories?q=%s" % - quote(repo)) + quote(repo), timeout=10) def info_user(username): - user = web.getJSON("https://api.github.com/users/%s" % quote(username)) + user = web.getJSON("https://api.github.com/users/%s" % quote(username), + timeout=10) user["repos"] = web.getJSON("https://api.github.com/users/%s/" - "repos?sort=updated" % quote(username)) + "repos?sort=updated" % quote(username), + timeout=10) return user -def user_keys(username): - keys = web.getURLContent("https://github.com/%s.keys" % quote(username)) - return keys.split('\n') - - def info_issue(repo, issue=None): rp = info_repos(repo) if rp["items"]: @@ -63,16 +65,10 @@ def info_commit(repo, commit=None): quote(fullname)) -# MODULE INTERFACE #################################################### - -@hook.command("github", - help="Display information about some repositories", - help_usage={ - "REPO": "Display information about the repository REPO", - }) +@hook("cmd_hook", "github") def cmd_github(msg): if not len(msg.args): - raise IMException("indicate a repository name to search") + raise IRCException("indicate a repository name to search") repos = info_repos(" ".join(msg.args)) @@ -97,14 +93,10 @@ def cmd_github(msg): return res -@hook.command("github_user", - help="Display information about users", - help_usage={ - "USERNAME": "Display information about the user USERNAME", - }) -def cmd_github_user(msg): +@hook("cmd_hook", "github_user") +def cmd_github(msg): if not len(msg.args): - raise IMException("indicate a user name to search") + raise IRCException("indicate a user name to search") res = Response(channel=msg.channel, nomore="No more user") @@ -129,37 +121,15 @@ def cmd_github_user(msg): user["html_url"], kf)) else: - raise IMException("User not found") + raise IRCException("User not found") return res -@hook.command("github_user_keys", - help="Display user SSH keys", - help_usage={ - "USERNAME": "Show USERNAME's SSH keys", - }) -def cmd_github_user_keys(msg): +@hook("cmd_hook", "github_issue") +def cmd_github(msg): if not len(msg.args): - raise IMException("indicate a user name to search") - - res = Response(channel=msg.channel, nomore="No more keys") - - for k in user_keys(" ".join(msg.args)): - res.append_message(k) - - return res - - -@hook.command("github_issue", - help="Display repository's issues", - help_usage={ - "REPO": "Display latest issues created on REPO", - "REPO #ISSUE": "Display the issue number #ISSUE for REPO", - }) -def cmd_github_issue(msg): - if not len(msg.args): - raise IMException("indicate a repository to view its issues") + raise IRCException("indicate a repository to view its issues") issue = None @@ -180,7 +150,7 @@ def cmd_github_issue(msg): issues = info_issue(repo, issue) if issues is None: - raise IMException("Repository not found") + raise IRCException("Repository not found") for issue in issues: res.append_message("%s%s issue #%d: \x03\x02%s\x03\x02 opened by %s on %s: %s" % @@ -194,15 +164,10 @@ def cmd_github_issue(msg): return res -@hook.command("github_commit", - help="Display repository's commits", - help_usage={ - "REPO": "Display latest commits on REPO", - "REPO COMMIT": "Display details for the COMMIT on REPO", - }) -def cmd_github_commit(msg): +@hook("cmd_hook", "github_commit") +def cmd_github(msg): if not len(msg.args): - raise IMException("indicate a repository to view its commits") + raise IRCException("indicate a repository to view its commits") commit = None if re.match("^[a-fA-F0-9]+$", msg.args[0]): @@ -220,7 +185,7 @@ def cmd_github_commit(msg): commits = info_commit(repo, commit) if commits is None: - raise IMException("Repository or commit not found") + raise IRCException("Repository not found") for commit in commits: res.append_message("Commit %s by %s on %s: %s" % diff --git a/modules/grep.py b/modules/grep.py deleted file mode 100644 index fde8ecb..0000000 --- a/modules/grep.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Filter messages, displaying lines matching a pattern""" - -# PYTHON STUFFS ####################################################### - -import re - -from nemubot import context -from nemubot.exception import IMException -from nemubot.hooks import hook -from nemubot.message import Command, Text - -from nemubot.module.more import Response - - -# MODULE CORE ######################################################### - -def grep(fltr, cmd, msg, icase=False, only=False): - """Perform a grep like on known nemubot structures - - Arguments: - fltr -- The filter regexp - cmd -- The subcommand to execute - msg -- The original message - icase -- like the --ignore-case parameter of grep - only -- like the --only-matching parameter of grep - """ - - fltr = re.compile(fltr, re.I if icase else 0) - - for r in context.subtreat(context.subparse(msg, cmd)): - if isinstance(r, Response): - for i in range(len(r.messages) - 1, -1, -1): - if isinstance(r.messages[i], list): - for j in range(len(r.messages[i]) - 1, -1, -1): - res = fltr.match(r.messages[i][j]) - if not res: - r.messages[i].pop(j) - elif only: - r.messages[i][j] = res.group(1) if fltr.groups else res.group(0) - if len(r.messages[i]) <= 0: - r.messages.pop(i) - elif isinstance(r.messages[i], str): - res = fltr.match(r.messages[i]) - if not res: - r.messages.pop(i) - elif only: - r.messages[i] = res.group(1) if fltr.groups else res.group(0) - yield r - - elif isinstance(r, Text): - res = fltr.match(r.message) - if res: - if only: - r.message = res.group(1) if fltr.groups else res.group(0) - yield r - - else: - yield r - - -# MODULE INTERFACE #################################################### - -@hook.command("grep", - help="Display only lines from a subcommand matching the given pattern", - help_usage={"PTRN !SUBCMD": "Filter SUBCMD command using the pattern PTRN"}, - keywords={ - "nocase": "Perform case-insensitive matching", - "only": "Print only the matched parts of a matching line", - }) -def cmd_grep(msg): - if len(msg.args) < 2: - raise IMException("Please provide a filter and a command") - - only = "only" in msg.kwargs - - l = [m for m in grep(msg.args[0] if len(msg.args[0]) and msg.args[0][0] == "^" else ".*?(" + msg.args[0] + ").*?", - " ".join(msg.args[1:]), - msg, - icase="nocase" in msg.kwargs, - only=only) if m is not None] - - if len(l) <= 0: - raise IMException("Pattern not found in output") - - return l diff --git a/modules/imdb.py b/modules/imdb.py index 7a42935..49c4cc9 100644 --- a/modules/imdb.py +++ b/modules/imdb.py @@ -1,115 +1,112 @@ -"""Show many information about a movie or serie""" +# coding=utf-8 -# PYTHON STUFFS ####################################################### +"""Show many information about a movie or serie""" import re import urllib.parse -from bs4 import BeautifulSoup - -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web -from nemubot.module.more import Response +nemubotversion = 3.4 + +from more import Response -# MODULE CORE ######################################################### +def help_full(): + return "Search a movie title with: !imdbs ; View movie details with !imdb " -def get_movie_by_id(imdbid): + +def get_movie(title=None, year=None, imdbid=None, fullplot=True, tomatoes=False): """Returns the information about the matching movie""" - url = "http://www.imdb.com/title/" + urllib.parse.quote(imdbid) - soup = BeautifulSoup(web.getURLContent(url)) - - return { - "imdbID": imdbid, - "Title": soup.body.find('h1').contents[0].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("a")[1:]]), - "Duration": soup.body.find(attrs={"class": "title_wrapper"}).find("time").text.strip() if soup.body.find(attrs={"class": "title_wrapper"}).find("time") else None, - "imdbRating": soup.body.find(attrs={"class": "ratingValue"}).find("strong").text.strip() if soup.body.find(attrs={"class": "ratingValue"}) else None, - "imdbVotes": soup.body.find(attrs={"class": "imdbRating"}).find("a").text.strip() if soup.body.find(attrs={"class": "imdbRating"}) else None, - "Plot": re.sub(r"\s+", " ", soup.body.find(attrs={"class": "summary_text"}).text).strip(), - - "Type": "TV Series" if soup.find(id="title-episode-widget") else "Movie", - "Genre": ", ".join([x.text.strip() for x in soup.body.find(id="titleStoryLine").find_all("a") if x.get("href") is not None and x.get("href")[:21] == "/search/title?genres="]), - "Country": ", ".join([x.text.strip() for x in soup.body.find(id="titleDetails").find_all("a") if x.get("href") is not None and x.get("href")[:32] == "/search/title?country_of_origin="]), - "Credits": " ; ".join([x.find("h4").text.strip() + " " + (", ".join([y.text.strip() for y in x.find_all("a") if y.get("href") is not None and y.get("href")[:6] == "/name/"])) for x in soup.body.find_all(attrs={"class": "credit_summary_item"})]), - } - - -def find_movies(title, year=None): - """Find existing movies matching a approximate title""" - - title = title.lower() - # Built URL - url = "https://v2.sg.media-imdb.com/suggests/%s/%s.json" % (urllib.parse.quote(title[0]), urllib.parse.quote(title.replace(" ", "_"))) + 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&" # Make the request - data = web.getJSON(url, remove_callback=True) + data = web.getJSON(url) + + # Return data + if "Error" in data: + raise IRCException(data["Error"]) + + elif "Response" in data and data["Response"] == "True": + return data - if "d" not in data: - return None - elif year is None: - return data["d"] else: - return [d for d in data["d"] if "y" in d and str(d["y"]) == year] + raise IRCException("An error occurs during movie search") -# MODULE INTERFACE #################################################### +def find_movies(title): + """Find existing movies matching a approximate title""" -@hook.command("imdb", - help="View movie/serie details, using OMDB", - help_usage={ - "TITLE": "Look for a movie titled TITLE", - "IMDB_ID": "Look for the movie with the given IMDB_ID", - }) + # Built URL + url = "http://www.omdbapi.com/?s=%s" % urllib.parse.quote(title) + + # Make the request + data = web.getJSON(url) + + # Return data + if "Error" in data: + raise IRCException(data["Error"]) + + elif "Search" in data: + return data + + else: + raise IRCException("An error occurs during movie search") + + +@hook("cmd_hook", "imdb") def cmd_imdb(msg): + """View movie details with !imdb <title>""" if not len(msg.args): - raise IMException("precise a movie/serie title!") + raise IRCException("precise a movie/serie title!") title = ' '.join(msg.args) if re.match("^tt[0-9]{7}$", title) is not None: - data = get_movie_by_id(imdbid=title) + data = get_movie(imdbid=title) else: rm = re.match(r"^(.+)\s\(([0-9]{4})\)$", title) if rm is not None: - data = find_movies(rm.group(1), year=rm.group(2)) + data = get_movie(title=rm.group(1), year=rm.group(2)) else: - data = find_movies(title) - - if not data: - raise IMException("Movie/series not found") - - data = get_movie_by_id(data[0]["id"]) + data = get_movie(title=title) 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("%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; %s" - % (data['Type'], data['Country'], data['Credits'])) + res.append_message("\x02rating\x0F: %s (%s votes); \x02plot\x0F: %s" % + (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'])) return res -@hook.command("imdbs", - help="Search a movie/serie by title", - help_usage={ - "TITLE": "Search a movie/serie by TITLE", - }) +@hook("cmd_hook", "imdbs") def cmd_search(msg): + """!imdbs <approximative title> to search a movie title""" if not len(msg.args): - raise IMException("precise a movie/serie title!") + raise IRCException("precise a movie/serie title!") data = find_movies(' '.join(msg.args)) movies = list() - for m in data: - movies.append("\x02%s\x0F%s with %s" % (m['l'], (" (" + str(m['y']) + ")") if "y" in m else "", m['s'])) + for m in data['Search']: + movies.append("\x02%s\x0F (%s of %s)" % (m['Title'], m['Type'], m['Year'])) return Response(movies, title="Titles found", channel=msg.channel) diff --git a/modules/jsonbot.py b/modules/jsonbot.py index 3126dc1..9061d29 100644 --- a/modules/jsonbot.py +++ b/modules/jsonbot.py @@ -1,7 +1,9 @@ +from bs4 import BeautifulSoup + from nemubot.hooks import hook -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.tools import web -from nemubot.module.more import Response +from more import Response import json nemubotversion = 3.4 @@ -39,18 +41,18 @@ def getJsonKeys(data): else: return data.keys() -@hook.command("json") +@hook("cmd_hook", "json") def get_json_info(msg): if not len(msg.args): - raise IMException("Please specify a url and a list of JSON keys.") + raise IRCException("Please specify a url and a list of JSON keys.") request_data = web.getURLContent(msg.args[0].replace(' ', "%20")) if not request_data: - raise IMException("Please specify a valid url.") + raise IRCException("Please specify a valid url.") json_data = json.loads(request_data) if len(msg.args) == 1: - raise IMException("Please specify the keys to return (%s)" % ", ".join(getJsonKeys(json_data))) + raise IRCException("Please specify the keys to return (%s)" % ", ".join(getJsonKeys(json_data))) tags = ','.join(msg.args[1:]).split(',') response = getRequestedTags(tags, json_data) diff --git a/modules/man.py b/modules/man.py index f60e0cf..31ed9f2 100644 --- a/modules/man.py +++ b/modules/man.py @@ -1,6 +1,6 @@ -"""Read manual pages on IRC""" +# coding=utf-8 -# PYTHON STUFFS ####################################################### +"Read manual pages on IRC" import subprocess import re @@ -8,22 +8,18 @@ import os from nemubot.hooks import hook -from nemubot.module.more import Response +nemubotversion = 3.4 + +from more import Response -# GLOBALS ############################################################# +def help_full(): + return "!man [0-9] /what/: gives informations about /what/." RGXP_s = re.compile(b'\x1b\\[[0-9]+m') -# MODULE INTERFACE #################################################### - -@hook.command("MAN", - help="Show man pages", - help_usage={ - "SUBJECT": "Display the default man page for SUBJECT", - "SECTION SUBJECT": "Display the man page in SECTION for SUBJECT" - }) +@hook("cmd_hook", "MAN") def cmd_man(msg): args = ["man"] num = None @@ -56,11 +52,7 @@ def cmd_man(msg): return res -@hook.command("man", - help="Show man pages synopsis (in one line)", - help_usage={ - "SUBJECT": "Display man page synopsis for SUBJECT", - }) +@hook("cmd_hook", "man") def cmd_whatis(msg): args = ["whatis", " ".join(msg.args)] @@ -73,6 +65,10 @@ def cmd_whatis(msg): res.append_message(" ".join(line.decode().split())) if len(res.messages) <= 0: - res.append_message("There is no man page for %s." % msg.args[0]) + if num is not None: + res.append_message("There is no entry %s in section %d." % + (msg.args[0], num)) + else: + res.append_message("There is no man page for %s." % msg.args[0]) return res diff --git a/modules/mapquest.py b/modules/mapquest.py index f328e1d..95952ab 100644 --- a/modules/mapquest.py +++ b/modules/mapquest.py @@ -1,34 +1,33 @@ -"""Transform name location to GPS coordinates""" +# coding=utf-8 -# PYTHON STUFFS ####################################################### +"""Transform name location to GPS coordinates""" import re from urllib.parse import quote -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web -from nemubot.module.more import Response +nemubotversion = 4.0 -# GLOBALS ############################################################# +from more import Response -URL_API = "https://open.mapquestapi.com/geocoding/v1/address?key=%s&location=%%s" - - -# LOADING ############################################################# +URL_API = "http://open.mapquestapi.com/geocoding/v1/address?key=%s&location=%%s" def load(context): - if not context.config or "apikey" not in context.config: + if not context.config or not context.config.hasAttribute("apikey"): raise ImportError("You need a MapQuest API key in order to use this " "module. Add it to the module configuration file:\n" "<module name=\"mapquest\" key=\"XXXXXXXXXXXXXXXX\" " - "/>\nRegister at https://developer.mapquest.com/") + "/>\nRegister at http://developer.mapquest.com/") global URL_API URL_API = URL_API % context.config["apikey"].replace("%", "%%") -# MODULE CORE ######################################################### +def help_full(): + return "!geocode /place/: get coordinate of /place/." + def geocode(location): obj = web.getJSON(URL_API % quote(location)) @@ -44,18 +43,12 @@ def where(loc): "{adminArea1}".format(**loc)).strip() -# MODULE INTERFACE #################################################### - -@hook.command("geocode", - help="Get GPS coordinates of a place", - help_usage={ - "PLACE": "Get GPS coordinates of PLACE" - }) +@hook("cmd_hook", "geocode") def cmd_geocode(msg): if not len(msg.args): - raise IMException("indicate a name") + raise IRCException("indicate a name") - res = Response(channel=msg.channel, nick=msg.frm, + res = Response(channel=msg.channel, nick=msg.nick, nomore="No more geocode", count=" (%s more geocode)") for loc in geocode(' '.join(msg.args)): diff --git a/modules/mediawiki.py b/modules/mediawiki.py index be608ca..630afdb 100644 --- a/modules/mediawiki.py +++ b/modules/mediawiki.py @@ -2,24 +2,25 @@ """Use MediaWiki API to get pages""" +import json import re import urllib.parse -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web nemubotversion = 3.4 -from nemubot.module.more import Response +from more import Response # MEDIAWIKI REQUESTS ################################################## -def get_namespaces(site, ssl=False, path="/w/api.php"): +def get_namespaces(site, ssl=False): # Built URL - url = "http%s://%s%s?format=json&action=query&meta=siteinfo&siprop=namespaces" % ( - "s" if ssl else "", site, path) + url = "http%s://%s/w/api.php?format=json&action=query&meta=siteinfo&siprop=namespaces" % ( + "s" if ssl else "", site) # Make the request data = web.getJSON(url) @@ -30,10 +31,10 @@ def get_namespaces(site, ssl=False, path="/w/api.php"): return namespaces -def get_raw_page(site, term, ssl=False, path="/w/api.php"): +def get_raw_page(site, term, ssl=False): # Built URL - url = "http%s://%s%s?format=json&redirects&action=query&prop=revisions&rvprop=content&titles=%s" % ( - "s" if ssl else "", site, path, urllib.parse.quote(term)) + url = "http%s://%s/w/api.php?format=json&redirects&action=query&prop=revisions&rvprop=content&titles=%s" % ( + "s" if ssl else "", site, urllib.parse.quote(term)) # Make the request data = web.getJSON(url) @@ -42,13 +43,13 @@ def get_raw_page(site, term, ssl=False, path="/w/api.php"): try: return data["query"]["pages"][k]["revisions"][0]["*"] except: - raise IMException("article not found") + raise IRCException("article not found") -def get_unwikitextified(site, wikitext, ssl=False, path="/w/api.php"): +def get_unwikitextified(site, wikitext, ssl=False): # Built URL - url = "http%s://%s%s?format=json&action=expandtemplates&text=%s" % ( - "s" if ssl else "", site, path, urllib.parse.quote(wikitext)) + url = "http%s://%s/w/api.php?format=json&action=expandtemplates&text=%s" % ( + "s" if ssl else "", site, urllib.parse.quote(wikitext)) # Make the request data = web.getJSON(url) @@ -58,25 +59,25 @@ def get_unwikitextified(site, wikitext, ssl=False, path="/w/api.php"): ## Search -def opensearch(site, term, ssl=False, path="/w/api.php"): +def opensearch(site, term, ssl=False): # Built URL - url = "http%s://%s%s?format=json&action=opensearch&search=%s" % ( - "s" if ssl else "", site, path, urllib.parse.quote(term)) + url = "http%s://%s/w/api.php?format=xml&action=opensearch&search=%s" % ( + "s" if ssl else "", site, urllib.parse.quote(term)) # Make the request - response = web.getJSON(url) + response = web.getXML(url) - if response is not None and len(response) >= 4: - for k in range(len(response[1])): - yield (response[1][k], - response[2][k], - response[3][k]) + if response is not None and response.hasNode("Section"): + for itm in response.getNode("Section").getNodes("Item"): + yield (itm.getNode("Text").getContent(), + itm.getNode("Description").getContent() if itm.hasNode("Description") else "", + itm.getNode("Url").getContent()) -def search(site, term, ssl=False, path="/w/api.php"): +def search(site, term, ssl=False): # Built URL - url = "http%s://%s%s?format=json&action=query&list=search&srsearch=%s&srprop=titlesnippet|snippet" % ( - "s" if ssl else "", site, path, urllib.parse.quote(term)) + url = "http%s://%s/w/api.php?format=json&action=query&list=search&srsearch=%s&srprop=titlesnippet|snippet" % ( + "s" if ssl else "", site, urllib.parse.quote(term)) # Make the request data = web.getJSON(url) @@ -89,11 +90,6 @@ def search(site, term, ssl=False, path="/w/api.php"): # PARSING FUNCTIONS ################################################### -def get_model(cnt, model="Infobox"): - for full in re.findall(r"(\{\{" + model + " .*?(?:\{\{.*?}}.*?)*}})", cnt, flags=re.DOTALL): - return full[3 + len(model):-2].replace("\n", " ").strip() - - def strip_model(cnt): # Strip models at begin: mostly useless cnt = re.sub(r"^(({{([^{]|\s|({{([^{]|\s|{{.*?}})*?}})*?)*?}}|\[\[([^[]|\s|\[\[.*?\]\])*?\]\])\s*)+", "", cnt, flags=re.DOTALL) @@ -113,9 +109,9 @@ def strip_model(cnt): return cnt -def parse_wikitext(site, cnt, namespaces=dict(), **kwargs): +def parse_wikitext(site, cnt, namespaces=dict(), ssl=False): for i, _, _, _ in re.findall(r"({{([^{]|\s|({{(.|\s|{{.*?}})*?}})*?)*?}})", cnt): - cnt = cnt.replace(i, get_unwikitextified(site, i, **kwargs), 1) + cnt = cnt.replace(i, get_unwikitextified(site, i, ssl), 1) # Strip [[...]] for full, args, lnk in re.findall(r"(\[\[(.*?|)?([^|]*?)\]\])", cnt): @@ -144,106 +140,71 @@ def irc_format(cnt): return cnt.replace("'''", "\x03\x02").replace("''", "\x03\x1f") -def parse_infobox(cnt): - for v in cnt.split("|"): - try: - yield re.sub(r"^\s*([^=]*[^=\s])\s*=\s*(.+)\s*$", "\x03\x02" + r"\1" + ":\x03\x02 " + r"\2", v).replace("<br />", ", ").replace("<br/>", ", ").strip() - except: - yield re.sub(r"^\s+(.+)\s+$", "\x03\x02" + r"\1" + "\x03\x02", v).replace("<br />", ", ").replace("<br/>", ", ").strip() - - -def get_page(site, term, subpart=None, **kwargs): - raw = get_raw_page(site, term, **kwargs) +def get_page(site, term, ssl=False, subpart=None): + raw = get_raw_page(site, term, ssl) if subpart is not None: subpart = subpart.replace("_", " ") raw = re.sub(r"^.*(?P<title>==+)\s*(" + subpart + r")\s*(?P=title)", r"\1 \2 \1", raw, flags=re.DOTALL) - return raw + return strip_model(raw) # NEMUBOT ############################################################# -def mediawiki_response(site, term, to, **kwargs): - ns = get_namespaces(site, **kwargs) +def mediawiki_response(site, term, receivers): + ns = get_namespaces(site) terms = term.split("#", 1) try: # Print the article if it exists - return Response(strip_model(get_page(site, terms[0], subpart=terms[1] if len(terms) > 1 else None, **kwargs)), - line_treat=lambda line: irc_format(parse_wikitext(site, line, ns, **kwargs)), - channel=to) + return Response(get_page(site, terms[0], subpart=terms[1] if len(terms) > 1 else None), + line_treat=lambda line: irc_format(parse_wikitext(site, line, ns)), + channel=receivers) except: pass # Try looking at opensearch - os = [x for x, _, _ in opensearch(site, terms[0], **kwargs)] - print(os) + os = [x for x, _, _ in opensearch(site, terms[0])] # Fallback to global search if not len(os): - os = [x for x, _ in search(site, terms[0], **kwargs) if x is not None and x != ""] + os = [x for x, _ in search(site, terms[0]) if x is not None and x != ""] return Response(os, - channel=to, + channel=receivers, title="Article not found, would you mean") -@hook.command("mediawiki", - help="Read an article on a MediaWiki", - keywords={ - "ssl": "query over https instead of http", - "path=PATH": "absolute path to the API", - }) +@hook("cmd_hook", "mediawiki") def cmd_mediawiki(msg): + """Read an article on a MediaWiki""" if len(msg.args) < 2: - raise IMException("indicate a domain and a term to search") + raise IRCException("indicate a domain and a term to search") return mediawiki_response(msg.args[0], " ".join(msg.args[1:]), - msg.to_response, - **msg.kwargs) + msg.receivers) -@hook.command("mediawiki_search", - help="Search an article on a MediaWiki", - keywords={ - "ssl": "query over https instead of http", - "path=PATH": "absolute path to the API", - }) +@hook("cmd_hook", "search_mediawiki") def cmd_srchmediawiki(msg): + """Search an article on a MediaWiki""" if len(msg.args) < 2: - raise IMException("indicate a domain and a term to search") + raise IRCException("indicate a domain and a term to search") - res = Response(channel=msg.to_response, nomore="No more results", count=" (%d more results)") + res = Response(channel=msg.receivers, nomore="No more results", count=" (%d more results)") - for r in search(msg.args[0], " ".join(msg.args[1:]), **msg.kwargs): + for r in search(msg.args[0], " ".join(msg.args[1:])): res.append_message("%s: %s" % r) return res -@hook.command("mediawiki_infobox", - help="Highlight information from an article on a MediaWiki", - keywords={ - "ssl": "query over https instead of http", - "path=PATH": "absolute path to the API", - }) -def cmd_infobox(msg): - if len(msg.args) < 2: - raise IMException("indicate a domain and a term to search") - - ns = get_namespaces(msg.args[0], **msg.kwargs) - - return Response(", ".join([x for x in parse_infobox(get_model(get_page(msg.args[0], " ".join(msg.args[1:]), **msg.kwargs), "Infobox"))]), - line_treat=lambda line: irc_format(parse_wikitext(msg.args[0], line, ns, **msg.kwargs)), - channel=msg.to_response) - - -@hook.command("wikipedia") +@hook("cmd_hook", "wikipedia") def cmd_wikipedia(msg): if len(msg.args) < 2: - raise IMException("indicate a lang and a term to search") + raise IRCException("indicate a lang and a term to search") return mediawiki_response(msg.args[0] + ".wikipedia.org", " ".join(msg.args[1:]), - msg.to_response) + msg.receivers) diff --git a/nemubot/module/more.py b/modules/more.py similarity index 88% rename from nemubot/module/more.py rename to modules/more.py index 206d97a..c5b5e49 100644 --- a/nemubot/module/more.py +++ b/modules/more.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # @@ -16,18 +18,17 @@ """Progressive display of very long messages""" -# PYTHON STUFFS ####################################################### - import logging +import sys from nemubot.message import Text, DirectAsk from nemubot.hooks import hook +nemubotversion = 3.4 + logger = logging.getLogger("nemubot.response") -# MODULE CORE ######################################################### - class Response: def __init__(self, message=None, channel=None, nick=None, server=None, @@ -50,7 +51,7 @@ class Response: @property - def to(self): + def receivers(self): if self.channel is None: if self.nick is not None: return [self.nick] @@ -60,7 +61,6 @@ class Response: else: return [self.channel] - def append_message(self, message, title=None, shown_first_count=-1): if type(message) is str: message = message.split('\n') @@ -91,7 +91,7 @@ class Response: def append_content(self, message): if message is not None and len(message) > 0: if self.messages is None or len(self.messages) == 0: - self.messages = [message] + self.messages = list(message) self.alone = True else: self.messages[len(self.messages)-1] += message @@ -141,10 +141,10 @@ class Response: if self.nick: return DirectAsk(self.nick, self.get_message(maxlen - len(self.nick) - 2), - server=None, to=self.to) + server=None, to=self.receivers) else: return Text(self.get_message(maxlen), - server=None, to=self.to) + server=None, to=self.receivers) def __str__(self): @@ -181,16 +181,8 @@ class Response: return self.nomore if self.line_treat is not None and self.elt == 0: - try: - if isinstance(self.messages[0], list): - for x in self.messages[0]: - print(x, self.line_treat(x)) - self.messages[0] = [self.line_treat(x).replace("\n", " ").strip() for x in self.messages[0]] - else: - self.messages[0] = (self.line_treat(self.messages[0]) - .replace("\n", " ").strip()) - except Exception as e: - logger.exception(e) + self.messages[0] = (self.line_treat(self.messages[0]) + .replace("\n", " ").strip()) msg = "" if self.title is not None: @@ -200,9 +192,7 @@ class Response: msg += self.title + ": " elif self.elt > 0: - msg += "[…]" - if self.messages[0][self.elt - 1] == ' ': - msg += " " + msg += "[…] " elts = self.messages[0][self.elt:] if isinstance(elts, list): @@ -220,7 +210,7 @@ class Response: else: if len(elts.encode()) <= maxlen: self.pop() - if self.count is not None and not self.alone: + if self.count is not None: return msg + elts + (self.count % len(self.messages)) else: return msg + elts @@ -247,16 +237,14 @@ class Response: SERVERS = dict() -# MODULE INTERFACE #################################################### - -@hook.post() +@hook("all_post") def parseresponse(res): # TODO: handle inter-bot communication NOMORE # TODO: check that the response is not the one already saved if isinstance(res, Response): if res.server not in SERVERS: SERVERS[res.server] = dict() - for receiver in res.to: + for receiver in res.receivers: if receiver in SERVERS[res.server]: nw, bk = SERVERS[res.server][receiver] else: @@ -266,7 +254,7 @@ def parseresponse(res): return res -@hook.command("more") +@hook("cmd_hook", "more") def cmd_more(msg): """Display next chunck of the message""" res = list() @@ -282,7 +270,7 @@ def cmd_more(msg): return res -@hook.command("next") +@hook("cmd_hook", "next") def cmd_next(msg): """Display the next information include in the message""" res = list() diff --git a/modules/networking/__init__.py b/modules/networking/__init__.py index 3b939ab..9688830 100644 --- a/modules/networking/__init__.py +++ b/modules/networking/__init__.py @@ -5,10 +5,10 @@ import logging import re -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook -from nemubot.module.more import Response +from more import Response from . import isup from . import page @@ -38,28 +38,28 @@ def load(context): # MODULE INTERFACE #################################################### -@hook.command("title", +@hook("cmd_hook", "title", help="Retrieve webpage's title", help_usage={"URL": "Display the title of the given URL"}) def cmd_title(msg): if not len(msg.args): - raise IMException("Indicate the URL to visit.") + raise IRCException("Indicate the URL to visit.") url = " ".join(msg.args) res = re.search("<title>(.*?)", page.fetch(" ".join(msg.args)), re.DOTALL) if res is None: - raise IMException("The page %s has no title" % url) + raise IRCException("The page %s has no title" % url) else: return Response("%s: %s" % (url, res.group(1).replace("\n", " ")), channel=msg.channel) -@hook.command("curly", +@hook("cmd_hook", "curly", help="Retrieve webpage's headers", help_usage={"URL": "Display HTTP headers of the given URL"}) def cmd_curly(msg): if not len(msg.args): - raise IMException("Indicate the URL to visit.") + raise IRCException("Indicate the URL to visit.") url = " ".join(msg.args) version, status, reason, headers = page.headers(url) @@ -67,12 +67,12 @@ def cmd_curly(msg): return Response("Entêtes de la page %s : HTTP/%s, statut : %d %s ; headers : %s" % (url, version, status, reason, ", ".join(["\x03\x02" + h + "\x03\x02: " + v for h, v in headers])), channel=msg.channel) -@hook.command("curl", +@hook("cmd_hook", "curl", help="Retrieve webpage's body", help_usage={"URL": "Display raw HTTP body of the given URL"}) def cmd_curl(msg): if not len(msg.args): - raise IMException("Indicate the URL to visit.") + raise IRCException("Indicate the URL to visit.") res = Response(channel=msg.channel) for m in page.fetch(" ".join(msg.args)).split("\n"): @@ -80,24 +80,24 @@ def cmd_curl(msg): return res -@hook.command("w3m", +@hook("cmd_hook", "w3m", help="Retrieve and format webpage's content", help_usage={"URL": "Display and format HTTP content of the given URL"}) def cmd_w3m(msg): if not len(msg.args): - raise IMException("Indicate the URL to visit.") + raise IRCException("Indicate the URL to visit.") res = Response(channel=msg.channel) for line in page.render(" ".join(msg.args)).split("\n"): res.append_message(line) return res -@hook.command("traceurl", +@hook("cmd_hook", "traceurl", help="Follow redirections of a given URL and display each step", help_usage={"URL": "Display redirections steps for the given URL"}) def cmd_traceurl(msg): if not len(msg.args): - raise IMException("Indicate an URL to trace!") + raise IRCException("Indicate an URL to trace!") res = list() for url in msg.args[:4]: @@ -109,12 +109,12 @@ def cmd_traceurl(msg): return res -@hook.command("isup", +@hook("cmd_hook", "isup", help="Check if a website is up", help_usage={"DOMAIN": "Check if a DOMAIN is up"}) def cmd_isup(msg): if not len(msg.args): - raise IMException("Indicate an domain name to check!") + raise IRCException("Indicate an domain name to check!") res = list() for url in msg.args[:4]: @@ -126,12 +126,12 @@ def cmd_isup(msg): return res -@hook.command("w3c", +@hook("cmd_hook", "w3c", help="Perform a w3c HTML validator check", help_usage={"URL": "Do W3C HTML validation on the given URL"}) def cmd_w3c(msg): if not len(msg.args): - raise IMException("Indicate an URL to validate!") + raise IRCException("Indicate an URL to validate!") headers, validator = w3c.validator(msg.args[0]) @@ -149,20 +149,20 @@ def cmd_w3c(msg): -@hook.command("watch", data="diff", +@hook("cmd_hook", "watch", data="diff", help="Alert on webpage change", help_usage={"URL": "Watch the given URL and alert when it changes"}) -@hook.command("updown", data="updown", +@hook("cmd_hook", "updown", data="updown", help="Alert on server availability change", help_usage={"URL": "Watch the given domain and alert when it availability status changes"}) def cmd_watch(msg, diffType="diff"): if not len(msg.args): - raise IMException("indicate an URL to watch!") + raise IRCException("indicate an URL to watch!") return watchWebsite.add_site(msg.args[0], msg.frm, msg.channel, msg.server, diffType) -@hook.command("listwatch", +@hook("cmd_hook", "listwatch", help="List URL watched for the channel", help_usage={None: "List URL watched for the channel"}) def cmd_listwatch(msg): @@ -173,12 +173,12 @@ def cmd_listwatch(msg): return Response("No URL are currently watched. Use !watch URL to watch one.", channel=msg.channel) -@hook.command("unwatch", +@hook("cmd_hook", "unwatch", help="Unwatch a previously watched URL", help_usage={"URL": "Unwatch the given URL"}) def cmd_unwatch(msg): if not len(msg.args): - raise IMException("which URL should I stop watching?") + raise IRCException("which URL should I stop watching?") for arg in msg.args: return watchWebsite.del_site(arg, msg.frm, msg.channel, msg.frm_owner) diff --git a/modules/networking/isup.py b/modules/networking/isup.py index 99e2664..c518900 100644 --- a/modules/networking/isup.py +++ b/modules/networking/isup.py @@ -11,7 +11,7 @@ def isup(url): o = urllib.parse.urlparse(getNormalizedURL(url), "http") if o.netloc != "": - isup = getJSON("https://isitup.org/%s.json" % o.netloc) + isup = getJSON("http://isitup.org/%s.json" % o.netloc) if isup is not None and "status_code" in isup and isup["status_code"] == 1: return isup["response_time"] diff --git a/modules/networking/page.py b/modules/networking/page.py index 689944b..6179e34 100644 --- a/modules/networking/page.py +++ b/modules/networking/page.py @@ -5,7 +5,7 @@ import tempfile import urllib from nemubot import __version__ -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.tools import web @@ -23,7 +23,7 @@ def headers(url): o = urllib.parse.urlparse(web.getNormalizedURL(url), "http") if o.netloc == "": - raise IMException("invalid URL") + raise IRCException("invalid URL") if o.scheme == "http": conn = http.client.HTTPConnection(o.hostname, port=o.port, timeout=5) else: @@ -32,18 +32,18 @@ def headers(url): conn.request("HEAD", o.path, None, {"User-agent": "Nemubot v%s" % __version__}) except ConnectionError as e: - raise IMException(e.strerror) + raise IRCException(e.strerror) except socket.timeout: - raise IMException("request timeout") + raise IRCException("request timeout") except socket.gaierror: print (" Unable to receive page %s from %s on %d." % (o.path, o.hostname, o.port if o.port is not None else 0)) - raise IMException("an unexpected error occurs") + raise IRCException("an unexpected error occurs") try: res = conn.getresponse() except http.client.BadStatusLine: - raise IMException("An error occurs") + raise IRCException("An error occurs") finally: conn.close() @@ -51,7 +51,7 @@ def headers(url): def _onNoneDefault(): - raise IMException("An error occurs when trying to access the page") + raise IRCException("An error occurs when trying to access the page") def fetch(url, onNone=_onNoneDefault): @@ -71,11 +71,11 @@ def fetch(url, onNone=_onNoneDefault): else: return None except ConnectionError as e: - raise IMException(e.strerror) + raise IRCException(e.strerror) except socket.timeout: - raise IMException("The request timeout when trying to access the page") + raise IRCException("The request timeout when trying to access the page") except socket.error as e: - raise IMException(e.strerror) + raise IRCException(e.strerror) def _render(cnt): diff --git a/modules/networking/w3c.py b/modules/networking/w3c.py index 3c8084f..3d920ef 100644 --- a/modules/networking/w3c.py +++ b/modules/networking/w3c.py @@ -2,7 +2,7 @@ import json import urllib from nemubot import __version__ -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.tools.web import getNormalizedURL def validator(url): @@ -14,19 +14,19 @@ def validator(url): o = urllib.parse.urlparse(getNormalizedURL(url), "http") if o.netloc == "": - raise IMException("Indicate a valid URL!") + raise IRCException("Indicate a valid URL!") try: - req = urllib.request.Request("https://validator.w3.org/check?uri=%s&output=json" % (urllib.parse.quote(o.geturl())), headers={ 'User-Agent' : "Nemubot v%s" % __version__}) + req = urllib.request.Request("http://validator.w3.org/check?uri=%s&output=json" % (urllib.parse.quote(o.geturl())), headers={ 'User-Agent' : "Nemubot v%s" % __version__}) raw = urllib.request.urlopen(req, timeout=10) except urllib.error.HTTPError as e: - raise IMException("HTTP error occurs: %s %s" % (e.code, e.reason)) + raise IRCException("HTTP error occurs: %s %s" % (e.code, e.reason)) headers = dict() for Hname, Hval in raw.getheaders(): headers[Hname] = Hval if "X-W3C-Validator-Status" not in headers or (headers["X-W3C-Validator-Status"] != "Valid" and headers["X-W3C-Validator-Status"] != "Invalid"): - raise IMException("Unexpected error on W3C servers" + (" (" + headers["X-W3C-Validator-Status"] + ")" if "X-W3C-Validator-Status" in headers else "")) + raise IRCException("Unexpected error on W3C servers" + (" (" + headers["X-W3C-Validator-Status"] + ")" if "X-W3C-Validator-Status" in headers else "")) return headers, json.loads(raw.read().decode()) diff --git a/modules/networking/watchWebsite.py b/modules/networking/watchWebsite.py index d6b806f..41ea7d3 100644 --- a/modules/networking/watchWebsite.py +++ b/modules/networking/watchWebsite.py @@ -1,19 +1,19 @@ """Alert on changes on websites""" -from functools import partial import logging from random import randint import urllib.parse from urllib.parse import urlparse from nemubot.event import ModuleEvent -from nemubot.exception import IMException +from nemubot.exception import IRCException +from nemubot.hooks import hook from nemubot.tools.web import getNormalizedURL from nemubot.tools.xmlparser.node import ModuleState logger = logging.getLogger("nemubot.module.networking.watchWebsite") -from nemubot.module.more import Response +from more import Response from . import page @@ -62,7 +62,7 @@ def del_site(url, nick, channel, frm_owner): for a in site.getNodes("alert"): if a["channel"] == channel: # if not (nick == a["nick"] or frm_owner): -# raise IMException("you cannot unwatch this URL.") +# raise IRCException("you cannot unwatch this URL.") site.delChild(a) if not site.hasNode("alert"): del_event(site["_evt_id"]) @@ -70,7 +70,7 @@ def del_site(url, nick, channel, frm_owner): save() return Response("I don't watch this URL anymore.", channel=channel, nick=nick) - raise IMException("I didn't watch this URL!") + raise IRCException("I didn't watch this URL!") def add_site(url, nick, channel, server, diffType="diff"): @@ -82,7 +82,7 @@ def add_site(url, nick, channel, server, diffType="diff"): o = urlparse(getNormalizedURL(url), "http") if o.netloc == "": - raise IMException("sorry, I can't watch this URL :(") + raise IRCException("sorry, I can't watch this URL :(") alert = ModuleState("alert") alert["nick"] = nick @@ -210,14 +210,15 @@ def start_watching(site, offset=0): offset -- offset time to delay the launch of the first check """ - #o = urlparse(getNormalizedURL(site["url"]), "http") - #print("Add %s event for site: %s" % (site["type"], o.netloc)) + o = urlparse(getNormalizedURL(site["url"]), "http") + #print_debug("Add %s event for site: %s" % (site["type"], o.netloc)) try: - evt = ModuleEvent(func=partial(fwatch, url=site["url"]), - cmp=site["lastcontent"], - offset=offset, interval=site.getInt("time"), - call=partial(alert_change, site=site)) + evt = ModuleEvent(func=fwatch, + cmp_data=site["lastcontent"], + func_data=site["url"], offset=offset, + interval=site.getInt("time"), + call=alert_change, call_data=site) site["_evt_id"] = add_event(evt) - except IMException: + except IRCException: logger.exception("Unable to watch %s", site["url"]) diff --git a/modules/networking/whois.py b/modules/networking/whois.py index 999dc01..469194d 100644 --- a/modules/networking/whois.py +++ b/modules/networking/whois.py @@ -1,51 +1,61 @@ -# PYTHON STUFFS ####################################################### - import datetime import urllib -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.tools.web import getJSON -from nemubot.module.more import Response +from 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 = "https://www.whoisxmlapi.com/whoisserver/WhoisService?da=2&domainName=%%s&outputFormat=json&userName=%s&password=%s" - - -# LOADING ############################################################# +URL_WHOIS = "http://www.whoisxmlapi.com/whoisserver/WhoisService?rid=1&domainName=%%s&outputFormat=json&userName=%s&password=%s" def load(CONF, add_hook): - global URL_AVAIL, URL_WHOIS + global URL_WHOIS - if not CONF or not CONF.hasNode("whoisxmlapi") or "username" not in CONF.getNode("whoisxmlapi") or "password" not in CONF.getNode("whoisxmlapi"): + if not CONF or not CONF.hasNode("whoisxmlapi") or not CONF.getNode("whoisxmlapi").hasAttribute("username") or not CONF.getNode("whoisxmlapi").hasAttribute("password"): raise ImportError("You need a WhoisXML API account in order to use " "the !netwhois feature. Add it to the module " "configuration file:\n\nRegister at " - "https://www.whoisxmlapi.com/newaccount.php") + "http://www.whoisxmlapi.com/newaccount.php") - URL_AVAIL = URL_AVAIL % (urllib.parse.quote(CONF.getNode("whoisxmlapi")["username"]), urllib.parse.quote(CONF.getNode("whoisxmlapi")["password"])) URL_WHOIS = URL_WHOIS % (urllib.parse.quote(CONF.getNode("whoisxmlapi")["username"]), urllib.parse.quote(CONF.getNode("whoisxmlapi")["password"])) import nemubot.hooks - add_hook(nemubot.hooks.Command(cmd_whois, "netwhois", - help="Get whois information about given domains", - help_usage={"DOMAIN": "Return whois information on the given DOMAIN"}), - "in","Command") - add_hook(nemubot.hooks.Command(cmd_avail, "domain_available", - help="Domain availability check using whoisxmlapi.com", - help_usage={"DOMAIN": "Check if the given DOMAIN is available or not"}), - "in","Command") + add_hook("cmd_hook", nemubot.hooks.Message(cmd_whois, "netwhois", + help="Get whois information about given domains", + help_usage={"DOMAIN": "Return whois information on the given DOMAIN"})) -# MODULE CORE ######################################################### +def extractdate(str): + tries = [ + "%Y-%m-%dT%H:%M:%S.0%Z", + "%Y-%m-%dT%H:%M:%S%Z", + "%Y-%m-%dT%H:%M:%S%z", + "%Y-%m-%dT%H:%M:%SZ", + "%Y-%m-%dT%H:%M:%S.0Z", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S.0%Z", + "%Y-%m-%d %H:%M:%S%Z", + "%Y-%m-%d %H:%M:%S%z", + "%Y-%m-%d %H:%M:%S.0Z", + "%Y-%m-%d %H:%M:%SZ", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d", + "%d/%m/%Y", + ] + + for t in tries: + try: + return datetime.datetime.strptime(str, t) + except ValueError: + pass + return datetime.datetime.strptime(str, t) + def whois_entityformat(entity): ret = "" if "organization" in entity: ret += entity["organization"] - if "organization" in entity and "name" in entity: - ret += " " if "name" in entity: ret += entity["name"] @@ -67,70 +77,30 @@ def whois_entityformat(entity): return ret.lstrip() -def available(dom): - js = getJSON(URL_AVAIL % urllib.parse.quote(dom)) - - if "ErrorMessage" in js: - raise IMException(js["ErrorMessage"]["msg"]) - - return js["DomainInfo"]["domainAvailability"] == "AVAILABLE" - - -# MODULE INTERFACE #################################################### - -def cmd_avail(msg): - if not len(msg.args): - raise IMException("Indicate a domain name for having its availability status!") - - return Response(["%s: %s" % (dom, "available" if available(dom) else "unavailable") for dom in msg.args], - channel=msg.channel) - def cmd_whois(msg): if not len(msg.args): - raise IMException("Indiquer un domaine ou une IP à whois !") + raise IRCException("Indiquer un domaine ou une IP à whois !") dom = msg.args[0] js = getJSON(URL_WHOIS % urllib.parse.quote(dom)) if "ErrorMessage" in js: - raise IMException(js["ErrorMessage"]["msg"]) + err = js["ErrorMessage"] + raise IRCException(js["ErrorMessage"]["msg"]) whois = js["WhoisRecord"] - res = [] + res = Response(channel=msg.channel, nomore="No more whois information") - if "registrarName" in whois: - res.append("\x03\x02registered by\x03\x02 " + whois["registrarName"]) - - if "domainAvailability" in whois: - res.append(whois["domainAvailability"]) - - if "contactEmail" in whois: - res.append("\x03\x02contact email\x03\x02 " + whois["contactEmail"]) - - if "audit" in whois: - if "createdDate" in whois["audit"] and "$" in whois["audit"]["createdDate"]: - res.append("\x03\x02created on\x03\x02 " + whois["audit"]["createdDate"]["$"]) - if "updatedDate" in whois["audit"] and "$" in whois["audit"]["updatedDate"]: - res.append("\x03\x02updated on\x03\x02 " + whois["audit"]["updatedDate"]["$"]) - - if "registryData" in whois: - if "expiresDateNormalized" in whois["registryData"]: - res.append("\x03\x02expire on\x03\x02 " + whois["registryData"]["expiresDateNormalized"]) - if "registrant" in whois["registryData"]: - res.append("\x03\x02registrant:\x03\x02 " + whois_entityformat(whois["registryData"]["registrant"])) - if "zoneContact" in whois["registryData"]: - res.append("\x03\x02zone contact:\x03\x02 " + whois_entityformat(whois["registryData"]["zoneContact"])) - if "technicalContact" in whois["registryData"]: - res.append("\x03\x02technical contact:\x03\x02 " + whois_entityformat(whois["registryData"]["technicalContact"])) - if "administrativeContact" in whois["registryData"]: - res.append("\x03\x02administrative contact:\x03\x02 " + whois_entityformat(whois["registryData"]["administrativeContact"])) - if "billingContact" in whois["registryData"]: - res.append("\x03\x02billing contact:\x03\x02 " + whois_entityformat(whois["registryData"]["billingContact"])) - - return Response(res, - title=whois["domainName"], - channel=msg.channel, - nomore="No more whois information") + res.append_message("%s: %s%s%s%s\x03\x02registered by\x03\x02 %s, \x03\x02administrated by\x03\x02 %s, \x03\x02managed by\x03\x02 %s" % (whois["domainName"], + whois["status"].replace("\n", ", ") + " " if "status" in whois else "", + "\x03\x02created on\x03\x02 " + extractdate(whois["createdDate"]).strftime("%c") + ", " if "createdDate" in whois else "", + "\x03\x02updated on\x03\x02 " + extractdate(whois["updatedDate"]).strftime("%c") + ", " if "updatedDate" in whois else "", + "\x03\x02expires on\x03\x02 " + extractdate(whois["expiresDate"]).strftime("%c") + ", " if "expiresDate" in whois else "", + whois_entityformat(whois["registrant"]) if "registrant" in whois else "unknown", + whois_entityformat(whois["administrativeContact"]) if "administrativeContact" in whois else "unknown", + whois_entityformat(whois["technicalContact"]) if "technicalContact" in whois else "unknown", + )) + return res diff --git a/modules/news.py b/modules/news.py index c4c967a..7aa323f 100644 --- a/modules/news.py +++ b/modules/news.py @@ -8,12 +8,11 @@ from urllib.parse import urljoin from bs4 import BeautifulSoup -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web -from nemubot.module.more import Response -from nemubot.module.urlreducer import reduce_inline +from more import Response from nemubot.tools.feed import Feed, AtomEntry @@ -42,20 +41,19 @@ def get_last_news(url): # MODULE INTERFACE #################################################### -@hook.command("news") +@hook("cmd_hook", "news") def cmd_news(msg): if not len(msg.args): - raise IMException("Indicate the URL to visit.") + raise IRCException("Indicate the URL to visit.") url = " ".join(msg.args) links = [x for x in find_rss_links(url)] if len(links) == 0: links = [ url ] - res = Response(channel=msg.channel, nomore="No more news from %s" % url, line_treat=reduce_inline) + res = Response(channel=msg.channel, nomore="No more news from %s" % url) for n in get_last_news(links[0]): res.append_message("%s published %s: %s %s" % (("\x02" + web.striphtml(n.title) + "\x0F") if n.title else "An article without title", (n.updated.strftime("on %A %d. %B %Y at %H:%M") if n.updated else "someday") if isinstance(n, AtomEntry) else n.pubDate, web.striphtml(n.summary) if n.summary else "", n.link if n.link else "")) - return res diff --git a/modules/nextstop.xml b/modules/nextstop.xml new file mode 100644 index 0000000..d34e8ae --- /dev/null +++ b/modules/nextstop.xml @@ -0,0 +1,4 @@ + + + + diff --git a/modules/nextstop/__init__.py b/modules/nextstop/__init__.py new file mode 100644 index 0000000..65095b2 --- /dev/null +++ b/modules/nextstop/__init__.py @@ -0,0 +1,55 @@ +# coding=utf-8 + +"""Informe les usagers des prochains passages des transports en communs de la RATP""" + +from nemubot.exception import IRCException +from nemubot.hooks import hook +from more import Response + +nemubotversion = 3.4 + +from .external.src import ratp + +def help_full (): + return "!ratp transport line [station]: Donne des informations sur les prochains passages du transport en commun séléctionné à l'arrêt désiré. Si aucune station n'est précisée, les liste toutes." + + +@hook("cmd_hook", "ratp") +def ask_ratp(msg): + """Hook entry from !ratp""" + if len(msg.args) >= 3: + transport = msg.args[0] + line = msg.args[1] + station = msg.args[2] + if len(msg.args) == 4: + times = ratp.getNextStopsAtStation(transport, line, station, msg.args[3]) + else: + times = ratp.getNextStopsAtStation(transport, line, station) + + if len(times) == 0: + raise IRCException("la station %s n'existe pas sur le %s ligne %s." % (station, transport, line)) + + (time, direction, stationname) = times[0] + return Response(message=["\x03\x02%s\x03\x02 direction %s" % (time, direction) for time, direction, stationname in times], + title="Prochains passages du %s ligne %s à l'arrêt %s" % (transport, line, stationname), + channel=msg.channel) + + elif len(msg.args) == 2: + stations = ratp.getAllStations(msg.args[0], msg.args[1]) + + if len(stations) == 0: + raise IRCException("aucune station trouvée.") + return Response([s for s in stations], title="Stations", channel=msg.channel) + + else: + raise IRCException("Mauvais usage, merci de spécifier un type de transport et une ligne, ou de consulter l'aide du module.") + +@hook("cmd_hook", "ratp_alert") +def ratp_alert(msg): + if len(msg.args) == 2: + transport = msg.args[0] + cause = msg.args[1] + incidents = ratp.getDisturbance(cause, transport) + return Response(incidents, channel=msg.channel, nomore="No more incidents", count=" (%d more incidents)") + else: + raise IRCException("Mauvais usage, merci de spécifier un type de transport et un type d'alerte (alerte, manif, travaux), ou de consulter l'aide du module.") diff --git a/modules/nextstop/external b/modules/nextstop/external new file mode 160000 index 0000000..3d5c9b2 --- /dev/null +++ b/modules/nextstop/external @@ -0,0 +1 @@ +Subproject commit 3d5c9b2d52fbd214f5aaad00e5f3952de919b3e5 diff --git a/modules/nntp.py b/modules/nntp.py deleted file mode 100644 index 7fdceb4..0000000 --- a/modules/nntp.py +++ /dev/null @@ -1,229 +0,0 @@ -"""The NNTP module""" - -# PYTHON STUFFS ####################################################### - -import email -import email.policy -from email.utils import mktime_tz, parseaddr, parsedate_tz -from functools import partial -from nntplib import NNTP, decode_header -import re -import time -from datetime import datetime -from zlib import adler32 - -from nemubot import context -from nemubot.event import ModuleEvent -from nemubot.exception import IMException -from nemubot.hooks import hook -from nemubot.tools.xmlparser.node import ModuleState - -from nemubot.module.more import Response - - -# LOADING ############################################################# - -def load(context): - for wn in context.data.getNodes("watched_newsgroup"): - watch(**wn.attributes) - - -# MODULE CORE ######################################################### - -def list_groups(group_pattern="*", **server): - with NNTP(**server) as srv: - response, l = srv.list(group_pattern) - for i in l: - yield i.group, srv.description(i.group), i.flag - -def read_group(group, **server): - with NNTP(**server) as srv: - response, count, first, last, name = srv.group(group) - resp, overviews = srv.over((first, last)) - for art_num, over in reversed(overviews): - yield over - -def read_article(msg_id, **server): - with NNTP(**server) as srv: - response, info = srv.article(msg_id) - return email.message_from_bytes(b"\r\n".join(info.lines), policy=email.policy.SMTPUTF8) - - -servers_lastcheck = dict() -servers_lastseen = dict() - -def whatsnew(group="*", **server): - fill = dict() - if "user" in server: fill["user"] = server["user"] - if "password" in server: fill["password"] = server["password"] - if "host" in server: fill["host"] = server["host"] - if "port" in server: fill["port"] = server["port"] - - idx = _indexServer(**server) - if idx in servers_lastcheck and servers_lastcheck[idx] is not None: - date_last_check = servers_lastcheck[idx] - else: - date_last_check = datetime.now() - - if idx not in servers_lastseen: - servers_lastseen[idx] = [] - - with NNTP(**fill) as srv: - response, servers_lastcheck[idx] = srv.date() - - response, groups = srv.newgroups(date_last_check) - for g in groups: - yield g - - response, articles = srv.newnews(group, date_last_check) - for msg_id in articles: - if msg_id not in servers_lastseen[idx]: - servers_lastseen[idx].append(msg_id) - response, info = srv.article(msg_id) - yield email.message_from_bytes(b"\r\n".join(info.lines)) - - # Clean huge lists - if len(servers_lastseen[idx]) > 42: - servers_lastseen[idx] = servers_lastseen[idx][23:] - - -def format_article(art, **response_args): - art["X-FromName"], art["X-FromEmail"] = parseaddr(art["From"] if "From" in art else "") - if art["X-FromName"] == '': art["X-FromName"] = art["X-FromEmail"] - - date = mktime_tz(parsedate_tz(art["Date"])) - if date < time.time() - 120: - title = "\x0314In \x0F\x03{0:02d}{Newsgroups}\x0F\x0314: on \x0F{Date}\x0314 by \x0F\x03{0:02d}{X-FromName}\x0F \x02{Subject}\x0F" - else: - title = "\x0314In \x0F\x03{0:02d}{Newsgroups}\x0F\x0314: by \x0F\x03{0:02d}{X-FromName}\x0F \x02{Subject}\x0F" - - return Response(art.get_payload().replace('\n', ' '), - title=title.format(adler32(art["Newsgroups"].encode()) & 0xf, adler32(art["X-FromEmail"].encode()) & 0xf, **{h: decode_header(i) for h,i in art.items()}), - **response_args) - - -watches = dict() - -def _indexServer(**kwargs): - if "user" not in kwargs: kwargs["user"] = "" - if "password" not in kwargs: kwargs["password"] = "" - if "host" not in kwargs: kwargs["host"] = "" - if "port" not in kwargs: kwargs["port"] = 119 - return "{user}:{password}@{host}:{port}".format(**kwargs) - -def _newevt(**args): - context.add_event(ModuleEvent(call=partial(_ticker, **args), interval=42)) - -def _ticker(to_server, to_channel, group, server): - _newevt(to_server=to_server, to_channel=to_channel, group=group, server=server) - n = 0 - for art in whatsnew(group, **server): - n += 1 - if n > 10: - continue - context.send_response(to_server, format_article(art, channel=to_channel)) - if n > 10: - context.send_response(to_server, Response("... and %s others news" % (n - 10), channel=to_channel)) - -def watch(to_server, to_channel, group="*", **server): - _newevt(to_server=to_server, to_channel=to_channel, group=group, server=server) - - -# MODULE INTERFACE #################################################### - -keywords_server = { - "host=HOST": "hostname or IP of the NNTP server", - "port=PORT": "port of the NNTP server", - "user=USERNAME": "username to use to connect to the server", - "password=PASSWORD": "password to use to connect to the server", -} - -@hook.command("nntp_groups", - help="Show list of existing groups", - help_usage={ - None: "Display all groups", - "PATTERN": "Filter on group matching the PATTERN" - }, - keywords=keywords_server) -def cmd_groups(msg): - if "host" not in msg.kwargs: - raise IMException("please give a hostname in keywords") - - return Response(["\x02\x03{0:02d}{1}\x0F: {2}".format(adler32(g[0].encode()) & 0xf, *g) for g in list_groups(msg.args[0] if len(msg.args) > 0 else "*", **msg.kwargs)], - channel=msg.channel, - title="Matching groups on %s" % msg.kwargs["host"]) - - -@hook.command("nntp_overview", - help="Show an overview of articles in given group(s)", - help_usage={ - "GROUP": "Filter on group matching the PATTERN" - }, - keywords=keywords_server) -def cmd_overview(msg): - if "host" not in msg.kwargs: - raise IMException("please give a hostname in keywords") - - if not len(msg.args): - raise IMException("which group would you overview?") - - for g in msg.args: - arts = [] - for grp in read_group(g, **msg.kwargs): - grp["X-FromName"], grp["X-FromEmail"] = parseaddr(grp["from"] if "from" in grp else "") - if grp["X-FromName"] == '': grp["X-FromName"] = grp["X-FromEmail"] - - arts.append("On {date}, from \x03{0:02d}{X-FromName}\x0F \x02{subject}\x0F: \x0314{message-id}\x0F".format(adler32(grp["X-FromEmail"].encode()) & 0xf, **{h: decode_header(i) for h,i in grp.items()})) - - if len(arts): - yield Response(arts, - channel=msg.channel, - title="In \x03{0:02d}{1}\x0F".format(adler32(g[0].encode()) & 0xf, g)) - - -@hook.command("nntp_read", - help="Read an article from a server", - help_usage={ - "MSG_ID": "Read the given message" - }, - keywords=keywords_server) -def cmd_read(msg): - if "host" not in msg.kwargs: - raise IMException("please give a hostname in keywords") - - for msgid in msg.args: - if not re.match("<.*>", msgid): - msgid = "<" + msgid + ">" - art = read_article(msgid, **msg.kwargs) - yield format_article(art, channel=msg.channel) - - -@hook.command("nntp_watch", - help="Launch an event looking for new groups and articles on a server", - help_usage={ - None: "Watch all groups", - "PATTERN": "Limit the watch on group matching this PATTERN" - }, - keywords=keywords_server) -def cmd_watch(msg): - if "host" not in msg.kwargs: - raise IMException("please give a hostname in keywords") - - if not msg.frm_owner: - raise IMException("sorry, this command is currently limited to the owner") - - wnnode = ModuleState("watched_newsgroup") - wnnode["id"] = _indexServer(**msg.kwargs) - wnnode["to_server"] = msg.server - wnnode["to_channel"] = msg.channel - wnnode["group"] = msg.args[0] if len(msg.args) > 0 else "*" - - wnnode["user"] = msg.kwargs["user"] if "user" in msg.kwargs else "" - wnnode["password"] = msg.kwargs["password"] if "password" in msg.kwargs else "" - wnnode["host"] = msg.kwargs["host"] if "host" in msg.kwargs else "" - wnnode["port"] = msg.kwargs["port"] if "port" in msg.kwargs else 119 - - context.data.addChild(wnnode) - watch(**wnnode.attributes) - - return Response("Ok ok, I watch this newsgroup!", channel=msg.channel) diff --git a/modules/openai.py b/modules/openai.py deleted file mode 100644 index b9b6e21..0000000 --- a/modules/openai.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Perform requests to openai""" - -# PYTHON STUFFS ####################################################### - -from openai import OpenAI - -from nemubot import context -from nemubot.hooks import hook -from nemubot.tools import web - -from nemubot.module.more import Response - - -# LOADING ############################################################# - -CLIENT = None -MODEL = "gpt-4" -ENDPOINT = None - -def load(context): - global CLIENT, ENDPOINT, MODEL - if not context.config or ("apikey" not in context.config and "endpoint" not in context.config): - raise ImportError ("You need a OpenAI API key in order to use " - "this module. Add it to the module configuration: " - "\n") - kwargs = { - "api_key": context.config["apikey"] or "", - } - - if "endpoint" in context.config: - ENDPOINT = context.config["endpoint"] - kwargs["base_url"] = ENDPOINT - - CLIENT = OpenAI(**kwargs) - - if "model" in context.config: - MODEL = context.config["model"] - - -# MODULE INTERFACE #################################################### - -@hook.command("list_models", - help="list available LLM") -def cmd_listllm(msg): - llms = web.getJSON(ENDPOINT + "/models", timeout=6) - return Response(message=[m for m in map(lambda i: i["id"], llms["data"])], title="Here is the available models", channel=msg.channel) - - -@hook.command("set_model", - help="Set the model to use when talking to nemubot") -def cmd_setllm(msg): - if len(msg.args) != 1: - raise IMException("Indicate 1 model to use") - - wanted_model = msg.args[0] - - llms = web.getJSON(ENDPOINT + "/models", timeout=6) - for model in llms["data"]: - if wanted_model == model["id"]: - break - else: - raise IMException("Unable to set such model: unknown") - - MODEL = wanted_model - return Response("New model in use: " + wanted_model, channel=msg.channel) - - -@hook.ask() -def parseask(msg): - chat_completion = CLIENT.chat.completions.create( - messages=[ - { - "role": "system", - "content": "You are a kind multilingual assistant. Respond to the user request in 255 characters maximum. Be conscise, go directly to the point. Never add useless terms.", - }, - { - "role": "user", - "content": msg.message, - } - ], - model=MODEL, - ) - - return Response(chat_completion.choices[0].message.content, - msg.channel, - msg.frm) diff --git a/modules/openroute.py b/modules/openroute.py deleted file mode 100644 index c280dec..0000000 --- a/modules/openroute.py +++ /dev/null @@ -1,158 +0,0 @@ -"""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 nemubot.module.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 diff --git a/modules/pkgs.py b/modules/pkgs.py deleted file mode 100644 index 386946f..0000000 --- a/modules/pkgs.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Get information about common software""" - -# PYTHON STUFFS ####################################################### - -import portage - -from nemubot import context -from nemubot.exception import IMException -from nemubot.hooks import hook - -from nemubot.module.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 diff --git a/modules/ratp.py b/modules/ratp.py deleted file mode 100644 index 06f5f1d..0000000 --- a/modules/ratp.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Informe les usagers des prochains passages des transports en communs de la RATP""" - -# PYTHON STUFFS ####################################################### - -from nemubot.exception import IMException -from nemubot.hooks import hook -from nemubot.module.more import Response - -from nextstop import ratp - -@hook.command("ratp", - help="Affiche les prochains horaires de passage", - help_usage={ - "TRANSPORT": "Affiche les lignes du moyen de transport donné", - "TRANSPORT LINE": "Affiche les stations sur la ligne de transport donnée", - "TRANSPORT LINE STATION": "Affiche les prochains horaires de passage à l'arrêt donné", - "TRANSPORT LINE STATION DESTINATION": "Affiche les prochains horaires de passage dans la direction donnée", - }) -def ask_ratp(msg): - l = len(msg.args) - - transport = msg.args[0] if l > 0 else None - line = msg.args[1] if l > 1 else None - station = msg.args[2] if l > 2 else None - direction = msg.args[3] if l > 3 else None - - if station is not None: - times = sorted(ratp.getNextStopsAtStation(transport, line, station, direction), key=lambda i: i[0]) - - if len(times) == 0: - raise IMException("la station %s n'existe pas sur le %s ligne %s." % (station, transport, line)) - - (time, direction, stationname) = times[0] - return Response(message=["\x03\x02%s\x03\x02 direction %s" % (time, direction) for time, direction, stationname in times], - title="Prochains passages du %s ligne %s à l'arrêt %s" % (transport, line, stationname), - channel=msg.channel) - - elif line is not None: - stations = ratp.getAllStations(transport, line) - - if len(stations) == 0: - raise IMException("aucune station trouvée.") - return Response(stations, title="Stations", channel=msg.channel) - - elif transport is not None: - lines = ratp.getTransportLines(transport) - if len(lines) == 0: - raise IMException("aucune ligne trouvée.") - return Response(lines, title="Lignes", channel=msg.channel) - - else: - raise IMException("précise au moins un moyen de transport.") - - -@hook.command("ratp_alert", - help="Affiche les perturbations en cours sur le réseau") -def ratp_alert(msg): - if len(msg.args) == 0: - raise IMException("précise au moins un moyen de transport.") - - l = len(msg.args) - transport = msg.args[0] if l > 0 else None - line = msg.args[1] if l > 1 else None - - if line is not None: - d = ratp.getDisturbanceFromLine(transport, line) - if "date" in d and d["date"] is not None: - incidents = "Au {date[date]}, {title}: {message}".format(**d) - else: - incidents = "{title}: {message}".format(**d) - else: - incidents = ratp.getDisturbance(None, transport) - - return Response(incidents, channel=msg.channel, nomore="No more incidents", count=" (%d more incidents)") diff --git a/modules/reddit.py b/modules/reddit.py index d4def85..4c376d3 100644 --- a/modules/reddit.py +++ b/modules/reddit.py @@ -4,13 +4,13 @@ import re -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web nemubotversion = 3.4 -from nemubot.module.more import Response +from more import Response def help_full(): @@ -19,14 +19,14 @@ def help_full(): LAST_SUBS = dict() -@hook.command("subreddit") +@hook("cmd_hook", "subreddit") def cmd_subreddit(msg): global LAST_SUBS if not len(msg.args): if msg.channel in LAST_SUBS and len(LAST_SUBS[msg.channel]) > 0: subs = [LAST_SUBS[msg.channel].pop()] else: - raise IMException("Which subreddit? Need inspiration? " + raise IRCException("Which subreddit? Need inspiration? " "type !horny or !bored") else: subs = msg.args @@ -40,11 +40,11 @@ def cmd_subreddit(msg): else: where = "r" - sbr = web.getJSON("https://www.reddit.com/%s/%s/about.json" % + sbr = web.getJSON("http://www.reddit.com/%s/%s/about.json" % (where, sub.group(2))) if sbr is None: - raise IMException("subreddit not found") + raise IRCException("subreddit not found") if "title" in sbr["data"]: res = Response(channel=msg.channel, @@ -64,32 +64,25 @@ def cmd_subreddit(msg): channel=msg.channel)) else: all_res.append(Response("%s is not a valid subreddit" % osub, - channel=msg.channel, nick=msg.frm)) + channel=msg.channel, nick=msg.nick)) return all_res -@hook.message() +@hook("msg_default") def parselisten(msg): - 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) + parseresponse(msg) + return None -@hook.post() +@hook("all_post") def parseresponse(msg): global LAST_SUBS - if hasattr(msg, "text") and msg.text and type(msg.text) == str: + if hasattr(msg, "text") and msg.text: urls = re.findall("www.reddit.com(/\w/\w+/?)", msg.text) for url in urls: - for recv in msg.to: + for recv in msg.receivers: if recv not in LAST_SUBS: LAST_SUBS[recv] = list() LAST_SUBS[recv].append(url) diff --git a/modules/repology.py b/modules/repology.py deleted file mode 100644 index 8dbc6da..0000000 --- a/modules/repology.py +++ /dev/null @@ -1,94 +0,0 @@ -# coding=utf-8 - -"""Repology.org module: the packaging hub""" - -import datetime -import re - -from nemubot import context -from nemubot.exception import IMException -from nemubot.hooks import hook -from nemubot.tools import web -from nemubot.tools.xmlparser.node import ModuleState - -nemubotversion = 4.0 - -from nemubot.module.more import Response - -URL_REPOAPI = "https://repology.org/api/v1/project/%s" - -def get_json_project(project): - prj = web.getJSON(URL_REPOAPI % (project)) - - return prj - - -@hook.command("repology", - help="Display version information about a package", - help_usage={ - "PACKAGE_NAME": "Retrieve informations about PACKAGE_NAME", - }, - keywords={ - "distro=DISTRO": "filter by disto", - "status=STATUS[,STATUS...]": "filter by status", - }) -def cmd_repology(msg): - if len(msg.args) == 0: - raise IMException("Please provide at least a package name") - - res = Response(channel=msg.channel, nomore="No more information on package") - - for project in msg.args: - prj = get_json_project(project) - if len(prj) == 0: - raise IMException("Unable to find package " + project) - - pkg_versions = {} - pkg_maintainers = {} - pkg_licenses = {} - summary = None - - for repo in prj: - # Apply filters - if "distro" in msg.kwargs and repo["repo"].find(msg.kwargs["distro"]) < 0: - continue - if "status" in msg.kwargs and repo["status"] not in msg.kwargs["status"].split(","): - continue - - name = repo["visiblename"] if "visiblename" in repo else repo["name"] - status = repo["status"] if "status" in repo else "unknown" - if name not in pkg_versions: - pkg_versions[name] = {} - if status not in pkg_versions[name]: - pkg_versions[name][status] = [] - if repo["version"] not in pkg_versions[name][status]: - pkg_versions[name][status].append(repo["version"]) - - if "maintainers" in repo: - if name not in pkg_maintainers: - pkg_maintainers[name] = [] - for maintainer in repo["maintainers"]: - if maintainer not in pkg_maintainers[name]: - pkg_maintainers[name].append(maintainer) - - if "licenses" in repo: - if name not in pkg_licenses: - pkg_licenses[name] = [] - for lic in repo["licenses"]: - if lic not in pkg_licenses[name]: - pkg_licenses[name].append(lic) - - if "summary" in repo and summary is None: - summary = repo["summary"] - - for pkgname in sorted(pkg_versions.keys()): - m = "Package " + pkgname + " (" + summary + ")" - if pkgname in pkg_licenses: - m += " under " + ", ".join(pkg_licenses[pkgname]) - m += ": " + " - ".join([status + ": " + ", ".join(pkg_versions[pkgname][status]) for status in ["newest", "devel", "unique", "outdated", "legacy", "rolling", "noscheme", "untrusted", "ignored"] if status in pkg_versions[pkgname]]) - if "distro" in msg.kwargs and pkgname in pkg_maintainers: - m += " - Maintained by " + ", ".join(pkg_maintainers[pkgname]) - - res.append_message(m) - - return res diff --git a/modules/rnd.py b/modules/rnd.py index d1c6fe7..84c5693 100644 --- a/modules/rnd.py +++ b/modules/rnd.py @@ -6,49 +6,34 @@ import random import shlex from nemubot import context -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook +from nemubot.message import Command -from nemubot.module.more import Response +from more import Response # MODULE INTERFACE #################################################### -@hook.command("choice") +@hook("cmd_hook", "choice") def cmd_choice(msg): if not len(msg.args): - raise IMException("indicate some terms to pick!") + raise IRCException("indicate some terms to pick!") return Response(random.choice(msg.args), channel=msg.channel, - nick=msg.frm) + nick=msg.nick) -@hook.command("choicecmd") -def cmd_choicecmd(msg): +@hook("cmd_hook", "choicecmd") +def cmd_choice(msg): if not len(msg.args): - raise IMException("indicate some command to pick!") + raise IRCException("indicate some command to pick!") choice = shlex.split(random.choice(msg.args)) - return [x for x in context.subtreat(context.subparse(msg, choice))] - - -@hook.command("choiceres") -def cmd_choiceres(msg): - if not len(msg.args): - raise IMException("indicate some command to pick a message from!") - - rl = [x for x in context.subtreat(context.subparse(msg, " ".join(msg.args)))] - if len(rl) <= 0: - return rl - - r = random.choice(rl) - - if isinstance(r, Response): - for i in range(len(r.messages) - 1, -1, -1): - if isinstance(r.messages[i], list): - r.messages = [ random.choice(random.choice(r.messages)) ] - elif isinstance(r.messages[i], str): - r.messages = [ random.choice(r.messages) ] - return r + return [x for x in context.subtreat(Command(choice[0][1:], + choice[1:], + to_response=msg.to_response, + frm=msg.frm, + server=msg.server))] diff --git a/modules/sap.py b/modules/sap.py index 0b9017f..19b0f67 100644 --- a/modules/sap.py +++ b/modules/sap.py @@ -2,30 +2,32 @@ """Find information about an SAP transaction codes""" +import re import urllib.parse import urllib.request from bs4 import BeautifulSoup -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web +from nemubot.tools.web import striphtml nemubotversion = 4.0 -from nemubot.module.more import Response +from more import Response def help_full(): return "Retrieve SAP transaction codes and details using tcodes or keywords: !tcode " -@hook.command("tcode") +@hook("cmd_hook", "tcode") def cmd_tcode(msg): if not len(msg.args): - raise IMException("indicate a transaction code or " + raise IRCException("indicate a transaction code or " "a keyword to search!") - url = ("https://www.tcodesearch.com/tcodes/search?q=%s" % + url = ("http://www.tcodesearch.com/tcodes/search?q=%s" % urllib.parse.quote(msg.args[0])) page = web.getURLContent(url) diff --git a/modules/shodan.py b/modules/shodan.py deleted file mode 100644 index 9c158c6..0000000 --- a/modules/shodan.py +++ /dev/null @@ -1,104 +0,0 @@ -"""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 nemubot.module.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 diff --git a/modules/sleepytime.py b/modules/sleepytime.py index f7fb626..aef2db3 100644 --- a/modules/sleepytime.py +++ b/modules/sleepytime.py @@ -10,7 +10,7 @@ from nemubot.hooks import hook nemubotversion = 3.4 -from nemubot.module.more import Response +from more import Response def help_full(): @@ -19,7 +19,7 @@ def help_full(): " hh:mm") -@hook.command("sleepytime") +@hook("cmd_hook", "sleepytime") def cmd_sleep(msg): if len(msg.args) and re.match("[0-9]{1,2}[h':.,-]([0-9]{1,2})?[m'\":.,-]?", msg.args[0]) is not None: diff --git a/modules/smmry.py b/modules/smmry.py deleted file mode 100644 index b1fe72c..0000000 --- a/modules/smmry.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Summarize texts""" - -# PYTHON STUFFS ####################################################### - -from urllib.parse import quote - -from nemubot import context -from nemubot.exception import IMException -from nemubot.hooks import hook -from nemubot.tools import web - -from nemubot.module.more import Response -from nemubot.module.urlreducer import LAST_URLS - - -# GLOBALS ############################################################# - -URL_API = "https://api.smmry.com/?SM_API_KEY=%s" - - -# LOADING ############################################################# - -def load(context): - if not context.config or "apikey" not in context.config: - raise ImportError("You need a Smmry API key in order to use this " - "module. Add it to the module configuration file:\n" - "\nRegister at https://smmry.com/partner") - global URL_API - URL_API = URL_API % context.config["apikey"] - - -# MODULE INTERFACE #################################################### - -@hook.command("smmry", - help="Summarize the following words/command return", - help_usage={ - "WORDS/CMD": "" - }, - keywords={ - "keywords?=X": "Returns keywords instead of summary (count optional)", - "length=7": "The number of sentences returned, default 7", - "break": "inserts the string [BREAK] between sentences", - "ignore_length": "returns summary regardless of quality or length", - "quote_avoid": "sentences with quotations will be excluded", - "question_avoid": "sentences with question will be excluded", - "exclamation_avoid": "sentences with exclamation marks will be excluded", - }) -def cmd_smmry(msg): - if not len(msg.args): - global LAST_URLS - if msg.channel in LAST_URLS and len(LAST_URLS[msg.channel]) > 0: - msg.args.append(LAST_URLS[msg.channel].pop()) - else: - raise IMException("I have no more URL to sum up.") - - URL = URL_API - if "length" in msg.kwargs: - if int(msg.kwargs["length"]) > 0 : - URL += "&SM_LENGTH=" + msg.kwargs["length"] - else: - msg.kwargs["ignore_length"] = True - if "break" in msg.kwargs: URL += "&SM_WITH_BREAK" - if "ignore_length" in msg.kwargs: URL += "&SM_IGNORE_LENGTH" - if "quote_avoid" in msg.kwargs: URL += "&SM_QUOTE_AVOID" - if "question_avoid" in msg.kwargs: URL += "&SM_QUESTION_AVOID" - if "exclamation_avoid" in msg.kwargs: URL += "&SM_EXCLAMATION_AVOID" - if "keywords" in msg.kwargs and msg.kwargs["keywords"] is not None and int(msg.kwargs["keywords"]) > 0: URL += "&SM_KEYWORD_COUNT=" + msg.kwargs["keywords"] - - res = Response(channel=msg.channel) - - if web.isURL(" ".join(msg.args)): - smmry = web.getJSON(URL + "&SM_URL=" + quote(" ".join(msg.args)), timeout=23) - else: - cnt = "" - for r in context.subtreat(context.subparse(msg, " ".join(msg.args))): - if isinstance(r, Response): - for i in range(len(r.messages) - 1, -1, -1): - if isinstance(r.messages[i], list): - for j in range(len(r.messages[i]) - 1, -1, -1): - cnt += r.messages[i][j] + "\n" - elif isinstance(r.messages[i], str): - cnt += r.messages[i] + "\n" - else: - cnt += str(r.messages) + "\n" - - elif isinstance(r, Text): - cnt += r.message + "\n" - - else: - cnt += str(r) + "\n" - - smmry = web.getJSON(URL, body="sm_api_input=" + quote(cnt), timeout=23) - - if "sm_api_error" in smmry: - if smmry["sm_api_error"] == 0: - title = "Internal server problem (not your fault)" - elif smmry["sm_api_error"] == 1: - title = "Incorrect submission variables" - elif smmry["sm_api_error"] == 2: - title = "Intentional restriction (low credits?)" - elif smmry["sm_api_error"] == 3: - title = "Summarization error" - else: - title = "Unknown error" - raise IMException(title + ": " + smmry['sm_api_message'].lower()) - - if "keywords" in msg.kwargs: - smmry["sm_api_content"] = ", ".join(smmry["sm_api_keyword_array"]) - - if "sm_api_title" in smmry and smmry["sm_api_title"] != "": - res.append_message(smmry["sm_api_content"], title=smmry["sm_api_title"]) - else: - res.append_message(smmry["sm_api_content"]) - - return res diff --git a/modules/sms.py b/modules/sms.py index 57ab3ae..91a8623 100644 --- a/modules/sms.py +++ b/modules/sms.py @@ -10,13 +10,13 @@ import urllib.request import urllib.parse from nemubot import context -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools.xmlparser.node import ModuleState nemubotversion = 3.4 -from nemubot.module.more import Response +from more import Response def load(context): context.data.setIndex("name", "phone") @@ -46,89 +46,47 @@ def send_sms(frm, api_usr, api_key, content): return None -def check_sms_dests(dests, cur_epoch): - """Raise exception if one of the dest is not known or has already receive a SMS recently - """ - for u in dests: + +@hook("cmd_hook", "sms") +def cmd_sms(msg): + if not len(msg.args): + raise IRCException("À qui veux-tu envoyer ce SMS ?") + + # Check dests + cur_epoch = time.mktime(time.localtime()); + for u in msg.args[0].split(","): if u not in context.data.index: - raise IMException("Désolé, je sais pas comment envoyer de SMS à %s." % u) + raise IRCException("Désolé, je sais pas comment envoyer de SMS à %s." % u) elif cur_epoch - float(context.data.index[u]["lastuse"]) < 42: - raise IMException("Un peu de calme, %s a déjà reçu un SMS il n'y a pas si longtemps." % u) - return True + raise IRCException("Un peu de calme, %s a déjà reçu un SMS il n'y a pas si longtemps." % u) - -def send_sms_to_list(msg, frm, dests, content, cur_epoch): + # Go! fails = list() - for u in dests: + for u in msg.args[0].split(","): context.data.index[u]["lastuse"] = cur_epoch - test = send_sms(frm, context.data.index[u]["user"], context.data.index[u]["key"], content) + if msg.to_response[0] == msg.frm: + frm = msg.frm + else: + frm = msg.frm + "@" + msg.to[0] + test = send_sms(frm, context.data.index[u]["user"], context.data.index[u]["key"], " ".join(msg.args[1:])) if test is not None: 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.frm) + return Response("quelque chose ne s'est pas bien passé durant l'envoi du SMS : " + ", ".join(fails), msg.channel, msg.nick) else: - return Response("le SMS a bien été envoyé", msg.channel, msg.frm) - - -@hook.command("sms") -def cmd_sms(msg): - if not len(msg.args): - raise IMException("À qui veux-tu envoyer ce SMS ?") - - cur_epoch = time.mktime(time.localtime()) - dests = msg.args[0].split(",") - frm = msg.frm if msg.to_response[0] == msg.frm else msg.frm + "@" + msg.to[0] - content = " ".join(msg.args[1:]) - - check_sms_dests(dests, cur_epoch) - return send_sms_to_list(msg, frm, dests, content, cur_epoch) - - -@hook.command("smscmd") -def cmd_smscmd(msg): - if not len(msg.args): - raise IMException("À qui veux-tu envoyer ce SMS ?") - - cur_epoch = time.mktime(time.localtime()) - dests = msg.args[0].split(",") - frm = msg.frm if msg.to_response[0] == msg.frm else msg.frm + "@" + msg.to[0] - cmd = " ".join(msg.args[1:]) - - content = None - for r in context.subtreat(context.subparse(msg, cmd)): - if isinstance(r, Response): - for m in r.messages: - if isinstance(m, list): - for n in m: - content = n - break - if content is not None: - break - elif isinstance(m, str): - content = m - break - - elif isinstance(r, Text): - content = r.message - - if content is None: - raise IMException("Aucun SMS envoyé : le résultat de la commande n'a pas retourné de contenu.") - - check_sms_dests(dests, cur_epoch) - return send_sms_to_list(msg, frm, dests, content, cur_epoch) - + return Response("le SMS a bien été envoyé", msg.channel, msg.nick) 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) -@hook.ask() +@hook("ask_default") def parseask(msg): - 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 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 resuser is not None and reskey is not None: apiuser = resuser.group("user") apikey = reskey.group("key") @@ -136,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.frm) + return Response("je n'ai pas pu enregistrer tes identifiants : %s" % test, msg.channel, msg.nick) - if msg.frm in context.data.index: - context.data.index[msg.frm]["user"] = apiuser - context.data.index[msg.frm]["key"] = apikey + if msg.nick in context.data.index: + context.data.index[msg.nick]["user"] = apiuser + context.data.index[msg.nick]["key"] = apikey else: ms = ModuleState("phone") - ms.setAttribute("name", msg.frm) + ms.setAttribute("name", msg.nick) 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.frm) + msg.channel, msg.nick) diff --git a/modules/spell/__init__.py b/modules/spell/__init__.py index da16a80..af08fde 100644 --- a/modules/spell/__init__.py +++ b/modules/spell/__init__.py @@ -1,25 +1,53 @@ +# coding=utf-8 + """Check words spelling""" -# PYTHON STUFFS ####################################################### +import re +from urllib.parse import quote from nemubot import context -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools.xmlparser.node import ModuleState from .pyaspell import Aspell from .pyaspell import AspellError -from nemubot.module.more import Response +nemubotversion = 3.4 +from more import Response -# LOADING ############################################################# +def help_full(): + return "!spell [] : give the correct spelling of in ." def load(context): context.data.setIndex("name", "score") +@hook("cmd_hook", "spell") +def cmd_spell(msg): + if not len(msg.args): + raise IRCException("indique une orthographe approximative du mot dont tu veux vérifier l'orthographe.") -# MODULE CORE ######################################################### + lang = "fr" + strRes = list() + for word in msg.args: + if len(word) <= 2 and len(msg.args) > 2: + lang = word + else: + try: + r = check_spell(word, lang) + except AspellError: + return Response("Je n'ai pas le dictionnaire `%s' :(" % lang, msg.channel, msg.nick) + if r == True: + add_score(msg.nick, "correct") + strRes.append("l'orthographe de `%s' est correcte" % word) + elif len(r) > 0: + add_score(msg.nick, "bad") + strRes.append("suggestions pour `%s' : %s" % (word, ", ".join(r))) + else: + add_score(msg.nick, "bad") + strRes.append("aucune suggestion pour `%s'" % word) + return Response(strRes, channel=msg.channel, nick=msg.nick) def add_score(nick, t): if nick not in context.data.index: @@ -33,59 +61,12 @@ def add_score(nick, t): context.data.index[nick][t] = 1 context.save() - -def check_spell(word, lang='fr'): - a = Aspell([("lang", lang)]) - if a.check(word.encode("utf-8")): - ret = True - else: - ret = a.suggest(word.encode("utf-8")) - a.close() - return ret - - -# MODULE INTERFACE #################################################### - -@hook.command("spell", - help="give the correct spelling of given words", - help_usage={"WORD": "give the correct spelling of the WORD."}, - keywords={"lang=": "change the language use for checking, default fr"}) -def cmd_spell(msg): - if not len(msg.args): - raise IMException("indique une orthographe approximative du mot dont tu veux vérifier l'orthographe.") - - lang = msg.kwargs["lang"] if "lang" in msg.kwargs else "fr" - - res = Response(channel=msg.channel) - for word in msg.args: - try: - r = check_spell(word, lang) - except AspellError: - raise IMException("Je n'ai pas le dictionnaire `%s' :(" % lang) - - if r == True: - add_score(msg.frm, "correct") - res.append_message("l'orthographe de `%s' est correcte" % word) - - elif len(r) > 0: - add_score(msg.frm, "bad") - res.append_message(r, title="suggestions pour `%s'" % word) - - else: - add_score(msg.frm, "bad") - res.append_message("aucune suggestion pour `%s'" % word) - - return res - - -@hook.command("spellscore", - help="Show spell score (tests, mistakes, ...) for someone", - help_usage={"USER": "Display score of USER"}) +@hook("cmd_hook", "spellscore") def cmd_score(msg): res = list() unknown = list() if not len(msg.args): - raise IMException("De qui veux-tu voir les scores ?") + raise IRCException("De qui veux-tu voir les scores ?") for cmd in msg.args: if cmd in context.data.index: res.append(Response("%s: %s" % (cmd, " ; ".join(["%s: %d" % (a, context.data.index[cmd].getInt(a)) for a in context.data.index[cmd].attributes.keys() if a != "name"])), channel=msg.channel)) @@ -95,3 +76,12 @@ def cmd_score(msg): res.append(Response("%s inconnus" % ", ".join(unknown), channel=msg.channel)) return res + +def check_spell(word, lang='fr'): + a = Aspell([("lang", lang)]) + if a.check(word.encode("utf-8")): + ret = True + else: + ret = a.suggest(word.encode("utf-8")) + a.close() + return ret diff --git a/modules/suivi.py b/modules/suivi.py index a54b722..32c39a3 100644 --- a/modules/suivi.py +++ b/modules/suivi.py @@ -1,53 +1,32 @@ -"""Postal tracking module""" - -# PYTHON STUFF ############################################ - -import json +import urllib.request 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, getURLHeaders, getJSON -from nemubot.module.more import Response +from nemubot.exception import IRCException +from nemubot.tools.web import getURLContent +from more import Response +nemubotversion = 4.0 -# POSTAGE SERVICE PARSERS ############################################ - -def get_tnt_info(track_id): - values = [] - data = getURLContent('https://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: - return None - last_status = status_list.find('div', class_='roster') - if last_status: - for info in last_status.find_all('div', class_='roster__item'): - values.append(info.get_text().strip()) - if len(values) == 3: - return (values[0], values[1], values[2]) - +def help_full(): + return "Traquez vos courriers La Poste ou Colissimo en utilisant la commande: !laposte ou !colissimo \nCe service se base sur http://www.csuivi.courrier.laposte.fr/suivi/index et http://www.colissimo.fr/portail_colissimo/suivre.do" def get_colissimo_info(colissimo_id): - colissimo_data = getURLContent("https://www.laposte.fr/particulier/outils/suivre-vos-envois?code=%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_='results-suivi') - if dataArray and dataArray.table and dataArray.table.tbody and dataArray.table.tbody.tr: - td = dataArray.table.tbody.tr.find_all('td') - if len(td) > 2: - date = td[0].get_text() - libelle = re.sub(r'[\n\t\r]', '', td[1].get_text()) - site = td[2].get_text().strip() - return (date, libelle, site.strip()) - + dataArray = soup.find(class_='dataArray') + if dataArray and dataArray.tbody and dataArray.tbody.tr: + date = dataArray.tbody.tr.find(headers="Date").get_text() + libelle = dataArray.tbody.tr.find(headers="Libelle").get_text().replace('\n', '').replace('\t', '').replace('\r', '') + site = dataArray.tbody.tr.find(headers="site").get_text().strip() + return (date, libelle, site.strip()) def get_chronopost_info(track_id): data = urllib.parse.urlencode({'listeNumeros': track_id}) - track_baseurl = "https://www.chronopost.fr/expedier/inputLTNumbersNoJahia.do?lang=fr_FR" - track_data = getURLContent(track_baseurl, data.encode('utf-8')) + track_baseurl = "http://www.chronopost.fr/expedier/inputLTNumbersNoJahia.do?lang=fr_FR" + track_data = urllib.request.urlopen(track_baseurl, data.encode('utf-8')) soup = BeautifulSoup(track_data) infoClass = soup.find(class_='numeroColi2') @@ -60,273 +39,104 @@ def get_chronopost_info(track_id): libelle = info[1] return (date, libelle) - 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 = getURLContent(track_baseurl, data.encode('utf-8')) + track_data = urllib.request.urlopen(track_baseurl, data.encode('utf-8')) soup = BeautifulSoup(track_data) dataArray = soup.find(class_='BandeauInfoColis') - if (dataArray and dataArray.find(class_='divStatut') - and dataArray.find(class_='divStatut').find(class_='tdText')): - status = dataArray.find(class_='divStatut') \ - .find(class_='tdText').get_text() + if dataArray and dataArray.find(class_='divStatut') and dataArray.find(class_='divStatut').find(class_='tdText'): + status = dataArray.find(class_='divStatut').find(class_='tdText').get_text() return status - -def get_ups_info(track_id): - data = json.dumps({'Locale': "en_US", 'TrackingNumber': [track_id]}) - track_baseurl = "https://www.ups.com/track/api/Track/GetStatus?loc=en_US" - track_data = getJSON(track_baseurl, data.encode('utf-8'), header={"Content-Type": "application/json"}) - return (track_data["trackDetails"][0]["trackingNumber"], - track_data["trackDetails"][0]["packageStatus"], - track_data["trackDetails"][0]["shipmentProgressActivities"][0]["date"] + " " + track_data["trackDetails"][0]["shipmentProgressActivities"][0]["time"], - track_data["trackDetails"][0]["shipmentProgressActivities"][0]["location"], - track_data["trackDetails"][0]["shipmentProgressActivities"][0]["activityScan"]) - - def get_laposte_info(laposte_id): - status, laposte_headers = getURLHeaders("https://www.laposte.fr/outils/suivre-vos-envois?" + urllib.parse.urlencode({'code': laposte_id})) + data = urllib.parse.urlencode({'id': laposte_id}) + laposte_baseurl = "http://www.part.csuivi.courrier.laposte.fr/suivi/index" - laposte_cookie = None - for k,v in laposte_headers: - if k.lower() == "set-cookie" and v.find("access_token") >= 0: - laposte_cookie = v.split(";")[0] + laposte_data = urllib.request.urlopen(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 + and soup.find(class_='resultat_rech_simple_table').thead.tr + and len(search_res.find_all('td')) > 3): + field = search_res.find('td') + poste_id = field.get_text() - laposte_data = getJSON("https://api.laposte.fr/ssu/v1/suivi-unifie/idship/%s?lang=fr_FR" % urllib.parse.quote(laposte_id), header={"Accept": "application/json", "Cookie": laposte_cookie}) + field = field.find_next('td') + poste_type = field.get_text() - shipment = laposte_data["shipment"] - return (shipment["product"], shipment["idShip"], shipment["event"][0]["label"], shipment["event"][0]["date"]) + field = field.find_next('td') + poste_date = field.get_text() + field = field.find_next('td') + poste_location = field.get_text() -def get_postnl_info(postnl_id): - data = urllib.parse.urlencode({'barcodes': postnl_id}) - postnl_baseurl = "http://www.postnl.post/details/" + field = field.find_next('td') + poste_status = field.get_text() - postnl_data = getURLContent(postnl_baseurl, data.encode('utf-8')) - soup = BeautifulSoup(postnl_data) - if (soup.find(id='datatables') - and soup.find(id='datatables').tbody - and soup.find(id='datatables').tbody.tr): - search_res = soup.find(id='datatables').tbody.tr - if len(search_res.find_all('td')) >= 3: - field = field.find_next('td') - post_date = field.get_text() + return (poste_type.lower(), poste_id.strip(), poste_status.lower(), poste_location, poste_date) - field = field.find_next('td') - post_status = field.get_text() - - field = field.find_next('td') - post_destination = field.get_text() - - 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(id="trackingHistory_1") - 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({ - "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"]) and - (not fedex_data["TrackPackagesResponse"]["errorList"][0]["code"] or - fedex_data["TrackPackagesResponse"]["errorList"][0]["code"] == '0') and - not fedex_data["TrackPackagesResponse"]["packageList"][0]["errorList"][0]["code"] - ): - 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): - info = get_tnt_info(tracknum) - if info: - status, date, place = info - placestr = '' - if place: - placestr = ' à \x02{place}\x0f' - return ('Le colis \x02{trackid}\x0f a actuellement le status: ' - '\x02{status}\x0F mis à jour le \x02{date}\x0f{place}.' - .format(trackid=tracknum, status=status, - date=re.sub(r'\s+', ' ', date), place=placestr)) - - -def handle_laposte(tracknum): - info = get_laposte_info(tracknum) - if info: - poste_type, poste_id, poste_status, poste_date = info - return ("\x02%s\x0F : \x02%s\x0F est actuellement " - "\x02%s\x0F (Mis à jour le \x02%s\x0F" - ")." % (poste_type, poste_id, poste_status, poste_date)) - - -def handle_postnl(tracknum): - info = get_postnl_info(tracknum) - if info: - post_status, post_destination, post_date = info - return ("PostNL \x02%s\x0F est actuellement " - "\x02%s\x0F vers le pays \x02%s\x0F (Mis à jour le \x02%s\x0F" - ")." % (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: {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_ups(tracknum): - info = get_ups_info(tracknum) - if info: - tracknum, status, last_date, last_location, last_status = info - return ("UPS \x02{tracknum}\x0F: {status}: in \x02{last_location}\x0F as of {last_date}: {last_status}".format(tracknum=tracknum, status=status, last_date=last_date, last_status=last_status.lower(), last_location=last_location)) - - -def handle_colissimo(tracknum): - info = get_colissimo_info(tracknum) - if info: - date, libelle, site = info - return ("Colissimo: \x02%s\x0F : \x02%s\x0F Dernière mise à jour le " - "\x02%s\x0F au site \x02%s\x0F." - % (tracknum, libelle, date, site)) - - -def handle_chronopost(tracknum): - info = get_chronopost_info(tracknum) - if info: - date, libelle = info - return ("Colis Chronopost: \x02%s\x0F : \x02%s\x0F. Dernière mise à " - "jour \x02%s\x0F." % (tracknum, libelle, date)) - - -def handle_coliprive(tracknum): - info = get_colisprive_info(tracknum) - if info: - 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)) - - -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, - 'colissimo': handle_colissimo, - 'chronopost': handle_chronopost, - 'coliprive': handle_coliprive, - 'tnt': handle_tnt, - 'fedex': handle_fedex, - 'dhl': handle_dhl, - 'usps': handle_usps, - 'ups': handle_ups, -} - - -# HOOKS ############################################################## - -@hook.command("track", - help="Track postage delivery", - help_usage={ - "TRACKING_ID [...]": "Track the specified postage IDs on various tracking services." - }, - keywords={ - "tracker=TRK": "Precise the tracker (default: all) among: " + ', '.join(TRACKING_HANDLERS) - }) +@hook("cmd_hook", "track") def get_tracking_info(msg): if not len(msg.args): - raise IMException("Renseignez un identifiant d'envoi.") + raise IRCException("Renseignez un identifiant d'envoi,") - res = Response(channel=msg.channel, count=" (%d suivis supplémentaires)") + info = get_colisprive_info(msg.args[0]) + if info: + return Response("Colis Privé: \x02%s\x0F : \x02%s\x0F." % (msg.args[0], info), msg.channel) - if 'tracker' in msg.kwargs: - if msg.kwargs['tracker'] in TRACKING_HANDLERS: - trackers = { - msg.kwargs['tracker']: TRACKING_HANDLERS[msg.kwargs['tracker']] - } - else: - raise IMException("No tracker named \x02{tracker}\x0F, please use" - " one of the following: \x02{trackers}\x0F" - .format(tracker=msg.kwargs['tracker'], - trackers=', ' - .join(TRACKING_HANDLERS.keys()))) - else: - trackers = TRACKING_HANDLERS + info = get_chronopost_info(msg.args[0]) + if info: + date, libelle = info + return Response("Colis Chronopost: \x02%s\x0F : \x02%s\x0F. Dernière mise à jour \x02%s\x0F." % (msg.args[0], libelle, date), msg.channel) - for tracknum in msg.args: - for name, tracker in trackers.items(): - ret = tracker(tracknum) - if ret: - res.append_message(ret) - break - if not ret: - res.append_message("L'identifiant \x02{id}\x0F semble incorrect," - " merci de vérifier son exactitude." - .format(id=tracknum)) - return res + info = get_colissimo_info(msg.args[0]) + if info: + date, libelle, site = info + return Response("Colissimo: \x02%s\x0F : \x02%s\x0F. Dernière mise à jour le \x02%s\x0F au site \x02%s\x0F." % (msg.args[0], libelle, date, site), msg.channel) + + info = get_laposte_info(msg.args[0]) + if info: + poste_type, poste_id, poste_status, poste_location, poste_date = info + return Response("Le courrier de type \x02%s\x0F : \x02%s\x0F est actuellement \x02%s\x0F dans la zone \x02%s\x0F (Mis à jour le \x02%s\x0F)." % (poste_type, poste_id, poste_status, poste_location, poste_date), msg.channel) + return Response("L'identifiant recherché semble incorrect, merci de vérifier son exactitude.", msg.channel) + +@hook("cmd_hook", "colisprive") +def get_colisprive_tracking_info(msg): + if not len(msg.args): + raise IRCException("Renseignez un identifiant d'envoi,") + info = get_colisprive_info(msg.args[0]) + if info: + return Response("Colis: \x02%s\x0F : \x02%s\x0F." % (msg.args[0], info), msg.channel) + return Response("L'identifiant recherché semble incorrect, merci de vérifier son exactitude.", msg.channel) + +@hook("cmd_hook", "chronopost") +def get_chronopost_tracking_info(msg): + if not len(msg.args): + raise IRCException("Renseignez un identifiant d'envoi,") + info = get_chronopost_info(msg.args[0]) + if info: + date, libelle = info + return Response("Colis: \x02%s\x0F : \x02%s\x0F. Dernière mise à jour \x02%s\x0F." % (msg.args[0], libelle, date), msg.channel) + return Response("L'identifiant recherché semble incorrect, merci de vérifier son exactitude.", msg.channel) + +@hook("cmd_hook", "colissimo") +def get_colissimo_tracking_info(msg): + if not len(msg.args): + raise IRCException("Renseignez un identifiant d'envoi,") + info = get_colissimo_info(msg.args[0]) + if info: + date, libelle, site = info + return Response("Colis: \x02%s\x0F : \x02%s\x0F. Dernière mise à jour le \x02%s\x0F au site \x02%s\x0F." % (msg.args[0], libelle, date, site), msg.channel) + return Response("L'identifiant recherché semble incorrect, merci de vérifier son exactitude.", msg.channel) + +@hook("cmd_hook", "laposte") +def get_laposte_tracking_info(msg): + if not len(msg.args): + raise IRCException("Renseignez un identifiant d'envoi,") + info = get_laposte_info(msg.args[0]) + if info: + poste_type, poste_id, poste_status, poste_location, poste_date = info + return Response("Le courrier de type \x02%s\x0F : \x02%s\x0F est actuellement \x02%s\x0F dans la zone \x02%s\x0F (Mis à jour le \x02%s\x0F)." % (poste_type, poste_id, poste_status, poste_location, poste_date), msg.channel) + return Response("L'identifiant recherché semble incorrect, merci de vérifier son exactitude.", msg.channel) diff --git a/modules/syno.py b/modules/syno.py index 78f0b7d..0bd8ce4 100644 --- a/modules/syno.py +++ b/modules/syno.py @@ -1,23 +1,27 @@ -"""Find synonyms""" +# coding=utf-8 -# PYTHON STUFFS ####################################################### +"""Find synonyms""" import re from urllib.parse import quote -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web -from nemubot.module.more import Response +nemubotversion = 4.0 + +from more import Response -# LOADING ############################################################# +def help_full(): + return "!syno [LANG] : give a list of synonyms for ." + def load(context): global lang_binding - if not context.config or not "bighugelabskey" in context.config: + if not context.config or not context.config.hasAttribute("bighugelabskey"): logger.error("You need a NigHugeLabs API key in order to have english " "theasorus. Add it to the module configuration file:\n" " 0: res.append_message(synos) return res else: - raise IMException("Aucun synonyme de %s n'a été trouvé" % word) + raise IRCException("Aucun synonyme de %s n'a été trouvé" % word) elif what == "antonymes": if len(anton) > 0: @@ -111,7 +108,7 @@ def go(msg, what): title="Antonymes de %s" % word) return res else: - raise IMException("Aucun antonyme de %s n'a été trouvé" % word) + raise IRCException("Aucun antonyme de %s n'a été trouvé" % word) else: - raise IMException("WHAT?!") + raise IRCException("WHAT?!") diff --git a/modules/tpb.py b/modules/tpb.py index a752324..76711bf 100644 --- a/modules/tpb.py +++ b/modules/tpb.py @@ -1,19 +1,19 @@ from datetime import datetime import urllib -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import human from nemubot.tools.web import getJSON nemubotversion = 4.0 -from nemubot.module.more import Response +from more import Response URL_TPBAPI = None def load(context): - if not context.config or "url" not in context.config: + if not context.config or not context.config.hasAttribute("url"): raise ImportError("You need a TPB API in order to use the !tpb feature" ". Add it to the module configuration file:\n\nSample " @@ -22,10 +22,10 @@ def load(context): global URL_TPBAPI URL_TPBAPI = context.config["url"] -@hook.command("tpb") +@hook("cmd_hook", "tpb") def cmd_tpb(msg): if not len(msg.args): - raise IMException("indicate an item to search!") + raise IRCException("indicate an item to search!") torrents = getJSON(URL_TPBAPI + urllib.parse.quote(" ".join(msg.args))) diff --git a/modules/translate.py b/modules/translate.py index 906ba93..a0d8dc2 100644 --- a/modules/translate.py +++ b/modules/translate.py @@ -1,27 +1,25 @@ +# coding=utf-8 + """Translation module""" -# PYTHON STUFFS ####################################################### - +import re from urllib.parse import quote -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web -from nemubot.module.more import Response +nemubotversion = 4.0 +from more import Response -# GLOBALS ############################################################# LANG = ["ar", "zh", "cz", "en", "fr", "gr", "it", "ja", "ko", "pl", "pt", "ro", "es", "tr"] URL = "http://api.wordreference.com/0.8/%s/json/%%s%%s/%%s" - -# LOADING ############################################################# - def load(context): - if not context.config or "wrapikey" not in context.config: + if not context.config or not context.config.hasAttribute("wrapikey"): raise ImportError("You need a WordReference API key in order to use " "this module. Add it to the module configuration " "file:\n[ [...]]: Found translation of from/to english to/from . Data © WordReference.com" + + +@hook("cmd_hook", "translate") +def cmd_translate(msg): + if not len(msg.args): + raise IRCException("which word would you translate?") + + if len(msg.args) > 2 and msg.args[0] in LANG and msg.args[1] in LANG: + if msg.args[0] != "en" and msg.args[1] != "en": + raise IRCException("sorry, I can only translate to or from english") + langFrom = msg.args[0] + langTo = msg.args[1] + term = ' '.join(msg.args[2:]) + elif len(msg.args) > 1 and msg.args[0] in LANG: + langFrom = msg.args[0] + if langFrom == "en": + langTo = "fr" + else: + langTo = "en" + term = ' '.join(msg.args[1:]) + else: + langFrom = "en" + langTo = "fr" + term = ' '.join(msg.args) + + wres = web.getJSON(URL % (langFrom, langTo, quote(term))) + + if "Error" in wres: + raise IRCException(wres["Note"]) + + else: + res = Response(channel=msg.channel, + count=" (%d more meanings)", + nomore="No more translation") + for k in sorted(wres.keys()): + t = wres[k] + if len(k) > 4 and k[:4] == "term": + if "Entries" in t: + ent = t["Entries"] + else: + ent = t["PrincipalTranslations"] + + for i in sorted(ent.keys()): + res.append_message("Translation of %s%s: %s" % ( + ent[i]["OriginalTerm"]["term"], + meaning(ent[i]["OriginalTerm"]), + extract_traslation(ent[i]))) + return res + def meaning(entry): ret = list() @@ -53,59 +101,3 @@ def extract_traslation(entry): if "Note" in entry and entry["Note"]: ret.append("note: %s" % entry["Note"]) return ", ".join(ret) - - -def translate(term, langFrom="en", langTo="fr"): - wres = web.getJSON(URL % (langFrom, langTo, quote(term))) - - if "Error" in wres: - raise IMException(wres["Note"]) - - else: - for k in sorted(wres.keys()): - t = wres[k] - if len(k) > 4 and k[:4] == "term": - if "Entries" in t: - ent = t["Entries"] - else: - ent = t["PrincipalTranslations"] - - for i in sorted(ent.keys()): - yield "Translation of %s%s: %s" % ( - ent[i]["OriginalTerm"]["term"], - meaning(ent[i]["OriginalTerm"]), - extract_traslation(ent[i])) - - -# MODULE INTERFACE #################################################### - -@hook.command("translate", - help="Word translation using WordReference.com", - help_usage={ - "TERM": "Found translation of TERM from/to english to/from ." - }, - keywords={ - "from=LANG": "language of the term you asked for translation between: en, " + ", ".join(LANG), - "to=LANG": "language of the translated terms between: en, " + ", ".join(LANG), - }) -def cmd_translate(msg): - if not len(msg.args): - raise IMException("which word would you translate?") - - langFrom = msg.kwargs["from"] if "from" in msg.kwargs else "en" - if "to" in msg.kwargs: - langTo = msg.kwargs["to"] - else: - langTo = "fr" if langFrom == "en" else "en" - - if langFrom not in LANG or langTo not in LANG: - raise IMException("sorry, I can only translate to or from: " + ", ".join(LANG)) - if langFrom != "en" and langTo != "en": - raise IMException("sorry, I can only translate to or from english") - - res = Response(channel=msg.channel, - count=" (%d more meanings)", - nomore="No more translation") - for t in translate(" ".join(msg.args), langFrom=langFrom, langTo=langTo): - res.append_message(t) - return res diff --git a/modules/urbandict.py b/modules/urbandict.py deleted file mode 100644 index b561e89..0000000 --- a/modules/urbandict.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Search definition from urbandictionnary""" - -# PYTHON STUFFS ####################################################### - -from urllib.parse import quote - -from nemubot.exception import IMException -from nemubot.hooks import hook -from nemubot.tools import web - -from nemubot.module.more import Response - -# MODULE CORE ######################################################### - -def search(terms): - return web.getJSON( - "https://api.urbandictionary.com/v0/define?term=%s" - % quote(' '.join(terms))) - - -# MODULE INTERFACE #################################################### - -@hook.command("urbandictionnary") -def udsearch(msg): - if not len(msg.args): - raise IMException("Indicate a term to search") - - s = search(msg.args) - - res = Response(channel=msg.channel, nomore="No more results", - count=" (%d more definitions)") - - for i in s["list"]: - res.append_message(i["definition"].replace("\n", " "), - title=i["word"]) - - return res diff --git a/modules/urlreducer.py b/modules/urlreducer.py deleted file mode 100644 index 86f4d42..0000000 --- a/modules/urlreducer.py +++ /dev/null @@ -1,173 +0,0 @@ -"""URL reducer module""" - -# PYTHON STUFFS ####################################################### - -import re -import json -from urllib.parse import urlparse -from urllib.parse import quote - -from nemubot.exception import IMException -from nemubot.hooks import hook -from nemubot.message import Text -from nemubot.tools import web - - -# MODULE FUNCTIONS #################################################### - -def default_reducer(url, data): - snd_url = url + quote(data, "/:%@&=?") - return web.getURLContent(snd_url) - - -def ycc_reducer(url, data): - return "https://ycc.fr/%s" % default_reducer(url, data) - -def lstu_reducer(url, data): - json_data = json.loads(web.getURLContent(url, "lsturl=" + quote(data), - header={"Content-Type": "application/x-www-form-urlencoded"})) - if 'short' in json_data: - return json_data['short'] - elif 'msg' in json_data: - raise IMException("Error: %s" % json_data['msg']) - else: - IMException("An error occured while shortening %s." % data) - -# MODULE VARIABLES #################################################### - -PROVIDERS = { - "tinyurl": (default_reducer, "https://tinyurl.com/api-create.php?url="), - "ycc": (ycc_reducer, "https://ycc.fr/redirection/create/"), - "framalink": (lstu_reducer, "https://frama.link/a?format=json"), - "huitre": (lstu_reducer, "https://huit.re/a?format=json"), - "lstu": (lstu_reducer, "https://lstu.fr/a?format=json"), -} -DEFAULT_PROVIDER = "framalink" - -PROVIDERS_NETLOC = [urlparse(web.getNormalizedURL(url), "http").netloc for f, url in PROVIDERS.values()] - -# LOADING ############################################################# - - -def load(context): - global DEFAULT_PROVIDER - - if "provider" in context.config: - if context.config["provider"] == "custom": - PROVIDERS["custom"] = context.config["provider_url"] - DEFAULT_PROVIDER = context.config["provider"] - - -# MODULE CORE ######################################################### - -def reduce_inline(txt, provider=None): - for url in re.findall("([a-zA-Z0-9+.-]+:(?://)?(?:[^ :/]+:[0-9]+)?[^ :]+)", txt): - txt = txt.replace(url, reduce(url, provider)) - return txt - - -def reduce(url, provider=None): - """Ask the url shortner website to reduce given URL - - Argument: - url -- the URL to reduce - """ - if provider is None: - provider = DEFAULT_PROVIDER - return PROVIDERS[provider][0](PROVIDERS[provider][1], url) - - -def gen_response(res, msg, srv): - if res is None: - raise IMException("bad URL : %s" % srv) - else: - return Text("URL for %s: %s" % (srv, res), server=None, - to=msg.to_response) - - -## URL stack - -LAST_URLS = dict() - - -@hook.message() -def parselisten(msg): - 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() -def parseresponse(msg): - global LAST_URLS - if hasattr(msg, "text") and isinstance(msg.text, str): - urls = re.findall("([a-zA-Z0-9+.-]+:(?://)?(?:[^ :/]+:[0-9]+)?[^ :]+)", - msg.text) - 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) - return msg - - -# MODULE INTERFACE #################################################### - -@hook.command("framalink", - help="Reduce any long URL", - help_usage={ - None: "Reduce the last URL said on the channel", - "URL [URL ...]": "Reduce the given URL(s)" - }, - keywords={ - "provider=SMTH": "Change the service provider used (by default: %s) among %s" % (DEFAULT_PROVIDER, ", ".join(PROVIDERS.keys())) - }) -def cmd_reduceurl(msg): - minify = list() - - if not len(msg.args): - global LAST_URLS - if msg.channel in LAST_URLS and len(LAST_URLS[msg.channel]) > 0: - minify.append(LAST_URLS[msg.channel].pop()) - else: - raise IMException("I have no more URL to reduce.") - - if len(msg.args) > 4: - raise IMException("I cannot reduce that many URLs at once.") - else: - minify += msg.args - - if 'provider' in msg.kwargs and msg.kwargs['provider'] in PROVIDERS: - provider = msg.kwargs['provider'] - else: - provider = DEFAULT_PROVIDER - - res = list() - for url in minify: - o = urlparse(web.getNormalizedURL(url), "http") - minief_url = reduce(url, provider) - if o.netloc == "": - res.append(gen_response(minief_url, msg, o.scheme)) - else: - res.append(gen_response(minief_url, msg, o.netloc)) - return res diff --git a/modules/velib.py b/modules/velib.py index 71c472c..bdfc8e0 100644 --- a/modules/velib.py +++ b/modules/velib.py @@ -1,24 +1,24 @@ -"""Gets information about velib stations""" +# coding=utf-8 -# PYTHON STUFFS ####################################################### +"""Gets information about velib stations""" import re from nemubot import context -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web -from nemubot.module.more import Response +nemubotversion = 4.0 +from more import Response -# LOADING ############################################################# URL_API = None # http://www.velib.paris.fr/service/stationdetails/paris/%s def load(context): global URL_API - if not context.config or "url" not in context.config: + if not context.config or not context.config.hasAttribute("url"): raise ImportError("Please provide url attribute in the module configuration") URL_API = context.config["url"] context.data.setIndex("name", "station") @@ -29,14 +29,25 @@ def load(context): # context.add_event(evt) -# MODULE CORE ######################################################### +def help_full(): + return ("!velib /number/ ...: gives available bikes and slots at " + "the station /number/.") + def station_status(station): """Gets available and free status of a given station""" response = web.getXML(URL_API % station) if response is not None: - available = int(response.getElementsByTagName("available")[0].firstChild.nodeValue) - free = int(response.getElementsByTagName("free")[0].firstChild.nodeValue) + available = response.getNode("available").getContent() + if available is not None and len(available) > 0: + available = int(available) + else: + available = 0 + free = response.getNode("free").getContent() + if free is not None and len(free) > 0: + free = int(free) + else: + free = 0 return (available, free) else: return (None, None) @@ -58,30 +69,27 @@ def print_station_status(msg, station): """Send message with information about the given station""" (available, free) = station_status(station) if available is not None and free is not None: - return Response("À la station %s : %d vélib et %d points d'attache" + return Response("à la station %s : %d vélib et %d points d'attache" " disponibles." % (station, available, free), - channel=msg.channel) - raise IMException("station %s inconnue." % station) + channel=msg.channel, nick=msg.nick) + raise IRCException("station %s inconnue." % station) -# MODULE INTERFACE #################################################### - -@hook.command("velib", - help="gives available bikes and slots at the given station", - help_usage={ - "STATION_ID": "gives available bikes and slots at the station STATION_ID" - }) +@hook("cmd_hook", "velib") def ask_stations(msg): + """Hook entry from !velib""" if len(msg.args) > 4: - raise IMException("demande-moi moins de stations à la fois.") - elif not len(msg.args): - raise IMException("pour quelle station ?") + raise IRCException("demande-moi moins de stations à la fois.") - for station in msg.args: - if re.match("^[0-9]{4,5}$", station): - return print_station_status(msg, station) - elif station in context.data.index: - return print_station_status(msg, - context.data.index[station]["number"]) - else: - raise IMException("numéro de station invalide.") + elif len(msg.args): + for station in msg.args: + if re.match("^[0-9]{4,5}$", station): + return print_station_status(msg, station) + elif station in context.data.index: + return print_station_status(msg, + context.data.index[station]["number"]) + else: + raise IRCException("numéro de station invalide.") + + else: + raise IRCException("pour quelle station ?") diff --git a/modules/virtualradar.py b/modules/virtualradar.py deleted file mode 100644 index 2c87e79..0000000 --- a/modules/virtualradar.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Retrieve flight information from VirtualRadar APIs""" - -# PYTHON STUFFS ####################################################### - -import re -from urllib.parse import quote -import time - -from nemubot.exception import IMException -from nemubot.hooks import hook -from nemubot.tools import web - -from nemubot.module.more import Response -from nemubot.module import mapquest - -# GLOBALS ############################################################# - -URL_API = "https://public-api.adsbexchange.com/VirtualRadar/AircraftList.json?fcallC=%s" - -SPEED_TYPES = { - 0: 'Ground speed', - 1: 'Ground speed reversing', - 2: 'Indicated air speed', - 3: 'True air speed'} - -WTC_CAT = { - 0: 'None', - 1: 'Light', - 2: 'Medium', - 3: 'Heavy' - } - -SPECIES = { - 1: 'Land plane', - 2: 'Sea plane', - 3: 'Amphibian', - 4: 'Helicopter', - 5: 'Gyrocopter', - 6: 'Tiltwing', - 7: 'Ground vehicle', - 8: 'Tower'} - -HANDLER_TABLE = { - 'From': lambda x: 'From: \x02%s\x0F' % x, - 'To': lambda x: 'To: \x02%s\x0F' % x, - 'Op': lambda x: 'Airline: \x02%s\x0F' % x, - 'Mdl': lambda x: 'Model: \x02%s\x0F' % x, - 'Call': lambda x: 'Flight: \x02%s\x0F' % x, - 'PosTime': lambda x: 'Last update: \x02%s\x0F' % (time.ctime(int(x)/1000)), - 'Alt': lambda x: 'Altitude: \x02%s\x0F ft' % x, - 'Spd': lambda x: 'Speed: \x02%s\x0F kn' % x, - 'SpdTyp': lambda x: 'Speed type: \x02%s\x0F' % SPEED_TYPES[x] if x in SPEED_TYPES else None, - 'Engines': lambda x: 'Engines: \x02%s\x0F' % x, - 'Gnd': lambda x: 'On the ground' if x else None, - 'Mil': lambda x: 'Military aicraft' if x else None, - 'Species': lambda x: 'Aircraft species: \x02%s\x0F' % SPECIES[x] if x in SPECIES else None, - 'WTC': lambda x: 'Turbulence level: \x02%s\x0F' % WTC_CAT[x] if x in WTC_CAT else None, - } - -# MODULE CORE ######################################################### - -def virtual_radar(flight_call): - obj = web.getJSON(URL_API % quote(flight_call)) - - if "acList" in obj: - for flight in obj["acList"]: - yield flight - -def flight_info(flight): - for prop in HANDLER_TABLE: - if prop in flight: - yield HANDLER_TABLE[prop](flight[prop]) - -# MODULE INTERFACE #################################################### - -@hook.command("flight", - help="Get flight information", - help_usage={ "FLIGHT": "Get information on FLIGHT" }) -def cmd_flight(msg): - if not len(msg.args): - raise IMException("please indicate a flight") - - res = Response(channel=msg.channel, nick=msg.frm, - nomore="No more flights", count=" (%s more flights)") - - for param in msg.args: - for flight in virtual_radar(param): - if 'Lat' in flight and 'Long' in flight: - loc = None - for location in mapquest.geocode('{Lat},{Long}'.format(**flight)): - loc = location - break - if loc: - res.append_message('\x02{0}\x0F: Position: \x02{1}\x0F, {2}'.format(flight['Call'], \ - mapquest.where(loc), \ - ', '.join(filter(None, flight_info(flight))))) - continue - res.append_message('\x02{0}\x0F: {1}'.format(flight['Call'], \ - ', '.join(filter(None, flight_info(flight))))) - return res diff --git a/modules/weather.py b/modules/weather.py index 9b36470..a36306e 100644 --- a/modules/weather.py +++ b/modules/weather.py @@ -1,81 +1,82 @@ # coding=utf-8 -"""The weather module. Powered by Dark Sky """ +"""The weather module""" import datetime import re +from urllib.parse import quote from nemubot import context -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web from nemubot.tools.xmlparser.node import ModuleState -from nemubot.module import mapquest +import mapquest nemubotversion = 4.0 -from nemubot.module.more import Response +from more import Response -URL_DSAPI = "https://api.darksky.net/forecast/%s/%%s,%%s?lang=%%s&units=%%s" - -UNITS = { - "ca": { - "temperature": "°C", - "distance": "km", - "precipIntensity": "mm/h", - "precip": "cm", - "speed": "km/h", - "pressure": "hPa", - }, - "uk2": { - "temperature": "°C", - "distance": "mi", - "precipIntensity": "mm/h", - "precip": "cm", - "speed": "mi/h", - "pressure": "hPa", - }, - "us": { - "temperature": "°F", - "distance": "mi", - "precipIntensity": "in/h", - "precip": "in", - "speed": "mi/h", - "pressure": "mbar", - }, - "si": { - "temperature": "°C", - "distance": "km", - "precipIntensity": "mm/h", - "precip": "cm", - "speed": "m/s", - "pressure": "hPa", - }, -} +URL_DSAPI = "https://api.forecast.io/forecast/%s/%%s,%%s" def load(context): - if not context.config or "darkskyapikey" not in context.config: + if not context.config or not context.config.hasAttribute("darkskyapikey"): raise ImportError("You need a Dark-Sky API key in order to use this " "module. Add it to the module configuration file:\n" "\n" - "Register at https://developer.forecast.io/") + "Register at http://developer.forecast.io/") context.data.setIndex("name", "city") global URL_DSAPI URL_DSAPI = URL_DSAPI % context.config["darkskyapikey"] -def format_wth(wth, flags): - units = UNITS[flags["units"] if "units" in flags and flags["units"] in UNITS else "si"] - return ("{temperature} {units[temperature]} {summary}; precipitation ({precipProbability:.0%} chance) intensity: {precipIntensity} {units[precipIntensity]}; relative humidity: {humidity:.0%}; wind speed: {windSpeed} {units[speed]} {windBearing}°; cloud coverage: {cloudCover:.0%}; pressure: {pressure} {units[pressure]}; ozone: {ozone} DU" - .format(units=units, **wth) - ) +def help_full (): + return "!weather /city/: Display the current weather in /city/." -def format_forecast_daily(wth, flags): - units = UNITS[flags["units"] if "units" in flags and flags["units"] in UNITS else "si"] - print(units) - return ("{summary}; between {temperatureMin}-{temperatureMax} {units[temperature]}; precipitation ({precipProbability:.0%} chance) intensity: maximum {precipIntensity} {units[precipIntensity]}; relative humidity: {humidity:.0%}; wind speed: {windSpeed} {units[speed]} {windBearing}°; cloud coverage: {cloudCover:.0%}; pressure: {pressure} {units[pressure]}; ozone: {ozone} DU".format(units=units, **wth)) +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"]) + )) + + +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"]) + )) def format_timestamp(timestamp, tzname, tzoffset, format="%c"): @@ -120,82 +121,60 @@ def treat_coord(msg): coords.append(geocode[0]["latLng"]["lng"]) return mapquest.where(geocode[0]), coords, specific - raise IMException("Je ne sais pas où se trouve %s." % city) + raise IRCException("Je ne sais pas où se trouve %s." % city) else: - raise IMException("indique-moi un nom de ville ou des coordonnées.") + raise IRCException("indique-moi un nom de ville ou des coordonnées.") -def get_json_weather(coords, lang="en", units="ca"): - wth = web.getJSON(URL_DSAPI % (float(coords[0]), float(coords[1]), lang, units)) +def get_json_weather(coords): + wth = web.getJSON(URL_DSAPI % (float(coords[0]), float(coords[1]))) # First read flags if wth is None or "darksky-unavailable" in wth["flags"]: - raise IMException("The given location is supported but a temporary error (such as a radar station being down for maintenace) made data unavailable.") + raise IRCException("The given location is supported but a temporary error (such as a radar station being down for maintenace) made data unavailable.") return wth -@hook.command("coordinates") +@hook("cmd_hook", "coordinates") def cmd_coordinates(msg): if len(msg.args) < 1: - raise IMException("indique-moi un nom de ville.") + raise IRCException("indique-moi un nom de ville.") j = msg.args[0].lower() if j not in context.data.index: - raise IMException("%s n'est pas une ville connue" % msg.args[0]) + raise IRCException("%s n'est pas une ville connue" % msg.args[0]) coords = context.data.index[j] return Response("Les coordonnées de %s sont %s,%s" % (msg.args[0], coords["lat"], coords["long"]), channel=msg.channel) -@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: ca", - }) +@hook("cmd_hook", "alert") def cmd_alert(msg): loc, coords, specific = treat_coord(msg) - 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 "ca") + wth = get_json_weather(coords) res = Response(channel=msg.channel, nomore="No more weather alert", count=" (%d more alerts)") if "alerts" in wth: for alert in wth["alerts"]: - if "expires" in alert: - res.append_message("\x03\x02%s\x03\x02 (see %s expire on %s): %s" % (alert["title"], alert["uri"], format_timestamp(int(alert["expires"]), wth["timezone"], wth["offset"]), alert["description"].replace("\n", " "))) - else: - res.append_message("\x03\x02%s\x03\x02 (see %s): %s" % (alert["title"], alert["uri"], alert["description"].replace("\n", " "))) + res.append_message("\x03\x02%s\x03\x02 (see %s expire on %s): %s" % (alert["title"], alert["uri"], format_timestamp(int(alert["expires"]), wth["timezone"], wth["offset"]), alert["description"].replace("\n", " "))) return res -@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: ca", - }) +@hook("cmd_hook", "météo") def cmd_weather(msg): loc, coords, specific = treat_coord(msg) - 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 "ca") + wth = get_json_weather(coords) res = Response(channel=msg.channel, nomore="No more weather information") if "alerts" in wth: alert_msgs = list() for alert in wth["alerts"]: - if "expires" in alert: - alert_msgs.append("\x03\x02%s\x03\x02 expire on %s" % (alert["title"], format_timestamp(int(alert["expires"]), wth["timezone"], wth["offset"]))) - else: - alert_msgs.append("\x03\x02%s\x03\x02" % (alert["title"])) + alert_msgs.append("\x03\x02%s\x03\x02 expire on %s" % (alert["title"], format_timestamp(int(alert["expires"]), wth["timezone"], wth["offset"]))) res.append_message("\x03\x16\x03\x02/!\\\x03\x02 Alert%s:\x03\x16 " % ("s" if len(alert_msgs) > 1 else "") + ", ".join(alert_msgs)) if specific is not None: @@ -207,17 +186,17 @@ def cmd_weather(msg): if gr.group(2).lower() == "h" and gr1 < len(wth["hourly"]["data"]): hour = wth["hourly"]["data"][gr1] - res.append_message("\x03\x02At %sh:\x03\x02 %s" % (format_timestamp(int(hour["time"]), wth["timezone"], wth["offset"], '%H'), format_wth(hour, wth["flags"]))) + res.append_message("\x03\x02At %sh:\x03\x02 %s" % (format_timestamp(int(hour["time"]), wth["timezone"], wth["offset"], '%H'), format_wth(hour))) elif gr.group(2).lower() == "d" and gr1 < len(wth["daily"]["data"]): day = wth["daily"]["data"][gr1] - res.append_message("\x03\x02On %s:\x03\x02 %s" % (format_timestamp(int(day["time"]), wth["timezone"], wth["offset"], '%A'), format_forecast_daily(day, wth["flags"]))) + res.append_message("\x03\x02On %s:\x03\x02 %s" % (format_timestamp(int(day["time"]), wth["timezone"], wth["offset"], '%A'), format_forecast_daily(day))) else: res.append_message("I don't understand %s or information is not available" % specific) else: - res.append_message("\x03\x02Currently:\x03\x02 " + format_wth(wth["currently"], wth["flags"])) + res.append_message("\x03\x02Currently:\x03\x02 " + format_wth(wth["currently"])) nextres = "\x03\x02Today:\x03\x02 %s " % wth["daily"]["data"][0]["summary"] if "minutely" in wth: @@ -227,11 +206,11 @@ def cmd_weather(msg): for hour in wth["hourly"]["data"][1:4]: res.append_message("\x03\x02At %sh:\x03\x02 %s" % (format_timestamp(int(hour["time"]), wth["timezone"], wth["offset"], '%H'), - format_wth(hour, wth["flags"]))) + format_wth(hour))) for day in wth["daily"]["data"][1:]: res.append_message("\x03\x02On %s:\x03\x02 %s" % (format_timestamp(int(day["time"]), wth["timezone"], wth["offset"], '%A'), - format_forecast_daily(day, wth["flags"]))) + format_forecast_daily(day))) return res @@ -239,9 +218,9 @@ def cmd_weather(msg): gps_ask = re.compile(r"^\s*(?P.*\w)\s*(?:(?:se|est)\s+(?:trouve|situ[ée]*)\s+[aà])\s*(?P-?[0-9]+(?:[,.][0-9]+))[^0-9.](?P-?[0-9]+(?:[,.][0-9]+))\s*$", re.IGNORECASE) -@hook.ask() +@hook("ask_default") def parseask(msg): - res = gps_ask.match(msg.message) + res = gps_ask.match(msg.text) if res is not None: city_name = res.group("city").lower() gps_lat = res.group("lat").replace(",", ".") @@ -258,4 +237,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.frm) + msg.channel, msg.nick) diff --git a/modules/whois.py b/modules/whois.py index 1a5f598..32c13ea 100644 --- a/modules/whois.py +++ b/modules/whois.py @@ -1,79 +1,55 @@ # coding=utf-8 -import json import re from nemubot import context -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools.xmlparser.node import ModuleState nemubotversion = 3.4 -from nemubot.module.more import Response -from nemubot.module.networking.page import headers +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/?limit=10000' > users.json -APIEXTRACT_FILE = None def load(context): global PASSWD_FILE - if not context.config or "passwd" not in context.config: + if not context.config or not context.config.hasAttribute("passwd"): 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")) context.data.getNode("aliases").setIndex("from", "alias") + if not context.data.hasNode("pics"): + context.data.addChild(ModuleState("pics")) + context.data.getNode("pics").setIndex("login", "pict") + import nemubot.hooks - context.add_hook(nemubot.hooks.Command(cmd_whois, "whois", keywords={"lookup": "Perform a lookup of the begining of the login instead of an exact search."}), - "in","Command") + context.add_hook("cmd_hook", + nemubot.hooks.Message(cmd_whois, "whois")) class Login: - def __init__(self, line=None, login=None, uidNumber=None, firstname=None, lastname=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 = firstname + " " + lastname - try: - self.gid = "epita" + str(int(promo)) - except: - self.gid = promo + 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 get_promo(self): - if hasattr(self, "promo"): - return self.promo - if hasattr(self, "home"): - try: - return self.home.split("/")[2].replace("_", " ") - except: - return self.gid + return self.home.split("/")[2].replace("_", " ") def get_photo(self): - for url in [ "https://photos.cri.epita.fr/%s", "https://intra-bocal.epitech.eu/trombi/%s.jpg" ]: + if self.login in context.data.getNode("pics").index: + return context.data.getNode("pics").index[self.login]["url"] + for url in [ "https://static.acu.epita.fr/photos/%s", "https://static.acu.epita.fr/photos/%s/%%s" % self.gid, "https://intra-bocal.epitech.eu/trombi/%s.jpg", "http://whois.23.tf/p/%s/%%s.jpg" % self.gid ]: url = url % self.login try: _, status, _, _ = headers(url) @@ -84,53 +60,38 @@ class Login: return None -def login_lookup(login, search=False): +def found_login(login): 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["results"]: - 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 "") + login_ = login + ":" lsize = len(login_) - 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()) + with open(PASSWD_FILE, encoding="iso-8859-15") as f: + for l in f.readlines(): + if l[:lsize] == login_: + return Login(l.strip()) + return None def cmd_whois(msg): if len(msg.args) < 1: - raise IMException("Provide a name") + raise IRCException("Provide a name") - def format_response(t): - srch, l = t - if type(l) is Login: - pic = l.get_photo() - return "%s is %s (%s %s): %s%s" % (srch, l.cn.capitalize(), l.login, l.uid, l.get_promo(), " and looks like %s" % pic if pic is not None else "") - else: - return l % srch - - res = Response(channel=msg.channel, count=" (%d more logins)", line_treat=format_response) + res = Response(channel=msg.channel, count=" (%d more logins)") for srch in msg.args: - found = False - for l in login_lookup(srch, "lookup" in msg.kwargs): - found = True - res.append_message((srch, l)) - if not found: - res.append_message((srch, "Unknown %s :(")) + l = found_login(srch) + if l is not None: + pic = l.get_photo() + res.append_message("%s is %s (%s %s): %s%s" % (srch, l.cn.capitalize(), l.login, l.uid, l.get_promo(), " and looks like %s" % pic if pic is not None else "")) + else: + res.append_message("Unknown %s :(" % srch) return res -@hook.command("nicks") +@hook("cmd_hook", "nicks") def cmd_nicks(msg): if len(msg.args) < 1: - raise IMException("Provide a login") - nick = login_lookup(msg.args[0]) + raise IRCException("Provide a login") + nick = found_login(msg.args[0]) if nick is None: nick = msg.args[0] else: @@ -145,14 +106,14 @@ def cmd_nicks(msg): else: return Response("%s has no known alias." % nick, channel=msg.channel) -@hook.ask() +@hook("ask_default") def parseask(msg): - res = re.match(r"^(\S+)\s*('s|suis|est|is|was|were)\s+([a-zA-Z0-9_-]{3,8})$", msg.message, re.I) + res = re.match(r"^(\S+)\s*('s|suis|est|is|was|were)\s+([a-zA-Z0-9_-]{3,8})$", msg.text, re.I) if res is not None: 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.frm + nick = msg.nick if nick in context.data.getNode("aliases").index: context.data.getNode("aliases").index[nick]["to"] = login else: @@ -164,4 +125,4 @@ def parseask(msg): return Response("ok, c'est noté, %s est %s" % (nick, login), channel=msg.channel, - nick=msg.frm) + nick=msg.nick) diff --git a/modules/wolframalpha.py b/modules/wolframalpha.py index fc83815..f3bc072 100644 --- a/modules/wolframalpha.py +++ b/modules/wolframalpha.py @@ -1,118 +1,99 @@ -"""Performing search and calculation""" - -# PYTHON STUFFS ####################################################### +# coding=utf-8 from urllib.parse import quote -import re from nemubot import context -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools import web -from nemubot.module.more import Response +nemubotversion = 4.0 +from more import Response -# LOADING ############################################################# - -URL_API = "https://api.wolframalpha.com/v2/query?input=%%s&format=plaintext&appid=%s" +URL_API = "http://api.wolframalpha.com/v2/query?input=%%s&appid=%s" def load(context): global URL_API - if not context.config or "apikey" not in context.config: + if not context.config or not context.config.hasAttribute("apikey"): raise ImportError ("You need a Wolfram|Alpha API key in order to use " "this module. Add it to the module configuration: " "\n\n" - "Register at https://products.wolframalpha.com/api/") + "Register at http://products.wolframalpha.com/api/") URL_API = URL_API % quote(context.config["apikey"]).replace("%", "%%") -# MODULE CORE ######################################################### - -class WFAResults: - +class WFASearch: def __init__(self, terms): - self.wfares = web.getXML(URL_API % quote(terms), - timeout=12) - + self.terms = terms + self.wfares = web.getXML(URL_API % quote(terms)) @property def success(self): try: - return self.wfares.documentElement.hasAttribute("success") and self.wfares.documentElement.getAttribute("success") == "true" + return self.wfares["success"] == "true" except: return False - @property def error(self): if self.wfares is None: return "An error occurs during computation." - elif self.wfares.documentElement.hasAttribute("error") and self.wfares.documentElement.getAttribute("error") == "true": + elif self.wfares["error"] == "true": return ("An error occurs during computation: " + - self.wfares.getElementsByTagName("error")[0].getElementsByTagName("msg")[0].firstChild.nodeValue) - elif len(self.wfares.getElementsByTagName("didyoumeans")): + self.wfares.getNode("error").getNode("msg").getContent()) + elif self.wfares.hasNode("didyoumeans"): start = "Did you mean: " tag = "didyoumean" end = "?" - elif len(self.wfares.getElementsByTagName("tips")): + elif self.wfares.hasNode("tips"): start = "Tips: " tag = "tip" end = "" - elif len(self.wfares.getElementsByTagName("relatedexamples")): + elif self.wfares.hasNode("relatedexamples"): start = "Related examples: " tag = "relatedexample" end = "" - elif len(self.wfares.getElementsByTagName("futuretopic")): - return self.wfares.getElementsByTagName("futuretopic")[0].getAttribute("msg") + elif self.wfares.hasNode("futuretopic"): + return self.wfares.getNode("futuretopic")["msg"] else: return "An error occurs during computation" - proposal = list() - for dym in self.wfares.getElementsByTagName(tag): + for dym in self.wfares.getNode(tag + "s").getNodes(tag): if tag == "tip": - proposal.append(dym.getAttribute("text")) + proposal.append(dym["text"]) elif tag == "relatedexample": - proposal.append(dym.getAttribute("desc")) + proposal.append(dym["desc"]) else: - proposal.append(dym.firstChild.nodeValue) - + proposal.append(dym.getContent()) return start + ', '.join(proposal) + end - @property - def results(self): - for node in self.wfares.getElementsByTagName("pod"): - for subnode in node.getElementsByTagName("subpod"): - if subnode.getElementsByTagName("plaintext")[0].firstChild: - yield (node.getAttribute("title") + - ((" / " + subnode.getAttribute("title")) if subnode.getAttribute("title") else "") + ": " + - "; ".join(subnode.getElementsByTagName("plaintext")[0].firstChild.nodeValue.split("\n"))) + def nextRes(self): + try: + for node in self.wfares.getNodes("pod"): + for subnode in node.getNodes("subpod"): + if subnode.getFirstNode("plaintext").getContent() != "": + yield (node["title"] + " " + subnode["title"] + ": " + + subnode.getFirstNode("plaintext").getContent()) + except IndexError: + pass -# MODULE INTERFACE #################################################### - -@hook.command("calculate", - help="Perform search and calculation using WolframAlpha", - help_usage={ - "TERM": "Look at the given term on WolframAlpha", - "CALCUL": "Perform the computation over WolframAlpha service", - }) +@hook("cmd_hook", "calculate") def calculate(msg): if not len(msg.args): - raise IMException("Indicate a calcul to compute") + raise IRCException("Indicate a calcul to compute") - s = WFAResults(' '.join(msg.args)) + s = WFASearch(' '.join(msg.args)) - if not s.success: - raise IMException(s.error) - - res = Response(channel=msg.channel, nomore="No more results") - - for result in s.results: - res.append_message(re.sub(r' +', ' ', result)) - if len(res.messages): - res.messages.pop(0) - - return res + if s.success: + res = Response(channel=msg.channel, nomore="No more results") + for result in s.nextRes: + res.append_message(result) + if (len(res.messages) > 0): + res.messages.pop(0) + return res + else: + return Response(s.error, msg.channel) diff --git a/modules/worldcup.py b/modules/worldcup.py index e72f1ac..1cd49dc 100644 --- a/modules/worldcup.py +++ b/modules/worldcup.py @@ -1,28 +1,27 @@ # coding=utf-8 -"""The 2014,2018 football worldcup module""" +"""The 2014 football worldcup module""" from datetime import datetime, timezone -from functools import partial import json import re from urllib.parse import quote from urllib.request import urlopen from nemubot import context -from nemubot.event import ModuleEvent -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools.xmlparser.node import ModuleState nemubotversion = 3.4 -from nemubot.module.more import Response +from more import Response API_URL="http://worldcup.sfg.io/%s" def load(context): - context.add_event(ModuleEvent(func=partial(lambda url: urlopen(url, timeout=10).read().decode(), API_URL % "matches/current?by_date=DESC"), call=current_match_new_action, interval=30)) + from nemubot.event import ModuleEvent + context.add_event(ModuleEvent(func=lambda url: urlopen(url, timeout=10).read().decode(), func_data=API_URL % "matches/current?by_date=DESC", call=current_match_new_action, interval=30)) def help_full (): @@ -33,13 +32,13 @@ def start_watch(msg): w = ModuleState("watch") w["server"] = msg.server w["channel"] = msg.channel - w["proprio"] = msg.frm + w["proprio"] = msg.nick w["start"] = datetime.now(timezone.utc) context.data.addChild(w) context.save() - raise IMException("This channel is now watching world cup events!") + raise IRCException("This channel is now watching world cup events!") -@hook.command("watch_worldcup") +@hook("cmd_hook", "watch_worldcup") def cmd_watch(msg): # Get current state @@ -53,23 +52,23 @@ def cmd_watch(msg): if msg.args[0] == "stop" and node is not None: context.data.delChild(node) context.save() - raise IMException("This channel will not anymore receives world cup events.") + raise IRCException("This channel will not anymore receives world cup events.") elif msg.args[0] == "start" and node is None: start_watch(msg) else: - raise IMException("Use only start or stop as first argument") + raise IRCException("Use only start or stop as first argument") else: if node is None: start_watch(msg) else: context.data.delChild(node) context.save() - raise IMException("This channel will not anymore receives world cup events.") + raise IRCException("This channel will not anymore receives world cup events.") -def current_match_new_action(matches): - def cmp(om, nm): - return len(nm) and (len(om) == 0 or len(nm[0]["home_team_events"]) != len(om[0]["home_team_events"]) or len(nm[0]["away_team_events"]) != len(om[0]["away_team_events"])) - context.add_event(ModuleEvent(func=partial(lambda url: json.loads(urlopen(url).read().decode()), API_URL % "matches/current?by_date=DESC"), cmp=partial(cmp, matches), call=current_match_new_action, interval=30)) +def current_match_new_action(match_str, osef): + context.add_event(ModuleEvent(func=lambda url: urlopen(url).read().decode(), func_data=API_URL % "matches/current?by_date=DESC", call=current_match_new_action, interval=30)) + + matches = json.loads(match_str) for match in matches: if is_valid(match): @@ -121,19 +120,20 @@ def detail_event(evt): return evt + " par" def txt_event(e): - return "%s minute : %s %s (%s)" % (e["time"], detail_event(e["type_of_event"]), e["player"], e["team"]["code"]) + return "%se minutes : %s %s (%s)" % (e["time"], detail_event(e["type_of_event"]), e["player"], e["team"]["code"]) def prettify(match): - matchdate = datetime.strptime(match["datetime"].replace(':', ''), "%Y-%m-%dT%H%M%SZ").replace(tzinfo=timezone.utc) + matchdate_local = datetime.strptime(match["datetime"].replace(':', ''), "%Y-%m-%dT%H%M%S.%f%z") + matchdate = matchdate_local - (matchdate_local.utcoffset() - datetime.timedelta(hours=2)) if match["status"] == "future": - return ["Match à venir (%s) le %s : %s vs. %s" % (match["fifa_id"], matchdate.strftime("%A %d à %H:%M"), match["home_team"]["country"], match["away_team"]["country"])] + return ["Match à venir (%s) le %s : %s vs. %s" % (match["match_number"], matchdate.strftime("%A %d à %H:%M"), match["home_team"]["country"], match["away_team"]["country"])] else: msgs = list() msg = "" if match["status"] == "completed": - msg += "Match (%s) du %s terminé : " % (match["fifa_id"], matchdate.strftime("%A %d à %H:%M")) + msg += "Match (%s) du %s terminé : " % (match["match_number"], matchdate.strftime("%A %d à %H:%M")) else: - msg += "Match en cours (%s) depuis %d minutes : " % (match["fifa_id"], (datetime.now(tz=timezone.utc) - matchdate).total_seconds() / 60) + msg += "Match en cours (%s) depuis %d minutes : " % (match["match_number"], (datetime.now(matchdate.tzinfo) - matchdate_local).seconds / 60) msg += "%s %d - %d %s" % (match["home_team"]["country"], match["home_team"]["goals"], match["away_team"]["goals"], match["away_team"]["country"]) @@ -163,21 +163,21 @@ def is_valid(match): def get_match(url, matchid): allm = get_matches(url) for m in allm: - if int(m["fifa_id"]) == matchid: + if int(m["match_number"]) == matchid: return [ m ] def get_matches(url): try: raw = urlopen(url) except: - raise IMException("requête invalide") + raise IRCException("requête invalide") matches = json.loads(raw.read().decode()) for match in matches: if is_valid(match): yield match -@hook.command("worldcup") +@hook("cmd_hook", "worldcup") def cmd_worldcup(msg): res = Response(channel=msg.channel, nomore="No more match to display", count=" (%d more matches)") @@ -192,9 +192,9 @@ def cmd_worldcup(msg): elif len(msg.args[0]) == 3: url = "matches/country?fifa_code=%s&by_date=DESC" % msg.args[0] elif is_int(msg.args[0]): - url = int(msg.args[0]) + url = int(msg.arg[0]) else: - raise IMException("unrecognized request; choose between 'today', 'tomorrow', a FIFA country code or a match identifier") + raise IRCException("unrecognized request; choose between 'today', 'tomorrow', a FIFA country code or a match identifier") if url is None: url = "matches/current?by_date=ASC" diff --git a/modules/youtube-title.py b/modules/youtube-title.py index 41b613a..4bf115c 100644 --- a/modules/youtube-title.py +++ b/modules/youtube-title.py @@ -1,10 +1,10 @@ from urllib.parse import urlparse import re, json, subprocess -from nemubot.exception import IMException +from nemubot.exception import IRCException from nemubot.hooks import hook from nemubot.tools.web import _getNormalizedURL, getURLContent -from nemubot.module.more import Response +from more import Response """Get information of youtube videos""" @@ -19,7 +19,7 @@ def _get_ytdl(links): res = [] with subprocess.Popen(cmd, stdout=subprocess.PIPE) as p: if p.wait() > 0: - raise IMException("Error while retrieving video information.") + raise IRCException("Error while retrieving video information.") for line in p.stdout.read().split(b"\n"): localres = '' if not line: @@ -46,13 +46,13 @@ def _get_ytdl(links): localres += ' | ' + info['webpage_url'] res.append(localres) if not res: - raise IMException("No video information to retrieve about this. Sorry!") + raise IRCException("No video information to retrieve about this. Sorry!") return res LAST_URLS = dict() -@hook.command("yt") +@hook("cmd_hook", "yt") def get_info_yt(msg): links = list() @@ -61,7 +61,7 @@ def get_info_yt(msg): if msg.channel in LAST_URLS and len(LAST_URLS[msg.channel]) > 0: links.append(LAST_URLS[msg.channel].pop()) else: - raise IMException("I don't have any youtube URL for now, please provide me one to get information!") + raise IRCException("I don't have any youtube URL for now, please provide me one to get information!") else: for url in msg.args: links.append(url) @@ -73,23 +73,23 @@ def get_info_yt(msg): return res -@hook.message() +@hook("msg_default") def parselisten(msg): parseresponse(msg) return None -@hook.post() +@hook("all_post") def parseresponse(msg): global LAST_URLS - if hasattr(msg, "text") and msg.text and type(msg.text) == str: + if hasattr(msg, "text") and msg.text: urls = re.findall("([a-zA-Z0-9+.-]+:(?://)?[^ :]+)", msg.text) for url in urls: o = urlparse(_getNormalizedURL(url)) if o.scheme != "": if o.netloc == "" and len(o.path) < 10: continue - for recv in msg.to: + for recv in msg.receivers: if recv not in LAST_URLS: LAST_URLS[recv] = list() LAST_URLS[recv].append(url) diff --git a/nemubot/__init__.py b/nemubot/__init__.py index 62807c6..9f02039 100644 --- a/nemubot/__init__.py +++ b/nemubot/__init__.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # @@ -17,9 +19,9 @@ __version__ = '4.0.dev3' __author__ = 'nemunaire' -from nemubot.modulecontext import _ModuleContext +from nemubot.modulecontext import ModuleContext -context = _ModuleContext() +context = ModuleContext(None, None) def requires_version(min=None, max=None): @@ -39,15 +41,11 @@ def requires_version(min=None, max=None): "but this is nemubot v%s." % (str(max), __version__)) -def attach(pidfile, socketfile): +def attach(pid, socketfile): import socket import sys - # Read PID from pidfile - with open(pidfile, "r") as f: - pid = int(f.readline()) - - print("nemubot is launched with PID %d. Attaching to Unix socket at: %s" % (pid, socketfile)) + print("nemubot is already launched with PID %d. Attaching to Unix socket at: %s" % (pid, socketfile)) sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: @@ -57,50 +55,42 @@ def attach(pidfile, socketfile): sys.stderr.write("\n") return 1 - import select - mypoll = select.poll() - - mypoll.register(sys.stdin.fileno(), select.POLLIN | select.POLLPRI) - mypoll.register(sock.fileno(), select.POLLIN | select.POLLPRI) + from select import select try: + print("Connection established.") while True: - for fd, flag in mypoll.poll(): - if flag & (select.POLLERR | select.POLLHUP | select.POLLNVAL): - sock.close() - print("Connection closed.") - return 1 + rl, wl, xl = select([sys.stdin, sock], [], []) - 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...") + 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...") - 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') - - if fd == sock.fileno(): - sys.stdout.write(sock.recv(2048).decode()) + else: + sock.send(line.encode() + b'\r\n') + if sock in rl: + sys.stdout.write(sock.recv(2048).decode()) except KeyboardInterrupt: pass except: @@ -110,7 +100,7 @@ def attach(pidfile, socketfile): return 0 -def daemonize(socketfile=None): +def daemonize(): """Detach the running process to run as a daemon """ @@ -146,3 +136,54 @@ def daemonize(socketfile=None): os.dup2(si.fileno(), sys.stdin.fileno()) os.dup2(so.fileno(), sys.stdout.fileno()) os.dup2(se.fileno(), sys.stderr.fileno()) + + +def reload(): + """Reload code of all Python modules used by nemubot + """ + + import imp + + import nemubot.bot + imp.reload(nemubot.bot) + + import nemubot.channel + imp.reload(nemubot.channel) + + import nemubot.consumer + imp.reload(nemubot.consumer) + + import nemubot.event + imp.reload(nemubot.event) + + import nemubot.exception + imp.reload(nemubot.exception) + + import nemubot.hooks + imp.reload(nemubot.hooks) + + nemubot.hooks.reload() + + import nemubot.importer + imp.reload(nemubot.importer) + + import nemubot.message + imp.reload(nemubot.message) + + nemubot.message.reload() + + import nemubot.server + rl = nemubot.server._rlist + wl = nemubot.server._wlist + xl = nemubot.server._xlist + imp.reload(nemubot.server) + nemubot.server._rlist = rl + nemubot.server._wlist = wl + nemubot.server._xlist = xl + + nemubot.server.reload() + + import nemubot.tools + imp.reload(nemubot.tools) + + nemubot.tools.reload() diff --git a/nemubot/__main__.py b/nemubot/__main__.py index 7070639..efc31bd 100644 --- a/nemubot/__main__.py +++ b/nemubot/__main__.py @@ -1,5 +1,7 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2017 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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 @@ -37,9 +39,6 @@ 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") @@ -71,27 +70,35 @@ def main(): # Resolve relatives paths args.data_path = os.path.abspath(os.path.expanduser(args.data_path)) - args.pidfile = os.path.abspath(os.path.expanduser(args.pidfile)) if args.pidfile is not None and args.pidfile != "" else None - args.socketfile = os.path.abspath(os.path.expanduser(args.socketfile)) if args.socketfile is not None and args.socketfile != "" else None + args.pidfile = os.path.abspath(os.path.expanduser(args.pidfile)) + args.socketfile = os.path.abspath(os.path.expanduser(args.socketfile)) args.logfile = os.path.abspath(os.path.expanduser(args.logfile)) - 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)] + 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)] - # Prepare the attached client, before setting other stuff - if not args.debug and not args.no_attach and args.socketfile is not None and args.pidfile is not None: + # 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: - pid = os.fork() - if pid > 0: - import time - os.waitpid(pid, 0) - time.sleep(1) - from nemubot import attach - sys.exit(attach(args.pidfile, args.socketfile)) - except OSError as err: - sys.stderr.write("Unable to fork: %s\n" % err) - sys.exit(1) + os.kill(pid, 0) + except OSError: + pass + else: + from nemubot import attach + sys.exit(attach(pid, args.socketfile)) - # Setup logging interface + # 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 loggin interface import logging logger = logging.getLogger("nemubot") logger.setLevel(logging.DEBUG) @@ -110,18 +117,6 @@ 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(args.pidfile, args.socketfile)) - # Add modules dir paths modules_paths = list() for path in args.modules_path: @@ -135,7 +130,7 @@ def main(): from nemubot.bot import Bot context = Bot(modules_paths=modules_paths, data_store=datastore.XML(args.data_path), - debug=args.verbose > 0) + verbosity=args.verbose) if args.no_connect: context.noautoconnect = True @@ -147,55 +142,14 @@ def main(): # Load requested configuration files for path in args.files: - if not os.path.isfile(path): + if os.path.isfile(path): + context.sync_queue.put_nowait(["loadconf", path]) + else: 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: - # Add the server in the context - for i in [0,1,2,3]: - srv = server.server(config, trynb=i) - try: - if context.add_server(srv): - logger.info("Server '%s' successfully added.", srv.name) - else: - logger.error("Can't add server '%s'.", srv.name) - except Exception as e: - logger.error("Unable to connect to '%s': %s", srv.name, e) - continue - break - - # Load module and their configuration - for mod in config.modules: - context.modules_configuration[mod.name] = mod - if mod.autoload: - try: - __import__("nemubot.module." + 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: - __import__("nemubot.module." + 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(args.socketfile) + __import__(module) # Signals handling def sigtermhandler(signum, frame): @@ -206,34 +160,43 @@ def main(): def sighuphandler(signum, frame): """On SIGHUP, perform a deep reload""" - nonlocal context + import imp + nonlocal nemubot, context, module_finder logger.debug("SIGHUP receive, iniate reload procedure...") + # Reload nemubot Python modules + imp.reload(nemubot) + nemubot.reload() + + # Hotswap context + import nemubot.bot + context = nemubot.bot.hotswap(context) + + # Reload ModuleFinder + sys.meta_path.remove(module_finder) + module_finder = ModuleFinder(context.modules_paths, context.add_module) + sys.meta_path.append(module_finder) + # Reload configuration file for path in args.files: if os.path.isfile(path): - sync_act("loadconf", path) + context.sync_queue.put_nowait(["loadconf", path]) signal.signal(signal.SIGHUP, sighuphandler) def sigusr1handler(signum, frame): """On SIGHUSR1, display stacktraces""" - import threading, traceback + import traceback for threadId, stack in sys._current_frames().items(): - thName = "#%d" % threadId - for th in threading.enumerate(): - if th.ident == threadId: - thName = th.name - break - logger.debug("########### Thread %s:\n%s", - thName, + logger.debug("########### Thread %d:\n%s", + threadId, "".join(traceback.format_stack(stack))) signal.signal(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())) + if args.socketfile: + from nemubot.server.socket import SocketListener + context.add_server(SocketListener(context.add_server, "master_socket", + sock_location=args.socketfile)) # context can change when performing an hotswap, always join the latest context oldcontext = None @@ -244,36 +207,7 @@ def main(): # Wait for consumers logger.info("Waiting for other threads shuts down...") - if args.debug: - sigusr1handler(0, None) 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 2b6e15c..a12dd7a 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -1,5 +1,7 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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,13 +16,10 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import logging -from multiprocessing import JoinableQueue import threading -import select import sys -import weakref from nemubot import __version__ from nemubot.consumer import Consumer, EventConsumer, MessageConsumer @@ -29,35 +28,27 @@ import nemubot.hooks logger = logging.getLogger("nemubot") -sync_queue = JoinableQueue() - -def sync_act(*args): - sync_queue.put(list(args)) - 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(), verbosity=0): """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 + modules_paths -- Paths to all directories where looking for module data_store -- An instance of the nemubot datastore for bot's modules - debug -- enable debug """ - super().__init__(name="Nemubot main") + threading.Thread.__init__(self) - logger.info("Initiate nemubot v%s (running on Python %s.%s.%s)", - __version__, - sys.version_info.major, sys.version_info.minor, sys.version_info.micro) + logger.info("Initiate nemubot v%s", __version__) - self.debug = debug - self.stop = True + self.verbosity = verbosity + self.stop = None # External IP for accessing this bot import ipaddress @@ -69,7 +60,6 @@ class Bot(threading.Thread): self.datastore.open() # Keep global context: servers and modules - self._poll = select.poll() self.servers = dict() self.modules = dict() self.modules_configuration = dict() @@ -84,44 +74,41 @@ class Bot(threading.Thread): import re def in_ping(msg): - return msg.respond("pong") - self.treater.hm.add_hook(nemubot.hooks.Message(in_ping, - match=lambda msg: re.match("^ *(m[' ]?entends?[ -]+tu|h?ear me|do you copy|ping)", - msg.message, re.I)), - "in", "DirectAsk") + if re.match("^ *(m[' ]?entends?[ -]+tu|h?ear me|do you copy|ping)", msg.message, re.I) is not None: + return msg.respond("pong") + self.treater.hm.add_hook(nemubot.hooks.Message(in_ping), "in", "DirectAsk") def in_echo(msg): from nemubot.message import Text - return Text(msg.frm + ": " + " ".join(msg.args), to=msg.to_response) - self.treater.hm.add_hook(nemubot.hooks.Command(in_echo, "echo"), "in", "Command") + return Text(msg.nick + ": " + " ".join(msg.args), to=msg.to_response) + self.treater.hm.add_hook(nemubot.hooks.Message(in_echo, "echo"), "in", "Command") def _help_msg(msg): """Parse and response to help messages""" - from nemubot.module.more import Response + from more import Response res = Response(channel=msg.to_response) if len(msg.args) >= 1: - 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 msg.args[0] in self.modules: + 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[mname]().__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:])): - if h.help_usage: - lp = ["\x03\x02%s%s\x03\x02: %s" % (msg.args[0], (" " + k if k is not None else ""), h.help_usage[k]) for k in h.help_usage] - jp = h.keywords.help() - return res.append_message(lp + ([". Moreover, you can provides some optional parameters: "] + jp if len(jp) else []), title="Usage for command %s" % msg.args[0]) - elif h.help: - return res.append_message("Command %s: %s" % (msg.args[0], h.help)) - else: - return res.append_message("Sorry, there is currently no help for the command %s. Feel free to make a pull request at https://github.com/nemunaire/nemubot/compare" % msg.args[0]) - res.append_message("Sorry, there is no command %s" % msg.args[0]) + for module in self.modules: + for (s, h) in self.modules[module].__nemubot_context__.hooks: + if s == "in_Command" and (h.name is not None or h.regexp is not None) and h.is_matching(msg.args[0][1:]): + if h.help_usage: + return res.append_message(["\x03\x02%s%s\x03\x02: %s" % (msg.args[0], " " + (k if k is not None else ""), h.help_usage[k]) for k in h.help_usage], title="Usage for command %s from module %s" % (msg.args[0], module)) + elif h.help: + return res.append_message("Command %s from module %s: %s" % (msg.args[0], module, h.help)) + else: + return res.append_message("Sorry, there is currently no help for the command %s. Feel free to make a pull request at https://github.com/nemunaire/nemubot/compare" % msg.args[0]) + else: + res.append_message("Sorry, there is no command %s" % msg.args[0]) else: res.append_message("Sorry, there is no module named %s" % msg.args[0]) else: @@ -135,120 +122,93 @@ class Bot(threading.Thread): "Vous pouvez le consulter, le dupliquer, " "envoyer des rapports de bogues ou bien " "contribuer au projet sur GitHub : " - "https://github.com/nemunaire/nemubot/") + "http://github.com/nemunaire/nemubot/") 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, self.modules[im].__doc__) for im in self.modules if self.modules[im].__doc__]) return res - self.treater.hm.add_hook(nemubot.hooks.Command(_help_msg, "help"), "in", "Command") + self.treater.hm.add_hook(nemubot.hooks.Message(_help_msg, "help"), "in", "Command") - import os from queue import Queue - # Messages to be treated — shared across all server connections. - # cnsr_active tracks consumers currently inside stm.run() (not idle), - # which lets us spawn a new thread the moment all existing ones are busy. - self.cnsr_queue = Queue() - self.cnsr_thrd = list() - self.cnsr_lock = threading.Lock() - self.cnsr_active = 0 # consumers currently executing a task - self.cnsr_max = os.cpu_count() or 4 # upper bound on concurrent consumer threads - - - def __del__(self): - self.datastore.close() + # Messages to be treated + self.cnsr_queue = Queue() + self.cnsr_thrd = list() + self.cnsr_thrd_size = -1 + # Synchrone actions to be treated by main thread + self.sync_queue = Queue() 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) - - - self.stop = False - - # Relaunch events - self._update_event_timer() + from select import select + from nemubot.server import _lock, _rlist, _wlist, _xlist logger.info("Starting main loop") + self.stop = False while not self.stop: - for fd, flag in self._poll.poll(): - # Handle internal socket passing orders - if fd != sync_queue._reader.fileno() and fd in self.servers: - srv = self.servers[fd] - - if flag & (select.POLLERR | select.POLLHUP | select.POLLNVAL): - try: - srv.exception(flag) - except: - logger.exception("Uncatched exception on server exception") - - if srv.fileno() > 0: - if flag & (select.POLLOUT): - try: - srv.async_write() - except: - logger.exception("Uncatched exception on server write") - - if flag & (select.POLLIN | select.POLLPRI): - try: - for i in srv.async_read(): - self.receive_message(srv, i) - except: - logger.exception("Uncatched exception on server read") - - else: - del self.servers[fd] - - - # Always check the sync queue - while not sync_queue.empty(): - 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": - self._poll.modify(int(args[1]), select.POLLOUT | select.POLLIN | select.POLLPRI) - elif args[0] == "unwrite": - self._poll.modify(int(args[1]), select.POLLIN | select.POLLPRI) - - elif args[0] == "register": - self._poll.register(int(args[1]), select.POLLIN | select.POLLPRI) - elif args[0] == "unregister": - try: - self._poll.unregister(int(args[1])) - except KeyError: - pass - except: - logger.exception("Unhandled excpetion during action:") - - elif action == "exit": + with _lock: + try: + rl, wl, xl = select(_rlist, _wlist, _xlist, 0.1) + except: + logger.error("Something went wrong in select") + fnd_smth = False + # Looking for invalid server + for r in _rlist: + if not hasattr(r, "fileno") or not isinstance(r.fileno(), int) or r.fileno() < 0: + _rlist.remove(r) + logger.error("Found invalid object in _rlist: " + str(r)) + fnd_smth = True + for w in _wlist: + if not hasattr(w, "fileno") or not isinstance(w.fileno(), int) or w.fileno() < 0: + _wlist.remove(w) + logger.error("Found invalid object in _wlist: " + str(w)) + fnd_smth = True + for x in _xlist: + if not hasattr(x, "fileno") or not isinstance(x.fileno(), int) or x.fileno() < 0: + _xlist.remove(x) + logger.error("Found invalid object in _xlist: " + str(x)) + fnd_smth = True + if not fnd_smth: + logger.exception("Can't continue, sorry") self.quit() + continue - elif action == "launch_consumer": - pass # This is treated after the loop - - sync_queue.task_done() + for x in xl: + try: + x.exception() + except: + logger.exception("Uncatched exception on server exception") + for w in wl: + try: + w.write_select() + except: + logger.exception("Uncatched exception on server write") + for r in rl: + for i in r.read(): + try: + self.receive_message(r, i) + except: + logger.exception("Uncatched exception on server read") - # Spawn a new consumer whenever the queue has work and every - # existing consumer is already busy executing a task. - with self.cnsr_lock: - while (not self.cnsr_queue.empty() - and self.cnsr_active >= len(self.cnsr_thrd) - and len(self.cnsr_thrd) < self.cnsr_max): - c = Consumer(self) - self.cnsr_thrd.append(c) - c.start() - sync_queue = None + # Launch new consumer threads if necessary + while self.cnsr_queue.qsize() > self.cnsr_thrd_size: + # Next launch if two more items in queue + self.cnsr_thrd_size += 2 + + c = Consumer(self) + self.cnsr_thrd.append(c) + c.start() + + while self.sync_queue.qsize() > 0: + action = self.sync_queue.get_nowait() + if action[0] == "exit": + self.quit() + elif action[0] == "loadconf": + for path in action[1:]: + from nemubot.tools.config import load_file + load_file(path, self) + self.sync_queue.task_done() logger.info("Ending main loop") @@ -270,6 +230,10 @@ class Bot(threading.Thread): 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 + import uuid # Generate the event id if no given @@ -280,7 +244,7 @@ class Bot(threading.Thread): if type(eid) is uuid.UUID: evt.id = str(eid) else: - # Ok, this is quiet useless... + # Ok, this is quite useless... try: evt.id = str(uuid.UUID(eid)) except ValueError: @@ -296,7 +260,7 @@ class Bot(threading.Thread): break self.events.insert(i, evt) - if i == 0 and not self.stop: + if i == 0: # First event changed, reset timer self._update_event_timer() if len(self.events) <= 0 or self.events[i] != evt: @@ -305,10 +269,10 @@ class Bot(threading.Thread): # Register the event in the source module if module_src is not None: - module_src.__nemubot_context__.events.append((evt, evt.id)) + module_src.__nemubot_context__.events.append(evt.id) evt.module_src = module_src - logger.info("New event registered in %d position: %s", i, t) + logger.info("New event registered: %s -> %s", evt.id, evt) return evt.id @@ -335,10 +299,10 @@ class Bot(threading.Thread): id = evt if len(self.events) > 0 and id == self.events[0].id: - if module_src is not None: - module_src.__nemubot_context__.events.remove((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: @@ -346,7 +310,7 @@ class Bot(threading.Thread): self.events.remove(evt) if module_src is not None: - module_src.__nemubot_context__.events.remove((evt, evt.id)) + module_src.__nemubot_context__.events.remove(evt.id) return True return False @@ -359,15 +323,11 @@ class Bot(threading.Thread): 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) + logger.debug("Update timer: next event in %d seconds", + self.events[0].time_left.seconds) + self.event_timer = threading.Timer( + self.events[0].time_left.seconds + self.events[0].time_left.microseconds / 1000000 if datetime.now(timezone.utc) < self.events[0].current else 0, + self._end_event_timer) self.event_timer.start() else: @@ -380,7 +340,6 @@ 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() @@ -395,12 +354,10 @@ class Bot(threading.Thread): autoconnect -- connect after add? """ - fileno = srv.fileno() - if fileno not in self.servers: - self.servers[fileno] = srv - self.servers[srv.name] = srv + if srv.id not in self.servers: + self.servers[srv.id] = srv if autoconnect and not hasattr(self, "noautoconnect"): - srv.connect() + srv.open() return True else: @@ -427,6 +384,10 @@ class Bot(threading.Thread): old one before""" module_name = module.__spec__.name if hasattr(module, "__spec__") else module.__name__ + if hasattr(self, "stop") and self.stop: + logger.warn("The bot is stopped, can't register new modules") + return + # Check if the module already exists if module_name in self.modules: self.unload_module(module_name) @@ -440,7 +401,7 @@ class Bot(threading.Thread): module.print = prnt # Create module context - from nemubot.modulecontext import _ModuleContext, ModuleContext + from nemubot.modulecontext import ModuleContext module.__nemubot_context__ = ModuleContext(self, module) if not hasattr(module, "logger"): @@ -448,14 +409,14 @@ 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 type(module.__dict__[attr]) == ModuleContext: module.__dict__[attr] = module.__nemubot_context__ # Register decorated functions import nemubot.hooks - for s, h in nemubot.hooks.hook.last_registered: - module.__nemubot_context__.add_hook(h, *s if isinstance(s, list) else s) - nemubot.hooks.hook.last_registered = [] + for s, h in nemubot.hooks.last_registered: + module.__nemubot_context__.add_hook(s, h) + nemubot.hooks.last_registered = [] # Launch the module if hasattr(module, "load"): @@ -466,20 +427,18 @@ class Bot(threading.Thread): raise # Save a reference to the module - self.modules[module_name] = weakref.ref(module) - logger.info("Module '%s' successfully loaded.", module_name) + self.modules[module_name] = module def unload_module(self, name): """Unload a module""" - if name in self.modules and self.modules[name]() is not None: - module = self.modules[name]() - module.print("Unloading module %s" % name) + if name in self.modules: + self.modules[name].print("Unloading module %s" % name) # Call the user defined unload method - if hasattr(module, "unload"): - module.unload(self) - module.__nemubot_context__.unload() + if hasattr(self.modules[name], "unload"): + self.modules[name].unload(self) + self.modules[name].__nemubot_context__.unload() # Remove from the nemubot dict del self.modules[name] @@ -511,28 +470,28 @@ class Bot(threading.Thread): def quit(self): """Save and unload modules and disconnect servers""" + self.datastore.close() + 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) - - logger.info("Close all servers connection...") - for srv in [self.servers[k] for k in self.servers]: - srv.close() - logger.info("Stop consumers") - with self.cnsr_lock: - k = list(self.cnsr_thrd) + k = self.cnsr_thrd for cnsr in k: cnsr.stop = True - if self.stop is False or sync_queue is not None: - self.stop = True - sync_act("end") - sync_queue.join() + logger.info("Save and unload all modules...") + k = list(self.modules.keys()) + for mod in k: + self.unload_module(mod) + + logger.info("Close all servers connection...") + k = list(self.servers.keys()) + for srv in k: + self.servers[srv].close() + + self.stop = True # Treatment @@ -546,3 +505,22 @@ class Bot(threading.Thread): del store[hook.name] elif isinstance(store, list): store.remove(hook) + + +def hotswap(bak): + bak.stop = True + if bak.event_timer is not None: + bak.event_timer.cancel() + + # Unload modules + for mod in [k for k in bak.modules.keys()]: + bak.unload_module(mod) + + # Save datastore + bak.datastore.close() + + new = Bot(str(bak.ip), bak.modules_paths, bak.datastore) + new.servers = bak.servers + + new._update_event_timer() + return new diff --git a/nemubot/channel.py b/nemubot/channel.py index 835c22f..45031eb 100644 --- a/nemubot/channel.py +++ b/nemubot/channel.py @@ -1,5 +1,7 @@ +# coding=utf-8 + # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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 @@ -21,18 +23,16 @@ class Channel: """A chat room""" - def __init__(self, name, password=None, encoding=None): + def __init__(self, name, password=None): """Initialize the channel Arguments: name -- the channel name password -- the optional password use to join it - encoding -- the optional encoding of the channel """ self.name = name self.password = password - self.encoding = encoding self.people = dict() self.topic = "" self.logger = logging.getLogger("nemubot.channel." + name) @@ -52,11 +52,11 @@ class Channel: elif cmd == "MODE": self.mode(msg) elif cmd == "JOIN": - self.join(msg.frm) + self.join(msg.nick) elif cmd == "NICK": - self.nick(msg.frm, msg.text) + self.nick(msg.nick, msg.text) elif cmd == "PART" or cmd == "QUIT": - self.part(msg.frm) + self.part(msg.nick) 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.frm] |= 4 + self.people[msg.nick] |= 4 elif msg.text[0] == "-o": - self.people[msg.frm] &= ~4 + self.people[msg.nick] &= ~4 elif msg.text[0] == "+h": - self.people[msg.frm] |= 2 + self.people[msg.nick] |= 2 elif msg.text[0] == "-h": - self.people[msg.frm] &= ~2 + self.people[msg.nick] &= ~2 elif msg.text[0] == "+v": - self.people[msg.frm] |= 1 + self.people[msg.nick] |= 1 elif msg.text[0] == "-v": - self.people[msg.frm] &= ~1 + self.people[msg.nick] &= ~1 def parse332(self, msg): """Parse RPL_TOPIC message diff --git a/nemubot/config/__init__.py b/nemubot/config/__init__.py deleted file mode 100644 index 6bbc1b2..0000000 --- a/nemubot/config/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -def get_boolean(s): - if isinstance(s, bool): - return s - else: - return (s and s != "0" and s.lower() != "false" and s.lower() != "off") - -from nemubot.config.include import Include -from nemubot.config.module import Module -from nemubot.config.nemubot import Nemubot -from nemubot.config.server import Server diff --git a/nemubot/config/include.py b/nemubot/config/include.py deleted file mode 100644 index 408c09a..0000000 --- a/nemubot/config/include.py +++ /dev/null @@ -1,20 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -class Include: - - def __init__(self, path): - self.path = path diff --git a/nemubot/config/module.py b/nemubot/config/module.py deleted file mode 100644 index ab51971..0000000 --- a/nemubot/config/module.py +++ /dev/null @@ -1,26 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from nemubot.config import get_boolean -from nemubot.tools.xmlparser.genericnode import GenericNode - - -class Module(GenericNode): - - def __init__(self, name, autoload=True, **kwargs): - super().__init__(None, **kwargs) - self.name = name - self.autoload = get_boolean(autoload) diff --git a/nemubot/config/nemubot.py b/nemubot/config/nemubot.py deleted file mode 100644 index 992cd8e..0000000 --- a/nemubot/config/nemubot.py +++ /dev/null @@ -1,46 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from nemubot.config.include import Include -from nemubot.config.module import Module -from nemubot.config.server import Server - - -class Nemubot: - - def __init__(self, nick="nemubot", realname="nemubot", owner=None, - ip=None, ssl=False, caps=None, encoding="utf-8"): - self.nick = nick - self.realname = realname - self.owner = owner - self.ip = ip - self.caps = caps.split(" ") if caps is not None else [] - self.encoding = encoding - self.servers = [] - self.modules = [] - self.includes = [] - - - def addChild(self, name, child): - if name == "module" and isinstance(child, Module): - self.modules.append(child) - return True - elif name == "server" and isinstance(child, Server): - self.servers.append(child) - return True - elif name == "include" and isinstance(child, Include): - self.includes.append(child) - return True diff --git a/nemubot/config/server.py b/nemubot/config/server.py deleted file mode 100644 index 17bfaee..0000000 --- a/nemubot/config/server.py +++ /dev/null @@ -1,45 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from nemubot.channel import Channel - - -class Server: - - def __init__(self, uri="irc://nemubot@localhost/", autoconnect=True, caps=None, **kwargs): - self.uri = uri - self.autoconnect = autoconnect - self.caps = caps.split(" ") if caps is not None else [] - self.args = kwargs - self.channels = [] - - - def addChild(self, name, child): - if name == "channel" and isinstance(child, Channel): - self.channels.append(child) - return True - - - def server(self, parent, trynb=0): - from nemubot.server import factory - - for a in ["nick", "owner", "realname", "encoding"]: - if a not in self.args: - self.args[a] = getattr(parent, a) - - self.caps += parent.caps - - return factory(self.uri, caps=self.caps, channels=self.channels, trynb=trynb, **self.args) diff --git a/nemubot/consumer.py b/nemubot/consumer.py index a9a4146..9c9d90d 100644 --- a/nemubot/consumer.py +++ b/nemubot/consumer.py @@ -1,5 +1,7 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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 @@ -38,37 +40,42 @@ class MessageConsumer: msgs = [] - # Parse message + # Parse the message try: for msg in self.srv.parse(self.orig): msgs.append(msg) except: logger.exception("Error occurred during the processing of the %s: " - "%s", type(self.orig).__name__, self.orig) + "%s", type(self.msgs[0]).__name__, self.msgs[0]) - # Treat message + if len(msgs) <= 0: + return + + # Qualify the message + if not hasattr(msg, "server") or msg.server is None: + msg.server = self.srv.id + if hasattr(msg, "frm_owner"): + msg.frm_owner = (not hasattr(self.srv, "owner") or self.srv.owner == msg.frm) + + # Treat the message for msg in msgs: for res in context.treater.treat_msg(msg): - # Identify destination + # Identify the destination to_server = None if isinstance(res, str): to_server = self.srv - elif not hasattr(res, "server"): - logger.error("No server defined for response of type %s: %s", type(res).__name__, res) - continue elif res.server is None: to_server = self.srv - res.server = self.srv.fileno() - elif res.server in context.servers: + res.server = self.srv.id + elif isinstance(res.server, str) and res.server in context.servers: to_server = context.servers[res.server] - else: - to_server = res.server - if to_server is None or not hasattr(to_server, "send_response") or not callable(to_server.send_response): - logger.error("The server defined in this response doesn't exist: %s", res.server) + if to_server is None: + logger.error("The server defined in this response doesn't " + "exist: %s", res.server) continue - # Sent message + # Sent the message only if treat_post authorize it to_server.send_response(res) @@ -94,7 +101,7 @@ class EventConsumer: # 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, self.evt.id)) + self.evt.module_src.__nemubot_context__.events.remove(self.evt.id) @@ -105,25 +112,18 @@ class Consumer(threading.Thread): def __init__(self, context): self.context = context self.stop = False - super().__init__(name="Nemubot consumer", daemon=True) + threading.Thread.__init__(self) def run(self): try: while not self.stop: - try: - stm = self.context.cnsr_queue.get(True, 1) - except queue.Empty: - break + stm = self.context.cnsr_queue.get(True, 1) + stm.run(self.context) + self.context.cnsr_queue.task_done() - with self.context.cnsr_lock: - self.context.cnsr_active += 1 - try: - stm.run(self.context) - finally: - self.context.cnsr_queue.task_done() - with self.context.cnsr_lock: - self.context.cnsr_active -= 1 + except queue.Empty: + pass finally: - with self.context.cnsr_lock: - self.context.cnsr_thrd.remove(self) + self.context.cnsr_thrd_size -= 2 + self.context.cnsr_thrd.remove(self) diff --git a/nemubot/datastore/__init__.py b/nemubot/datastore/__init__.py index 3e38ad2..323a160 100644 --- a/nemubot/datastore/__init__.py +++ b/nemubot/datastore/__init__.py @@ -1,5 +1,5 @@ # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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 diff --git a/nemubot/datastore/abstract.py b/nemubot/datastore/abstract.py index aeaecc6..6162d52 100644 --- a/nemubot/datastore/abstract.py +++ b/nemubot/datastore/abstract.py @@ -1,5 +1,5 @@ # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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 @@ -32,20 +32,16 @@ class Abstract: def close(self): return - def load(self, module, knodes): + def load(self, module): """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 aa6cbd0..46dca70 100644 --- a/nemubot/datastore/xml.py +++ b/nemubot/datastore/xml.py @@ -83,38 +83,27 @@ class XML(Abstract): return os.path.join(self.basedir, module + ".xml") - def load(self, module, knodes): + def load(self, module): """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 _true_load(data_file) + return parse_file(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 = _true_load(path) + cnt = parse_file(path) logger.warn("Restoring from backup: %s", path) @@ -123,7 +112,7 @@ class XML(Abstract): continue # Default case: initialize a new empty datastore - return super().load(module, knodes) + return Abstract.load(self, module) def _rotate(self, path): """Backup given path @@ -154,18 +143,4 @@ 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: - 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) + return data.save(path) diff --git a/nemubot/event/__init__.py b/nemubot/event/__init__.py index 49c6902..345200d 100644 --- a/nemubot/event/__init__.py +++ b/nemubot/event/__init__.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # @@ -21,84 +23,121 @@ class ModuleEvent: """Representation of a event initiated by a bot module""" - def __init__(self, call=None, func=None, cmp=None, interval=60, offset=0, times=1): - + def __init__(self, call=None, call_data=None, func=None, func_data=None, + cmp=None, cmp_data=None, end_call=None, end_data=None, + interval=60, offset=0, times=1, max_attempt=-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 - cmp -- Boolean function called to check changes or value to compare with + 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 + end_call -- Function called when times or max_attempt reach 0 (mainly for interaction with the event manager) + end_data -- Argument(s) (single or dict) to pass as argument 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) + max_attempt -- Maximum number of times the event will be checked """ # What have we to check? self.func = func + self.func_data = func_data # How detect a change? self.cmp = cmp + if cmp_data is not None: + self.cmp_data = cmp_data + elif callable(self.func): + 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) + else: + self.cmp_data = None # What should we call when? self.call = call - - # Store times - if isinstance(offset, timedelta): - self.offset = offset # Time to wait before the first check + if call_data is not None: + self.call_data = call_data else: - self.offset = timedelta(seconds=offset) # Time to wait before the first check - if isinstance(interval, timedelta): - self.interval = interval - else: - self.interval = timedelta(seconds=interval) - self._end = None # Cache + self.call_data = func_data + # Store time between each event + self.interval = timedelta(seconds=interval) # 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 + # Cache the time of the next occurence + self.next_occur = datetime.now(timezone.utc) + timedelta(seconds=offset) + self.interval - @property - 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 @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 + + return self.next_occur - datetime.now(timezone.utc) + def check(self): """Run a check and realized the event if this is time""" - # Get new data - if self.func is not None: - d_new = self.func() + self.max_attempt -= 1 + + # Get initial data + if not callable(self.func): + 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_new = None + d_init = self.func(self.func_data) # then compare with current data - if self.cmp is None or (callable(self.cmp) and self.cmp(d_new)) or (not callable(self.cmp) and d_new != self.cmp): + if not callable(self.cmp): + 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: self.times -= 1 # Call attended function - if self.func is not None: - self.call(d_new) + 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) else: - self.call() + self.call(d_init, self.call_data) + + # Is it finished? + if self.times == 0 or self.max_attempt == 0: + if not callable(self.end_call): + pass # TODO: log a WARN here + else: + if self.end_data is None: + self.end_call() + elif isinstance(self.end_data, dict): + self.end_call(**self.end_data) + else: + self.end_call(self.end_data) + + # Not finished, ready to next one! + else: + self.next_occur += self.interval diff --git a/nemubot/exception/__init__.py b/nemubot/exception.py similarity index 83% rename from nemubot/exception/__init__.py rename to nemubot/exception.py index 84464a0..93e6a53 100644 --- a/nemubot/exception/__init__.py +++ b/nemubot/exception.py @@ -1,3 +1,5 @@ +# coding=utf-8 + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # @@ -14,21 +16,20 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -class IMException(Exception): - +class IRCException(Exception): def __init__(self, message, personnal=True): - super(IMException, self).__init__(message) + super(IRCException, self).__init__(message) + self.message = message self.personnal = personnal - def fill_response(self, msg): if self.personnal: from nemubot.message import DirectAsk - return DirectAsk(msg.frm, *self.args, + return DirectAsk(msg.frm, self.message, server=msg.server, to=msg.to_response) else: from nemubot.message import Text - return Text(*self.args, + return Text(self.message, server=msg.server, to=msg.to_response) diff --git a/nemubot/hooks/__init__.py b/nemubot/hooks/__init__.py index 9024494..15af034 100644 --- a/nemubot/hooks/__init__.py +++ b/nemubot/hooks/__init__.py @@ -15,37 +15,29 @@ # along with this program. If not, see . from nemubot.hooks.abstract import Abstract -from nemubot.hooks.command import Command from nemubot.hooks.message import Message - -class hook: - - last_registered = [] +last_registered = [] - def _add(store, h, *args, **kwargs): - """Function used as a decorator for module loading""" - def sec(call): - hook.last_registered.append((store, h(call, *args, **kwargs))) - return call - return sec +def hook(store, *args, **kargs): + """Function used as a decorator for module loading""" + def sec(call): + last_registered.append((store, Message(call, *args, **kargs))) + return call + return sec - def add(store, *args, **kwargs): - return hook._add(store, Abstract, *args, **kwargs) +def reload(): + global Abstract, Message + import imp - def ask(*args, store=["in","DirectAsk"], **kwargs): - return hook._add(store, Message, *args, **kwargs) + import nemubot.hooks.abstract + imp.reload(nemubot.hooks.abstract) + Abstract = nemubot.hooks.abstract.Abstract + import nemubot.hooks.message + imp.reload(nemubot.hooks.message) + Message = nemubot.hooks.message.Message - def command(*args, store=["in","Command"], **kwargs): - return hook._add(store, Command, *args, **kwargs) - - def message(*args, store=["in","Text"], **kwargs): - return hook._add(store, Message, *args, **kwargs) - - def post(*args, store=["post"], **kwargs): - return hook._add(store, Abstract, *args, **kwargs) - - def pre(*args, store=["pre"], **kwargs): - return hook._add(store, Abstract, *args, **kwargs) + import nemubot.hooks.manager + imp.reload(nemubot.hooks.manager) diff --git a/nemubot/hooks/abstract.py b/nemubot/hooks/abstract.py index ffe79fb..7e9aa72 100644 --- a/nemubot/hooks/abstract.py +++ b/nemubot/hooks/abstract.py @@ -14,8 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import types - def call_game(call, *args, **kargs): """With given args, try to determine the right call to make @@ -44,95 +42,30 @@ class Abstract: """Abstract class for Hook implementation""" - def __init__(self, call, data=None, channels=None, servers=None, mtimes=-1, - end_call=None, check=None, match=None): - """Create basis of the hook - - Arguments: - call -- function to call to perform the hook - - Keyword arguments: - data -- optional datas passed to call - """ - - if channels is None: channels = list() - if servers is None: servers = list() - - assert callable(call), call - assert end_call is None or callable(end_call), end_call - assert check is None or callable(check), check - assert match is None or callable(match), match - assert isinstance(channels, list), channels - assert isinstance(servers, list), servers - assert type(mtimes) is int, mtimes - + def __init__(self, call, data=None, mtimes=-1, end_call=None): self.call = call self.data = data - self.mod_check = check - self.mod_match = match - - # TODO: find a way to have only one list: a limit is server + channel, not only server or channel - self.channels = channels - self.servers = servers - self.times = mtimes self.end_call = end_call - def can_read(self, receivers=list(), server=None): - assert isinstance(receivers, list), receivers - - if server is None or len(self.servers) == 0 or server in self.servers: - if len(self.channels) == 0: - return True - - for receiver in receivers: - if receiver in self.channels: - return True - - return False - - - def __str__(self): - return "" - - - def can_write(self, receivers=list(), server=None): - return True - - - def check(self, data1): - return self.mod_check(data1) if self.mod_check is not None else True - - - def match(self, data1): - return self.mod_match(data1) if self.mod_match is not None else True + def match(self, data1, server): + return NotImplemented def run(self, data1, *args): """Run the hook""" - from nemubot.exception import IMException + from nemubot.exception import IRCException self.times -= 1 - ret = None - try: - if self.check(data1): - ret = call_game(self.call, data1, self.data, *args) - if isinstance(ret, types.GeneratorType): - for r in ret: - yield r - ret = None - except IMException as e: + ret = call_game(self.call, data1, self.data, *args) + except IRCException as e: ret = e.fill_response(data1) finally: if self.times == 0: self.call_end(ret) - if isinstance(ret, list): - for r in ret: - yield ret - elif ret is not None: - yield ret + return ret diff --git a/nemubot/hooks/command.py b/nemubot/hooks/command.py deleted file mode 100644 index 863d672..0000000 --- a/nemubot/hooks/command.py +++ /dev/null @@ -1,67 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2015 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import re - -from nemubot.hooks.message import Message -from nemubot.hooks.abstract import Abstract -from nemubot.hooks.keywords import NoKeyword -from nemubot.hooks.keywords.abstract import Abstract as AbstractKeywords -from nemubot.hooks.keywords.dict import Dict as DictKeywords -import nemubot.message - - -class Command(Message): - - """Class storing hook information, specialized for Command messages""" - - def __init__(self, call, name=None, help_usage=dict(), keywords=NoKeyword(), - **kargs): - - super().__init__(call=call, **kargs) - - if isinstance(keywords, dict): - keywords = DictKeywords(keywords) - - assert type(help_usage) is dict, help_usage - assert isinstance(keywords, AbstractKeywords), keywords - - self.name = str(name) if name is not None else None - self.help_usage = help_usage - self.keywords = keywords - - - def __str__(self): - return "\x03\x02%s\x03\x02%s%s" % ( - self.name if self.name is not None else "\x03\x1f" + self.regexp + "\x03\x1f" if self.regexp is not None else "", - " (restricted to %:%s)" % ((",".join(self.servers) if self.server else "*") + (",".join(self.channels) if self.channels else "*")) if len(self.channels) or len(self.servers) else "", - ": %s" % self.help if self.help is not None else "" - ) - - - def check(self, msg): - return self.keywords.check(msg.kwargs) and super().check(msg) - - - def match(self, msg): - if not isinstance(msg, nemubot.message.command.Command): - return False - else: - return ( - (self.name is None or msg.cmd == self.name) and - (self.regexp is None or re.match(self.regexp, msg.cmd)) and - Abstract.match(self, msg) - ) diff --git a/nemubot/hooks/keywords/__init__.py b/nemubot/hooks/keywords/__init__.py deleted file mode 100644 index 598b04f..0000000 --- a/nemubot/hooks/keywords/__init__.py +++ /dev/null @@ -1,47 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from nemubot.exception.keyword import KeywordException -from nemubot.hooks.keywords.abstract import Abstract - - -class NoKeyword(Abstract): - - def check(self, mkw): - if len(mkw): - raise KeywordException("This command doesn't take any keyword arguments.") - return super().check(mkw) - - -class AnyKeyword(Abstract): - - def __init__(self, h): - """Class that accepts any passed keywords - - Arguments: - h -- Help string - """ - - super().__init__() - self.h = h - - - def check(self, mkw): - return super().check(mkw) - - - def help(self): - return self.h diff --git a/nemubot/hooks/keywords/abstract.py b/nemubot/hooks/keywords/abstract.py deleted file mode 100644 index a990cf3..0000000 --- a/nemubot/hooks/keywords/abstract.py +++ /dev/null @@ -1,35 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -class Abstract: - - def __init__(self): - pass - - def check(self, mkw): - """Check that all given message keywords are valid - - Argument: - mkw -- dictionnary of keywords present in the message - """ - - assert type(mkw) is dict, mkw - - return True - - - def help(self): - return "" diff --git a/nemubot/hooks/keywords/dict.py b/nemubot/hooks/keywords/dict.py deleted file mode 100644 index c2d3f2e..0000000 --- a/nemubot/hooks/keywords/dict.py +++ /dev/null @@ -1,59 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from nemubot.exception.keyword import KeywordException -from nemubot.hooks.keywords.abstract import Abstract -from nemubot.tools.human import guess - - -class Dict(Abstract): - - - def __init__(self, d): - super().__init__() - self.d = d - - - @property - def chk_noarg(self): - if not hasattr(self, "_cache_chk_noarg"): - self._cache_chk_noarg = [k for k in self.d if "=" not in k] - return self._cache_chk_noarg - - - @property - def chk_args(self): - if not hasattr(self, "_cache_chk_args"): - self._cache_chk_args = [k.split("=", 1)[0] for k in self.d if "=" in k] - return self._cache_chk_args - - - def check(self, mkw): - for k in mkw: - if ((k + "?") not in self.chk_args) and ((mkw[k] and k not in self.chk_args) or (not mkw[k] and k not in self.chk_noarg)): - if mkw[k] and k in self.chk_noarg: - raise KeywordException("Keyword %s doesn't take value." % k) - elif not mkw[k] and k in self.chk_args: - raise KeywordException("Keyword %s requires a value." % k) - else: - ch = [c for c in guess(k, self.d)] - raise KeywordException("Unknown keyword %s." % k + (" Did you mean: " + ", ".join(ch) + "?" if len(ch) else "")) - - return super().check(mkw) - - - def help(self): - return ["\x03\x02@%s\x03\x02: %s" % (k, self.d[k]) for k in self.d] diff --git a/nemubot/hooks/manager.py b/nemubot/hooks/manager.py index 6a57d2a..200091e 100644 --- a/nemubot/hooks/manager.py +++ b/nemubot/hooks/manager.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # @@ -14,47 +16,15 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import logging - class HooksManager: """Class to manage hooks""" - def __init__(self, name="core"): + def __init__(self): """Initialize the manager""" self.hooks = dict() - self.logger = logging.getLogger("nemubot.hooks.manager." + name) - - - def _access(self, *triggers): - """Access to the given triggers chain""" - - h = self.hooks - for t in triggers: - if t not in h: - h[t] = dict() - h = h[t] - - if "__end__" not in h: - h["__end__"] = list() - - return h - - - def _search(self, hook, *where, start=None): - """Search all occurence of the given hook""" - - if start is None: - start = self.hooks - - for k in start: - if k == "__end__": - if hook in start[k]: - yield where - else: - yield from self._search(hook, *where + (k,), start=start[k]) def add_hook(self, hook, *triggers): @@ -65,19 +35,20 @@ class HooksManager: triggers -- string that trigger the hook """ - assert hook is not None, hook + trigger = "_".join(triggers) - h = self._access(*triggers) + if trigger not in self.hooks: + self.hooks[trigger] = list() - h["__end__"].append(hook) - - self.logger.debug("New hook successfully added in %s: %s", - "/".join(triggers), hook) + self.hooks[trigger].append(hook) - def del_hooks(self, *triggers, hook=None): + def del_hook(self, hook=None, *triggers): """Remove the given hook from the manager + Return: + Boolean value reporting the deletion success + Argument: triggers -- trigger string to remove @@ -85,20 +56,15 @@ class HooksManager: hook -- a Hook instance to remove from the trigger string """ - assert hook is not None or len(triggers) + trigger = "_".join(triggers) - self.logger.debug("Trying to delete hook in %s: %s", - "/".join(triggers), hook) - - if hook is not None: - for h in self._search(hook, *triggers, start=self._access(*triggers)): - self._access(*h)["__end__"].remove(hook) - - else: - if len(triggers): - del self._access(*triggers[:-1])[triggers[-1]] + if trigger in self.hooks: + if hook is None: + del self.hooks[trigger] else: - self.hooks = dict() + self.hooks[trigger].remove(hook) + return True + return False def get_hooks(self, *triggers): @@ -106,29 +72,35 @@ class HooksManager: Argument: triggers -- the trigger string + + Keyword argument: + data -- Data to pass to the hook as argument """ - for n in range(len(triggers) + 1): - i = self._access(*triggers[:n]) - for h in i["__end__"]: - yield h + trigger = "_".join(triggers) + + res = list() + + for key in self.hooks: + if trigger.find(key) == 0: + res += self.hooks[key] + + return res - def get_reverse_hooks(self, *triggers, exclude_first=False): - """Returns list of triggered hooks that are bellow or at the same level + def exec_hook(self, *triggers, **data): + """Trigger hooks that match the given trigger string Argument: - triggers -- the trigger string + trigger -- the trigger string - Keyword arguments: - exclude_first -- start reporting hook at the next level + Keyword argument: + data -- Data to pass to the hook as argument """ - h = self._access(*triggers) - for k in h: - if k == "__end__": - if not exclude_first: - for hk in h[k]: - yield hk - else: - yield from self.get_reverse_hooks(*triggers + (k,)) + trigger = "_".join(triggers) + + for key in self.hooks: + if trigger.find(key) == 0: + for hook in self.hooks[key]: + hook.run(**data) diff --git a/nemubot/hooks/manager_test.py b/nemubot/hooks/manager_test.py deleted file mode 100755 index a0f38d7..0000000 --- a/nemubot/hooks/manager_test.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 - -import unittest - -from nemubot.hooks.manager import HooksManager - -class TestHookManager(unittest.TestCase): - - - def test_access(self): - hm = HooksManager() - - h1 = "HOOK1" - h2 = "HOOK2" - h3 = "HOOK3" - - hm.add_hook(h1) - hm.add_hook(h2, "pre") - hm.add_hook(h3, "pre", "Text") - hm.add_hook(h2, "post", "Text") - - self.assertIn("__end__", hm._access()) - self.assertIn("__end__", hm._access("pre")) - self.assertIn("__end__", hm._access("pre", "Text")) - self.assertIn("__end__", hm._access("post", "Text")) - - self.assertFalse(hm._access("inexistant")["__end__"]) - self.assertTrue(hm._access()["__end__"]) - self.assertTrue(hm._access("pre")["__end__"]) - self.assertTrue(hm._access("pre", "Text")["__end__"]) - self.assertTrue(hm._access("post", "Text")["__end__"]) - - - def test_search(self): - hm = HooksManager() - - h1 = "HOOK1" - h2 = "HOOK2" - h3 = "HOOK3" - h4 = "HOOK4" - - hm.add_hook(h1) - hm.add_hook(h2, "pre") - hm.add_hook(h3, "pre", "Text") - hm.add_hook(h2, "post", "Text") - - self.assertTrue([h for h in hm._search(h1)]) - self.assertFalse([h for h in hm._search(h4)]) - self.assertEqual(2, len([h for h in hm._search(h2)])) - self.assertEqual([("pre", "Text")], [h for h in hm._search(h3)]) - - - def test_delete(self): - hm = HooksManager() - - h1 = "HOOK1" - h2 = "HOOK2" - h3 = "HOOK3" - h4 = "HOOK4" - - hm.add_hook(h1) - hm.add_hook(h2, "pre") - hm.add_hook(h3, "pre", "Text") - hm.add_hook(h2, "post", "Text") - - hm.del_hooks(hook=h4) - - self.assertTrue(hm._access("pre")["__end__"]) - self.assertTrue(hm._access("pre", "Text")["__end__"]) - hm.del_hooks("pre") - self.assertFalse(hm._access("pre")["__end__"]) - - self.assertTrue(hm._access("post", "Text")["__end__"]) - hm.del_hooks("post", "Text", hook=h2) - self.assertFalse(hm._access("post", "Text")["__end__"]) - - self.assertTrue(hm._access()["__end__"]) - hm.del_hooks(hook=h1) - self.assertFalse(hm._access()["__end__"]) - - - def test_get(self): - hm = HooksManager() - - h1 = "HOOK1" - h2 = "HOOK2" - h3 = "HOOK3" - - hm.add_hook(h1) - hm.add_hook(h2, "pre") - hm.add_hook(h3, "pre", "Text") - hm.add_hook(h2, "post", "Text") - - self.assertEqual([h1, h2], [h for h in hm.get_hooks("pre")]) - self.assertEqual([h1, h2, h3], [h for h in hm.get_hooks("pre", "Text")]) - - - def test_get_rev(self): - hm = HooksManager() - - h1 = "HOOK1" - h2 = "HOOK2" - h3 = "HOOK3" - - hm.add_hook(h1) - hm.add_hook(h2, "pre") - hm.add_hook(h3, "pre", "Text") - hm.add_hook(h2, "post", "Text") - - self.assertEqual([h2, h3], [h for h in hm.get_reverse_hooks("pre")]) - self.assertEqual([h3], [h for h in hm.get_reverse_hooks("pre", exclude_first=True)]) - - -if __name__ == '__main__': - unittest.main() diff --git a/nemubot/hooks/message.py b/nemubot/hooks/message.py index ee07600..5f092ad 100644 --- a/nemubot/hooks/message.py +++ b/nemubot/hooks/message.py @@ -24,26 +24,54 @@ class Message(Abstract): """Class storing hook information, specialized for a generic Message""" - def __init__(self, call, regexp=None, help=None, **kwargs): - super().__init__(call=call, **kwargs) + def __init__(self, call, name=None, regexp=None, channels=list(), + server=None, help=None, help_usage=dict(), **kargs): + + Abstract.__init__(self, call=call, **kargs) assert regexp is None or type(regexp) is str, regexp + assert channels is None or type(channels) is list, channels + assert server is None or type(server) is str, server + assert type(help_usage) is dict, help_usage + self.name = str(name) if name is not None else None self.regexp = regexp + self.server = server + self.channels = channels self.help = help + self.help_usage = help_usage def __str__(self): - # TODO: find a way to name the feature (like command: help) - return self.help if self.help is not None else super().__str__() + return "\x03\x02%s\x03\x02%s%s" % ( + self.name if self.name is not None else "\x03\x1f" + self.regexp + "\x03\x1f" if self.regexp is not None else "", + " (restricted to %s)" % (self.server + ":" if self.server is not None else "") + (self.channels if self.channels else "*") if len(self.channels) or self.server else "", + ": %s" % self.help if self.help is not None else "" + ) - def check(self, msg): - return super().check(msg) + def match(self, msg, server=None): + if not isinstance(msg, nemubot.message.abstract.Abstract): + return True - - def match(self, msg): - if not isinstance(msg, nemubot.message.text.Text): - return False + elif isinstance(msg, nemubot.message.Command): + return self.is_matching(msg.cmd, msg.to, server) + elif isinstance(msg, nemubot.message.Text): + return self.is_matching(msg.message, msg.to, server) else: - return (self.regexp is None or re.match(self.regexp, msg.message)) and super().match(msg) + return False + + + def is_matching(self, strcmp, receivers=list(), server=None): + """Test if the current hook correspond to the message""" + if ((server is None or self.server is None or self.server == server) + and ((self.name is None or strcmp == self.name) and ( + self.regexp is None or re.match(self.regexp, strcmp)))): + + if receivers and self.channels: + for receiver in receivers: + if receiver in self.channels: + return True + else: + return True + return False diff --git a/nemubot/importer.py b/nemubot/importer.py index 674ab40..6769ea9 100644 --- a/nemubot/importer.py +++ b/nemubot/importer.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # @@ -29,16 +31,16 @@ class ModuleFinder(Finder): self.add_module = add_module def find_module(self, fullname, path=None): - if path is not None and fullname.startswith("nemubot.module."): - module_name = fullname.split(".", 2)[2] + # Search only for new nemubot modules (packages init) + if path is None: for mpath in self.modules_paths: - if os.path.isfile(os.path.join(mpath, module_name + ".py")): + if os.path.isfile(os.path.join(mpath, fullname + ".py")): return ModuleLoader(self.add_module, fullname, - os.path.join(mpath, module_name + ".py")) - elif os.path.isfile(os.path.join(os.path.join(mpath, module_name), "__init__.py")): + os.path.join(mpath, fullname + ".py")) + elif os.path.isfile(os.path.join(os.path.join(mpath, fullname), "__init__.py")): return ModuleLoader(self.add_module, fullname, os.path.join( - os.path.join(mpath, module_name), + os.path.join(mpath, fullname), "__init__.py")) return None @@ -53,17 +55,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 imported from %s.", name.split(".", 2)[2], self.path) + logger.info("Module '%s' successfully loaded.", name) return module # Python 3.4 def exec_module(self, module): - super().exec_module(module) + super(ModuleLoader, self).exec_module(module) self._load(module, module.__spec__.name) # Python 3.3 def load_module(self, fullname): - module = super().load_module(fullname) + module = super(ModuleLoader, self).load_module(fullname) return self._load(module, module.__name__) diff --git a/nemubot/message/__init__.py b/nemubot/message/__init__.py index 4d69dbb..31d7313 100644 --- a/nemubot/message/__init__.py +++ b/nemubot/message/__init__.py @@ -19,3 +19,27 @@ from nemubot.message.text import Text from nemubot.message.directask import DirectAsk from nemubot.message.command import Command from nemubot.message.command import OwnerCommand + + +def reload(): + global Abstract, Text, DirectAsk, Command, OwnerCommand + import imp + + import nemubot.message.abstract + imp.reload(nemubot.message.abstract) + Abstract = nemubot.message.abstract.Abstract + imp.reload(nemubot.message.text) + Text = nemubot.message.text.Text + imp.reload(nemubot.message.directask) + DirectAsk = nemubot.message.directask.DirectAsk + imp.reload(nemubot.message.command) + Command = nemubot.message.command.Command + OwnerCommand = nemubot.message.command.OwnerCommand + + import nemubot.message.visitor + imp.reload(nemubot.message.visitor) + + import nemubot.message.printer + imp.reload(nemubot.message.printer) + + nemubot.message.printer.reload() diff --git a/nemubot/message/abstract.py b/nemubot/message/abstract.py index 3af0511..3c69c8d 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, frm_owner=False): + def __init__(self, server=None, date=None, to=None, to_response=None, frm=None): """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 = frm_owner + self.frm_owner = False # Filled later, in consumer @property @@ -51,6 +51,11 @@ class Abstract: return self.to + @property + def receivers(self): + # TODO: this is for legacy modules + return self.to_response + @property def channel(self): # TODO: this is for legacy modules @@ -59,6 +64,12 @@ 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) @@ -72,8 +83,7 @@ class Abstract: "date": self.date, "to": self.to, "to_response": self._to_response, - "frm": self.frm, - "frm_owner": self.frm_owner, + "frm": self.frm } for w in without: diff --git a/nemubot/message/command.py b/nemubot/message/command.py index ca87e4c..895d16e 100644 --- a/nemubot/message/command.py +++ b/nemubot/message/command.py @@ -22,7 +22,7 @@ class Command(Abstract): """This class represents a specialized TextMessage""" def __init__(self, cmd, args=None, kwargs=None, *nargs, **kargs): - super().__init__(*nargs, **kargs) + Abstract.__init__(self, *nargs, **kargs) self.cmd = cmd self.args = args if args is not None else list() @@ -31,6 +31,11 @@ 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/directask.py b/nemubot/message/directask.py index 3b1fabb..03c7902 100644 --- a/nemubot/message/directask.py +++ b/nemubot/message/directask.py @@ -28,7 +28,7 @@ class DirectAsk(Text): designated -- the user designated by the message """ - super().__init__(*args, **kargs) + Text.__init__(self, *args, **kargs) self.designated = designated diff --git a/nemubot/exception/keyword.py b/nemubot/message/printer/IRC.py similarity index 72% rename from nemubot/exception/keyword.py rename to nemubot/message/printer/IRC.py index 6e3c07f..d9a1ffc 100644 --- a/nemubot/exception/keyword.py +++ b/nemubot/message/printer/IRC.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # @@ -14,10 +16,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from nemubot.exception import IMException +from nemubot.message import Text +from nemubot.message.printer.socket import Socket as SocketPrinter -class KeywordException(IMException): +class IRC(SocketPrinter): - def __init__(self, message): - super(KeywordException, self).__init__(message) + def visit_Text(self, msg): + self.pp += "PRIVMSG %s :" % ",".join(msg.to) + SocketPrinter.visit_Text(self, msg) diff --git a/nemubot/message/printer/IRCLib.py b/nemubot/message/printer/IRCLib.py deleted file mode 100644 index abd1f2f..0000000 --- a/nemubot/message/printer/IRCLib.py +++ /dev/null @@ -1,67 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2026 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from nemubot.message.visitor import AbstractVisitor - - -class IRCLib(AbstractVisitor): - - """Visitor that sends bot responses via an irc.client.ServerConnection. - - Unlike the socket-based IRC printer (which builds a raw PRIVMSG string), - this calls connection.privmsg() directly so the library handles encoding, - line-length capping, and any internal locking. - """ - - def __init__(self, connection): - self._conn = connection - - def _send(self, target, text): - try: - self._conn.privmsg(target, text) - except Exception: - pass # drop silently during reconnection - - # Visitor methods - - def visit_Text(self, msg): - if isinstance(msg.message, str): - for target in msg.to: - self._send(target, msg.message) - else: - msg.message.accept(self) - - def visit_DirectAsk(self, msg): - text = msg.message if isinstance(msg.message, str) else str(msg.message) - # Mirrors socket.py logic: - # rooms that are NOT the designated nick get a "nick: " prefix - others = [to for to in msg.to if to != msg.designated] - if len(others) == 0 or len(others) != len(msg.to): - for target in msg.to: - self._send(target, text) - if others: - for target in others: - self._send(target, "%s: %s" % (msg.designated, text)) - - def visit_Command(self, msg): - parts = ["!" + msg.cmd] + list(msg.args) - for target in msg.to: - self._send(target, " ".join(parts)) - - def visit_OwnerCommand(self, msg): - parts = ["`" + msg.cmd] + list(msg.args) - for target in msg.to: - self._send(target, " ".join(parts)) diff --git a/nemubot/message/printer/Matrix.py b/nemubot/message/printer/Matrix.py deleted file mode 100644 index ad1b99e..0000000 --- a/nemubot/message/printer/Matrix.py +++ /dev/null @@ -1,69 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2026 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from nemubot.message.visitor import AbstractVisitor - - -class Matrix(AbstractVisitor): - - """Visitor that sends bot responses as Matrix room messages. - - Instead of accumulating text like the IRC printer does, each visit_* - method calls send_func(room_id, text) directly for every destination room. - """ - - def __init__(self, send_func): - """ - Argument: - send_func -- callable(room_id: str, text: str) that sends a plain-text - message to the given Matrix room - """ - self._send = send_func - - def visit_Text(self, msg): - if isinstance(msg.message, str): - for room in msg.to: - self._send(room, msg.message) - else: - # Nested message object — let it visit itself - msg.message.accept(self) - - def visit_DirectAsk(self, msg): - text = msg.message if isinstance(msg.message, str) else str(msg.message) - # Rooms that are NOT the designated nick → prefix with "nick: " - others = [to for to in msg.to if to != msg.designated] - if len(others) == 0 or len(others) != len(msg.to): - # At least one room IS the designated target → send plain - for room in msg.to: - self._send(room, text) - if len(others): - # Other rooms → prefix with nick - for room in others: - self._send(room, "%s: %s" % (msg.designated, text)) - - def visit_Command(self, msg): - parts = ["!" + msg.cmd] - if msg.args: - parts.extend(msg.args) - for room in msg.to: - self._send(room, " ".join(parts)) - - def visit_OwnerCommand(self, msg): - parts = ["`" + msg.cmd] - if msg.args: - parts.extend(msg.args) - for room in msg.to: - self._send(room, " ".join(parts)) diff --git a/nemubot/message/printer/__init__.py b/nemubot/message/printer/__init__.py index e0fbeef..f906b35 100644 --- a/nemubot/message/printer/__init__.py +++ b/nemubot/message/printer/__init__.py @@ -1,5 +1,7 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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 @@ -13,3 +15,9 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . + +def reload(): + import imp + + import nemubot.message.printer.IRC + imp.reload(nemubot.message.printer.IRC) diff --git a/nemubot/message/printer/socket.py b/nemubot/message/printer/socket.py index 6884c88..2df7d5e 100644 --- a/nemubot/message/printer/socket.py +++ b/nemubot/message/printer/socket.py @@ -1,5 +1,7 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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 @@ -35,7 +37,7 @@ class Socket(AbstractVisitor): others = [to for to in msg.to if to != msg.designated] # Avoid nick starting message when discussing on user channel - if len(others) == 0 or len(others) != len(msg.to): + if len(others) != len(msg.to): res = Text(msg.message, server=msg.server, date=msg.date, to=msg.to, frm=msg.frm) diff --git a/nemubot/message/printer/test_socket.py b/nemubot/message/printer/test_socket.py index 41f74b0..aa8d833 100644 --- a/nemubot/message/printer/test_socket.py +++ b/nemubot/message/printer/test_socket.py @@ -1,5 +1,5 @@ # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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 diff --git a/nemubot/message/text.py b/nemubot/message/text.py index f691a04..ec90a36 100644 --- a/nemubot/message/text.py +++ b/nemubot/message/text.py @@ -28,7 +28,7 @@ class Text(Abstract): message -- the parsed message """ - super().__init__(*args, **kargs) + Abstract.__init__(self, *args, **kargs) self.message = message diff --git a/nemubot/message/visitor.py b/nemubot/message/visitor.py index 454633a..a9630c1 100644 --- a/nemubot/message/visitor.py +++ b/nemubot/message/visitor.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # diff --git a/nemubot/module/__init__.py b/nemubot/module/__init__.py deleted file mode 100644 index 33f0e41..0000000 --- a/nemubot/module/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# -# 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/nemubot/modulecontext.py b/nemubot/modulecontext.py index 4af3731..0814215 100644 --- a/nemubot/modulecontext.py +++ b/nemubot/modulecontext.py @@ -1,5 +1,5 @@ # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2017 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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,95 +14,24 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -class _ModuleContext: - - def __init__(self, module=None, knodes=None): - self.module = module - - if module is not None: - self.module_name = (module.__spec__.name if hasattr(module, "__spec__") else module.__name__).replace("nemubot.module.", "") - else: - self.module_name = "" - - self.hooks = list() - self.events = list() - self.debug = False - - from nemubot.config.module import Module - self.config = Module(self.module_name) - self._knodes = knodes +def convert_legacy_store(old): + if old == "cmd_hook" or old == "cmd_rgxp" or old == "cmd_default": + return "in_Command" + elif old == "ask_hook" or old == "ask_rgxp" or old == "ask_default": + return "in_DirectAsk" + elif old == "msg_hook" or old == "msg_rgxp" or old == "msg_default": + return "in_Text" + elif old == "all_post": + return "post" + elif old == "all_pre": + return "pre" + else: + return old - def load_data(self): - from nemubot.tools.xmlparser import module_state - return module_state.ModuleState("nemubotstate") +class ModuleContext: - def set_knodes(self, knodes): - self._knodes = knodes - - def set_default(self, default): - # Access to data will trigger the load of data - if self.data is None: - self._data = default - - def add_hook(self, hook, *triggers): - from nemubot.hooks import Abstract as AbstractHook - assert isinstance(hook, AbstractHook), hook - self.hooks.append((triggers, hook)) - - def del_hook(self, hook, *triggers): - from nemubot.hooks import Abstract as AbstractHook - 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 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(self, server, res): - self.module.logger.info("Send response: %s", res) - - 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): - if not hasattr(self, "_data"): - self._data = self.load_data() - return self._data - - - def unload(self): - """Perform actions for unloading the module""" - - # Remove registered hooks - for (s, h) in self.hooks: - self.del_hook(h, *s) - - # Remove registered events - for evt, eid in self.events: - self.del_event(evt) - - self.save() - - -class ModuleContext(_ModuleContext): - - def __init__(self, context, *args, **kwargs): + def __init__(self, context, module): """Initialize the module context arguments: @@ -110,46 +39,107 @@ class ModuleContext(_ModuleContext): module -- the module """ - super().__init__(*args, **kwargs) + if module is not None: + module_name = module.__spec__.name if hasattr(module, "__spec__") else module.__name__ # 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.debug - - - def load_data(self): - return self.context.datastore.load(self.module_name, self._knodes) - - 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) + if (context is not None and + module_name in context.modules_configuration): + self.config = context.modules_configuration[module_name] else: - self.module.logger.error("Try to send a message to the unknown server: %s", server) - return False + from nemubot.tools.xmlparser.node import ModuleState + self.config = ModuleState("module") + + self.hooks = list() + self.events = list() # Un eventManager, qui contient une liste globale et un thread global et quelques méthodes statique, mais chaque événement est exécuté dans son propre contexte :) + self.debug = context.verbosity > 0 if context is not None else False + + # Define some callbacks + if context is not None: + # Load module data + self.data = context.datastore.load(module_name) + + def add_hook(store, hook): + store = convert_legacy_store(store) + self.hooks.append((store, hook)) + return context.treater.hm.add_hook(hook, store) + def del_hook(store, hook): + store = convert_legacy_store(store) + self.hooks.remove((store, hook)) + return context.treater.hm.del_hook(hook, store) + def call_hook(store, msg): + for h in context.treater.hm.get_hooks(store): + if h.match(msg): + res = h.run(msg) + if isinstance(res, list): + for i in res: + yield i + else: + yield res + def subtreat(msg): + yield from context.treater.treat_msg(msg) + def add_event(evt, eid=None): + eid = context.add_event(evt, eid, module_src=module) + self.events.append(eid) + return eid + def del_event(evt): + return context.del_event(evt, module_src=module) + + 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 + + else: # Used when using outside of nemubot + from nemubot.tools.xmlparser import module_state + self.data = module_state.ModuleState("nemubotstate") + + def add_hook(store, hook): + store = convert_legacy_store(store) + self.hooks.append((store, hook)) + def del_hook(store, hook): + store = convert_legacy_store(store) + self.hooks.remove((store, hook)) + def call_hook(store, msg): + # TODO: what can we do here? + return None + 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) + + 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.call_hook = call_hook + self.subtreat = subtreat + + + def unload(self): + """Perform actions for unloading the module""" + + # Remove registered hooks + for (s, h) in self.hooks: + self.del_hook(s, h) + + # Remove registered events + for e in self.events: + self.del_event(e) + + self.save() diff --git a/nemubot/server/DCC.py b/nemubot/server/DCC.py new file mode 100644 index 0000000..6b8d8c0 --- /dev/null +++ b/nemubot/server/DCC.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- + +# Nemubot is a smart and modulable IM bot. +# Copyright (C) 2012-2015 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import imp +import os +import re +import socket +import sys +import time +import threading +import traceback + +import nemubot.message as message +import nemubot.server as server + +#Store all used ports +PORTS = list() + +class DCC(server.AbstractServer): + def __init__(self, srv, dest, socket=None): + server.Server.__init__(self) + + self.error = False # An error has occur, closing the connection? + self.messages = list() # Message queued before connexion + + # Informations about the sender + self.sender = dest + if self.sender is not None: + self.nick = (self.sender.split('!'))[0] + if self.nick != self.sender: + self.realname = (self.sender.split('!'))[1] + else: + self.realname = self.nick + + # Keep the server + self.srv = srv + self.treatement = self.treat_msg + + # Found a port for the connection + self.port = self.foundPort() + + if self.port is None: + self.logger.critical("No more available slot for DCC connection") + self.setError("Il n'y a plus de place disponible sur le serveur" + " pour initialiser une session DCC.") + + def foundPort(self): + """Found a free port for the connection""" + for p in range(65432, 65535): + if p not in PORTS: + PORTS.append(p) + return p + return None + + @property + def id(self): + """Gives the server identifiant""" + return self.srv.id + "/" + self.sender + + def setError(self, msg): + self.error = True + self.srv.send_msg_usr(self.sender, msg) + + def accept_user(self, host, port): + """Accept a DCC connection""" + self.s = socket.socket() + try: + self.s.connect((host, port)) + self.logger.info("Accepted user from %s:%d for %s", host, port, self.sender) + self.connected = True + self.stop = False + except: + self.connected = False + self.error = True + return False + self.start() + return True + + + def request_user(self, type="CHAT", filename="CHAT", size=""): + """Create a DCC connection""" + #Open the port + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.bind(('', self.port)) + except: + try: + self.port = self.foundPort() + s.bind(('', self.port)) + except: + self.setError("Une erreur s'est produite durant la tentative" + " d'ouverture d'une session DCC.") + return False + self.logger.info("Listening on %d for %s", self.port, self.sender) + + #Send CTCP request for DCC + self.srv.send_ctcp(self.sender, + "DCC %s %s %d %d %s" % (type, filename, self.srv.ip, + self.port, size), + "PRIVMSG") + + s.listen(1) + #Waiting for the client + (self.s, addr) = s.accept() + self.logger.info("Connected by %d", addr) + self.connected = True + return True + + def send_dcc_raw(self, line): + self.s.sendall(line + b'\n') + + def send_dcc(self, msg, to = None): + """If we talk to this user, send a message through this connection + else, send the message to the server class""" + if to is None or to == self.sender or to == self.nick: + if self.error: + self.srv.send_msg_final(self.nick, msg) + elif not self.connected or self.s is None: + try: + self.start() + except RuntimeError: + pass + self.messages.append(msg) + else: + for line in msg.split("\n"): + self.send_dcc_raw(line.encode()) + else: + self.srv.send_dcc(msg, to) + + def send_file(self, filename): + """Send a file over DCC""" + if os.path.isfile(filename): + self.messages = filename + try: + self.start() + except RuntimeError: + pass + else: + self.logger.error("File not found `%s'", filename) + + def run(self): + self.stopping.clear() + + # Send file connection + if not isinstance(self.messages, list): + self.request_user("SEND", + os.path.basename(self.messages), + os.path.getsize(self.messages)) + if self.connected: + with open(self.messages, 'rb') as f: + d = f.read(268435456) #Packets size: 256Mo + while d: + self.s.sendall(d) + self.s.recv(4) #The client send a confirmation after each packet + d = f.read(268435456) #Packets size: 256Mo + + # Messages connection + else: + if not self.connected: + if not self.request_user(): + #TODO: do something here + return False + + #Start by sending all queued messages + for mess in self.messages: + self.send_dcc(mess) + + time.sleep(1) + + readbuffer = b'' + self.nicksize = len(self.srv.nick) + self.Bnick = self.srv.nick.encode() + while not self.stop: + raw = self.s.recv(1024) #recieve server messages + if not raw: + break + readbuffer = readbuffer + raw + temp = readbuffer.split(b'\n') + readbuffer = temp.pop() + + for line in temp: + self.treatement(line) + + if self.connected: + self.s.close() + self.connected = False + + #Remove from DCC connections server list + if self.realname in self.srv.dcc_clients: + del self.srv.dcc_clients[self.realname] + + self.logger.info("Closing connection with %s", self.nick) + self.stopping.set() + if self.closing_event is not None: + self.closing_event() + #Rearm Thread + threading.Thread.__init__(self) + + def treat_msg(self, line): + """Treat a receive message, *can be overwritten*""" + if line == b'NEMUBOT###': + bot = self.srv.add_networkbot(self.srv, self.sender, self) + self.treatement = bot.treat_msg + self.send_dcc("NEMUBOT###") + elif (line[:self.nicksize] == self.Bnick and + line[self.nicksize+1:].strip()[:10] == b'my name is'): + name = line[self.nicksize+1:].strip()[11:].decode('utf-8', + 'replace') + if re.match("^[a-zA-Z0-9_-]+$", name): + if name not in self.srv.dcc_clients: + del self.srv.dcc_clients[self.sender] + self.nick = name + self.sender = self.nick + "!" + self.realname + self.srv.dcc_clients[self.realname] = self + self.send_dcc("Hi " + self.nick) + else: + self.send_dcc("This nickname is already in use" + ", please choose another one.") + else: + self.send_dcc("The name you entered contain" + " invalid char.") + else: + self.srv.treat_msg( + (":%s PRIVMSG %s :" % ( + self.sender,self.srv.nick)).encode() + line, + True) diff --git a/nemubot/server/IRC.py b/nemubot/server/IRC.py new file mode 100644 index 0000000..672d7af --- /dev/null +++ b/nemubot/server/IRC.py @@ -0,0 +1,272 @@ +# -*- coding: utf-8 -*- + +# Nemubot is a smart and modulable IM bot. +# Copyright (C) 2012-2015 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from datetime import datetime +import re + +from nemubot.channel import Channel +from nemubot.message.printer.IRC import IRC as IRCPrinter +from nemubot.server.message.IRC import IRC as IRCMessage +from nemubot.server.socket import SocketServer + + +class IRC(SocketServer): + + """Concrete implementation of a connexion to an IRC server""" + + def __init__(self, host="localhost", port=6667, ssl=False, owner=None, + nick="nemubot", username=None, password=None, + realname="Nemubot", encoding="utf-8", caps=None, + channels=list(), on_connect=None): + """Prepare a connection with an IRC server + + Keyword arguments: + host -- host to join + port -- port on the host to reach + ssl -- is this server using a TLS socket + owner -- bot's owner + nick -- bot's nick + username -- the username as sent to server + password -- if a password is required to connect to the server + realname -- the bot's realname + encoding -- the encoding used on the whole server + caps -- client capabilities to register on the server + channels -- list of channels to join on connection (if a channel is password protected, give a tuple: (channel_name, password)) + on_connect -- generator to call when connection is done + """ + + self.username = username if username is not None else nick + self.password = password + self.nick = nick + self.owner = owner + self.realname = realname + + self.id = self.username + "@" + host + ":" + str(port) + SocketServer.__init__(self, host=host, port=port, ssl=ssl) + self.printer = IRCPrinter + + self.encoding = encoding + + # Keep a list of joined channels + self.channels = dict() + + # Server/client capabilities + self.capabilities = caps + + # Register CTCP capabilities + self.ctcp_capabilities = dict() + + def _ctcp_clientinfo(msg, cmds): + """Response to CLIENTINFO CTCP message""" + return " ".join(self.ctcp_capabilities.keys()) + + def _ctcp_dcc(msg, cmds): + """Response to DCC CTCP message""" + try: + import ipaddress + ip = ipaddress.ip_address(int(cmds[3])) + port = int(cmds[4]) + conn = DCC(srv, msg.sender) + except: + return "ERRMSG invalid parameters provided as DCC CTCP request" + + self.logger.info("Receive DCC connection request from %s to %s:%d", conn.sender, ip, port) + + if conn.accept_user(ip, port): + srv.dcc_clients[conn.sender] = conn + conn.send_dcc("Hello %s!" % conn.nick) + else: + self.logger.error("DCC: unable to connect to %s:%d", ip, port) + return "ERRMSG unable to connect to %s:%d" % (ip, port) + + import nemubot + + self.ctcp_capabilities["ACTION"] = lambda msg, cmds: None + self.ctcp_capabilities["CLIENTINFO"] = _ctcp_clientinfo + #self.ctcp_capabilities["DCC"] = _ctcp_dcc + self.ctcp_capabilities["FINGER"] = lambda msg, cmds: "VERSION nemubot v%s" % nemubot.__version__ + self.ctcp_capabilities["NEMUBOT"] = lambda msg, cmds: "NEMUBOT %s" % nemubot.__version__ + self.ctcp_capabilities["PING"] = lambda msg, cmds: "PING %s" % " ".join(cmds[1:]) + self.ctcp_capabilities["SOURCE"] = lambda msg, cmds: "SOURCE https://github.com/nemunaire/nemubot" + self.ctcp_capabilities["TIME"] = lambda msg, cmds: "TIME %s" % (datetime.now()) + self.ctcp_capabilities["USERINFO"] = lambda msg, cmds: "USERINFO %s" % self.realname + self.ctcp_capabilities["VERSION"] = lambda msg, cmds: "VERSION nemubot v%s" % nemubot.__version__ + + # TODO: Temporary fix, waiting for hook based CTCP management + self.ctcp_capabilities["TYPING"] = lambda msg, cmds: None + + self.logger.debug("CTCP capabilities setup: %s", ", ".join(self.ctcp_capabilities)) + + + # Register hooks on some IRC CMD + self.hookscmd = dict() + + # Respond to PING + def _on_ping(msg): + self.write(b"PONG :" + msg.params[0]) + self.hookscmd["PING"] = _on_ping + + # Respond to 001 + def _on_connect(msg): + # First, send user defined command + if on_connect is not None: + if callable(on_connect): + toc = on_connect() + else: + toc = on_connect + if toc is not None: + for oc in toc: + self.write(oc) + # Then, JOIN some channels + for chn in channels: + if isinstance(chn, tuple): + self.write("JOIN %s %s" % chn) + else: + self.write("JOIN %s" % chn) + self.hookscmd["001"] = _on_connect + + # Respond to ERROR + def _on_error(msg): + self.close() + self.hookscmd["ERROR"] = _on_error + + # Respond to CAP + def _on_cap(msg): + if len(msg.params) != 3 or msg.params[1] != b"LS": + return + server_caps = msg.params[2].decode().split(" ") + for cap in self.capabilities: + if cap not in server_caps: + self.capabilities.remove(cap) + if len(self.capabilities) > 0: + self.write("CAP REQ :" + " ".join(self.capabilities)) + self.write("CAP END") + self.hookscmd["CAP"] = _on_cap + + # Respond to JOIN + def _on_join(msg): + if len(msg.params) == 0: + return + + for chname in msg.decode(msg.params[0]).split(","): + # Register the channel + chan = Channel(chname) + self.channels[chname] = chan + self.hookscmd["JOIN"] = _on_join + # Respond to PART + def _on_part(msg): + if len(msg.params) != 1 and len(msg.params) != 2: + return + + for chname in msg.params[0].split(b","): + if chname in self.channels: + if msg.nick == self.nick: + del self.channels[chname] + elif msg.nick in self.channels[chname].people: + del self.channels[chname].people[msg.nick] + self.hookscmd["PART"] = _on_part + # Respond to 331/RPL_NOTOPIC,332/RPL_TOPIC,TOPIC + def _on_topic(msg): + if len(msg.params) != 1 and len(msg.params) != 2: + return + if msg.params[0] in self.channels: + if len(msg.params) == 1 or len(msg.params[1]) == 0: + self.channels[msg.params[0]].topic = None + else: + self.channels[msg.params[0]].topic = msg.decode(msg.params[1]) + self.hookscmd["331"] = _on_topic + self.hookscmd["332"] = _on_topic + self.hookscmd["TOPIC"] = _on_topic + # Respond to 353/RPL_NAMREPLY + def _on_353(msg): + if len(msg.params) == 3: + msg.params.pop(0) # 353: like RFC 1459 + if len(msg.params) != 2: + return + if msg.params[0] in self.channels: + for nk in msg.decode(msg.params[1]).split(" "): + res = re.match("^(?P[^a-zA-Z[\]\\`_^{|}])(?P[a-zA-Z[\]\\`_^{|}][a-zA-Z0-9[\]\\`_^{|}-]*)$") + self.channels[msg.params[0]].people[res.group("nickname")] = res.group("level") + self.hookscmd["353"] = _on_353 + + # Respond to INVITE + def _on_invite(msg): + if len(msg.params) != 2: + return + self.write("JOIN " + msg.decode(msg.params[1])) + self.hookscmd["INVITE"] = _on_invite + + # Respond to ERR_NICKCOLLISION + def _on_nickcollision(msg): + self.nick += "_" + self.write("NICK " + self.nick) + self.hookscmd["433"] = _on_nickcollision + self.hookscmd["436"] = _on_nickcollision + + # Handle CTCP requests + def _on_ctcp(msg): + if len(msg.params) != 2 or not msg.is_ctcp: + return + cmds = msg.decode(msg.params[1][1:len(msg.params[1])-1]).split(' ') + if cmds[0] in self.ctcp_capabilities: + res = self.ctcp_capabilities[cmds[0]](msg, cmds) + else: + res = "ERRMSG Unknown or unimplemented CTCP request" + if res is not None: + self.write("NOTICE %s :\x01%s\x01" % (msg.nick, res)) + self.hookscmd["PRIVMSG"] = _on_ctcp + + + # Open/close + + def _open(self): + if SocketServer._open(self): + if self.password is not None: + self.write("PASS :" + self.password) + 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)) + return True + return False + + + def _close(self): + if self.connected: self.write("QUIT") + return SocketServer._close(self) + + + # Writes: as inherited + + # Read + + def read(self): + for line in SocketServer.read(self): + # PING should be handled here, so start parsing here :/ + msg = IRCMessage(line, self.encoding) + + if msg.cmd in self.hookscmd: + self.hookscmd[msg.cmd](msg) + + yield msg + + + def parse(self, msg): + mes = msg.to_bot_message(self) + if mes is not None: + yield mes diff --git a/nemubot/server/IRCLib.py b/nemubot/server/IRCLib.py deleted file mode 100644 index eb7c16f..0000000 --- a/nemubot/server/IRCLib.py +++ /dev/null @@ -1,375 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2026 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from datetime import datetime -import shlex -import threading - -import irc.bot -import irc.client -import irc.connection - -import nemubot.message as message -from nemubot.server.threaded import ThreadedServer - - -class _IRCBotAdapter(irc.bot.SingleServerIRCBot): - - """Internal adapter that bridges the irc library event model to nemubot. - - Subclasses SingleServerIRCBot to get automatic reconnection, PING/PONG, - and nick-collision handling for free. - """ - - def __init__(self, server_name, push_fn, channels, on_connect_cmds, - nick, server_list, owner=None, realname="Nemubot", - encoding="utf-8", **connect_params): - super().__init__(server_list, nick, realname, **connect_params) - self._nemubot_name = server_name - self._push = push_fn - self._channels_to_join = channels - self._on_connect_cmds = on_connect_cmds or [] - self.owner = owner - self.encoding = encoding - self._stop_event = threading.Event() - - - # Event loop control - - def start(self): - """Run the reactor loop until stop() is called.""" - self._connect() - while not self._stop_event.is_set(): - self.reactor.process_once(timeout=0.2) - - def stop(self): - """Signal the loop to exit and disconnect cleanly.""" - self._stop_event.set() - try: - self.connection.disconnect("Goodbye") - except Exception: - pass - - def on_disconnect(self, connection, event): - """Reconnect automatically unless we are shutting down.""" - if not self._stop_event.is_set(): - self.jump_server() - - - # Connection lifecycle - - def on_welcome(self, connection, event): - """001 — run on_connect commands then join channels.""" - for cmd in self._on_connect_cmds: - if callable(cmd): - for c in (cmd() or []): - connection.send_raw(c) - else: - connection.send_raw(cmd) - - for ch in self._channels_to_join: - if isinstance(ch, tuple): - connection.join(ch[0], ch[1] if len(ch) > 1 else "") - elif hasattr(ch, 'name'): - connection.join(ch.name, getattr(ch, 'password', "") or "") - else: - connection.join(str(ch)) - - def on_invite(self, connection, event): - """Auto-join on INVITE.""" - if event.arguments: - connection.join(event.arguments[0]) - - - # CTCP - - def on_ctcp(self, connection, event): - """Handle CTCP requests (irc library >= 19 dispatches all to on_ctcp).""" - nick = irc.client.NickMask(event.source).nick - ctcp_type = event.arguments[0].upper() if event.arguments else "" - ctcp_arg = event.arguments[1] if len(event.arguments) > 1 else "" - self._reply_ctcp(connection, nick, ctcp_type, ctcp_arg) - - # Fallbacks for older irc library versions that dispatch per-type - def on_ctcpversion(self, connection, event): - import nemubot - nick = irc.client.NickMask(event.source).nick - connection.ctcp_reply(nick, "VERSION nemubot v%s" % nemubot.__version__) - - def on_ctcpping(self, connection, event): - nick = irc.client.NickMask(event.source).nick - arg = event.arguments[0] if event.arguments else "" - connection.ctcp_reply(nick, "PING %s" % arg) - - def _reply_ctcp(self, connection, nick, ctcp_type, ctcp_arg): - import nemubot - responses = { - "ACTION": None, # handled as on_action - "CLIENTINFO": "CLIENTINFO ACTION CLIENTINFO FINGER PING SOURCE TIME USERINFO VERSION", - "FINGER": "FINGER nemubot v%s" % nemubot.__version__, - "PING": "PING %s" % ctcp_arg, - "SOURCE": "SOURCE https://github.com/nemunaire/nemubot", - "TIME": "TIME %s" % datetime.now(), - "USERINFO": "USERINFO Nemubot", - "VERSION": "VERSION nemubot v%s" % nemubot.__version__, - } - if ctcp_type in responses and responses[ctcp_type] is not None: - connection.ctcp_reply(nick, responses[ctcp_type]) - - - # Incoming messages - - def _decode(self, text): - if isinstance(text, bytes): - try: - return text.decode("utf-8") - except UnicodeDecodeError: - return text.decode(self.encoding, "replace") - return text - - def _make_message(self, connection, source, target, text): - """Convert raw IRC event data into a nemubot bot message.""" - nick = irc.client.NickMask(source).nick - text = self._decode(text) - bot_nick = connection.get_nickname() - is_channel = irc.client.is_channel(target) - to = [target] if is_channel else [nick] - to_response = [target] if is_channel else [nick] - - common = dict( - server=self._nemubot_name, - to=to, - to_response=to_response, - frm=nick, - frm_owner=(nick == self.owner), - ) - - # "botname: text" or "botname, text" - if (text.startswith(bot_nick + ":") or - text.startswith(bot_nick + ",")): - inner = text[len(bot_nick) + 1:].strip() - return message.DirectAsk(designated=bot_nick, message=inner, - **common) - - # "!command [args]" - if len(text) > 1 and text[0] == '!': - inner = text[1:].strip() - try: - args = shlex.split(inner) - except ValueError: - args = inner.split() - if args: - # Extract @key=value named arguments (same logic as IRC.py) - kwargs = {} - while len(args) > 1: - arg = args[1] - if len(arg) > 2 and arg[0:2] == '\\@': - args[1] = arg[1:] - elif len(arg) > 1 and arg[0] == '@': - arsp = arg[1:].split("=", 1) - kwargs[arsp[0]] = arsp[1] if len(arsp) == 2 else None - args.pop(1) - continue - break - return message.Command(cmd=args[0], args=args[1:], - kwargs=kwargs, **common) - - return message.Text(message=text, **common) - - def on_pubmsg(self, connection, event): - msg = self._make_message( - connection, event.source, event.target, - event.arguments[0] if event.arguments else "", - ) - if msg: - self._push(msg) - - def on_privmsg(self, connection, event): - nick = irc.client.NickMask(event.source).nick - msg = self._make_message( - connection, event.source, nick, - event.arguments[0] if event.arguments else "", - ) - if msg: - self._push(msg) - - def on_action(self, connection, event): - """CTCP ACTION (/me) — delivered as a plain Text message.""" - nick = irc.client.NickMask(event.source).nick - text = "/me %s" % (event.arguments[0] if event.arguments else "") - is_channel = irc.client.is_channel(event.target) - to = [event.target] if is_channel else [nick] - self._push(message.Text( - message=text, - server=self._nemubot_name, - to=to, to_response=to, - frm=nick, frm_owner=(nick == self.owner), - )) - - -class IRCLib(ThreadedServer): - - """IRC server using the irc Python library (jaraco). - - Compared to the hand-rolled IRC.py implementation, this gets: - - Automatic exponential-backoff reconnection - - PING/PONG handled transparently - - Nick-collision suffix logic built-in - """ - - def __init__(self, host="localhost", port=6667, nick="nemubot", - username=None, password=None, realname="Nemubot", - encoding="utf-8", owner=None, channels=None, - on_connect=None, ssl=False, **kwargs): - """Prepare a connection to an IRC server. - - Keyword arguments: - host -- IRC server hostname - port -- IRC server port (default 6667) - nick -- bot's nickname - username -- username for USER command (defaults to nick) - password -- server password (sent as PASS) - realname -- bot's real name - encoding -- fallback encoding for non-UTF-8 servers - owner -- nick of the bot's owner (sets frm_owner on messages) - channels -- list of channel names / (name, key) tuples to join - on_connect -- list of raw IRC commands (or a callable returning one) - to send after receiving 001 - ssl -- wrap the connection in TLS - """ - name = (username or nick) + "@" + host + ":" + str(port) - super().__init__(name=name) - - self._host = host - self._port = int(port) - self._nick = nick - self._username = username or nick - self._password = password - self._realname = realname - self._encoding = encoding - self.owner = owner - self._channels = channels or [] - self._on_connect_cmds = on_connect - self._ssl = ssl - - self._bot = None - self._thread = None - - - # ThreadedServer hooks - - def _start(self): - server_list = [irc.bot.ServerSpec(self._host, self._port, - self._password)] - - connect_params = {"username": self._username} - - if self._ssl: - import ssl as ssl_mod - ctx = ssl_mod.create_default_context() - host = self._host # capture for closure - connect_params["connect_factory"] = irc.connection.Factory( - wrapper=lambda sock: ctx.wrap_socket(sock, - server_hostname=host) - ) - - self._bot = _IRCBotAdapter( - server_name=self.name, - push_fn=self._push_message, - channels=self._channels, - on_connect_cmds=self._on_connect_cmds, - nick=self._nick, - server_list=server_list, - owner=self.owner, - realname=self._realname, - encoding=self._encoding, - **connect_params, - ) - self._thread = threading.Thread( - target=self._bot.start, - daemon=True, - name="nemubot.IRC/" + self.name, - ) - self._thread.start() - - def _stop(self): - if self._bot: - self._bot.stop() - if self._thread: - self._thread.join(timeout=5) - - - # Outgoing messages - - def send_response(self, response): - if response is None: - return - if isinstance(response, list): - for r in response: - self.send_response(r) - return - if not self._bot: - return - - from nemubot.message.printer.IRCLib import IRCLib as IRCLibPrinter - printer = IRCLibPrinter(self._bot.connection) - response.accept(printer) - - - # subparse: re-parse a plain string in the context of an existing message - # (used by alias, rnd, grep, cat, smmry, sms modules) - - def subparse(self, orig, cnt): - bot_nick = (self._bot.connection.get_nickname() - if self._bot else self._nick) - common = dict( - server=self.name, - to=orig.to, - to_response=orig.to_response, - frm=orig.frm, - frm_owner=orig.frm_owner, - date=orig.date, - ) - text = cnt - - if (text.startswith(bot_nick + ":") or - text.startswith(bot_nick + ",")): - inner = text[len(bot_nick) + 1:].strip() - return message.DirectAsk(designated=bot_nick, message=inner, - **common) - - if len(text) > 1 and text[0] == '!': - inner = text[1:].strip() - try: - args = shlex.split(inner) - except ValueError: - args = inner.split() - if args: - kwargs = {} - while len(args) > 1: - arg = args[1] - if len(arg) > 2 and arg[0:2] == '\\@': - args[1] = arg[1:] - elif len(arg) > 1 and arg[0] == '@': - arsp = arg[1:].split("=", 1) - kwargs[arsp[0]] = arsp[1] if len(arsp) == 2 else None - args.pop(1) - continue - break - return message.Command(cmd=args[0], args=args[1:], - kwargs=kwargs, **common) - - return message.Text(message=text, **common) diff --git a/nemubot/server/Matrix.py b/nemubot/server/Matrix.py deleted file mode 100644 index ed4b746..0000000 --- a/nemubot/server/Matrix.py +++ /dev/null @@ -1,200 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2026 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import asyncio -import shlex -import threading - -import nemubot.message as message -from nemubot.server.threaded import ThreadedServer - - -class Matrix(ThreadedServer): - - """Matrix server implementation using matrix-nio's AsyncClient. - - Runs an asyncio event loop in a daemon thread. Incoming room messages are - converted to nemubot bot messages and pushed through the pipe; outgoing - responses are sent via the async client from the same event loop. - """ - - def __init__(self, homeserver, user_id, password=None, access_token=None, - owner=None, nick=None, channels=None, **kwargs): - """Prepare a connection to a Matrix homeserver. - - Keyword arguments: - homeserver -- base URL of the homeserver, e.g. "https://matrix.org" - user_id -- full MXID (@user:server) or bare localpart - password -- login password (required if no access_token) - access_token -- pre-obtained access token (alternative to password) - owner -- MXID of the bot owner (marks frm_owner on messages) - nick -- display name / prefix for DirectAsk detection - channels -- list of room IDs / aliases to join on connect - """ - - # Ensure fully-qualified MXID - if not user_id.startswith("@"): - host = homeserver.split("//")[-1].rstrip("/") - user_id = "@%s:%s" % (user_id, host) - - super().__init__(name=user_id) - - self.homeserver = homeserver - self.user_id = user_id - self.password = password - self.access_token = access_token - self.owner = owner - self.nick = nick or user_id - - self._initial_rooms = channels or [] - self._client = None - self._loop = None - self._thread = None - - - # Open/close - - def _start(self): - self._thread = threading.Thread( - target=self._run_loop, - daemon=True, - name="nemubot.Matrix/" + self._name, - ) - self._thread.start() - - def _stop(self): - if self._client and self._loop and not self._loop.is_closed(): - try: - asyncio.run_coroutine_threadsafe( - self._client.close(), self._loop - ).result(timeout=5) - except Exception: - self._logger.exception("Error while closing Matrix client") - if self._thread: - self._thread.join(timeout=5) - - - # Asyncio thread - - def _run_loop(self): - self._loop = asyncio.new_event_loop() - asyncio.set_event_loop(self._loop) - try: - self._loop.run_until_complete(self._async_main()) - except Exception: - self._logger.exception("Unhandled exception in Matrix event loop") - finally: - self._loop.close() - - async def _async_main(self): - from nio import AsyncClient, LoginError, RoomMessageText - - self._client = AsyncClient(self.homeserver, self.user_id) - - if self.access_token: - self._client.access_token = self.access_token - self._logger.info("Using provided access token for %s", self.user_id) - elif self.password: - resp = await self._client.login(self.password) - if isinstance(resp, LoginError): - self._logger.error("Matrix login failed: %s", resp.message) - return - self._logger.info("Logged in to Matrix as %s", self.user_id) - else: - self._logger.error("Need either password or access_token to connect") - return - - self._client.add_event_callback(self._on_room_message, RoomMessageText) - - for room in self._initial_rooms: - await self._client.join(room) - self._logger.info("Joined room %s", room) - - await self._client.sync_forever(timeout=30000, full_state=True) - - - # Incoming messages - - async def _on_room_message(self, room, event): - """Callback invoked by matrix-nio for each m.room.message event.""" - - if event.sender == self.user_id: - return # ignore own messages - - text = event.body - room_id = room.room_id - frm = event.sender - - common_args = { - "server": self.name, - "to": [room_id], - "to_response": [room_id], - "frm": frm, - "frm_owner": frm == self.owner, - } - - if len(text) > 1 and text[0] == '!': - text = text[1:].strip() - try: - args = shlex.split(text) - except ValueError: - args = text.split(' ') - msg = message.Command(cmd=args[0], args=args[1:], **common_args) - - elif (text.lower().startswith(self.nick.lower() + ":") - or text.lower().startswith(self.nick.lower() + ",")): - text = text[len(self.nick) + 1:].strip() - msg = message.DirectAsk(designated=self.nick, message=text, - **common_args) - - else: - msg = message.Text(message=text, **common_args) - - self._push_message(msg) - - - # Outgoing messages - - def send_response(self, response): - if response is None: - return - if isinstance(response, list): - for r in response: - self.send_response(r) - return - - from nemubot.message.printer.Matrix import Matrix as MatrixPrinter - printer = MatrixPrinter(self._send_text) - response.accept(printer) - - def _send_text(self, room_id, text): - """Thread-safe: schedule a Matrix room_send on the asyncio loop.""" - if not self._client or not self._loop or self._loop.is_closed(): - self._logger.warning("Cannot send: Matrix client not ready") - return - future = asyncio.run_coroutine_threadsafe( - self._client.room_send( - room_id=room_id, - message_type="m.room.message", - content={"msgtype": "m.text", "body": text}, - ignore_unverified_devices=True, - ), - self._loop, - ) - future.add_done_callback( - lambda f: self._logger.warning("Matrix send error: %s", f.exception()) - if not f.cancelled() and f.exception() else None - ) diff --git a/nemubot/server/__init__.py b/nemubot/server/__init__.py index db9ad87..700a198 100644 --- a/nemubot/server/__init__.py +++ b/nemubot/server/__init__.py @@ -1,5 +1,7 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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,85 +16,66 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import threading -def factory(uri, ssl=False, **init_args): - from urllib.parse import urlparse, unquote, parse_qs +_lock = threading.Lock() + +# Lists for select +_rlist = [] +_wlist = [] +_xlist = [] + + +def factory(uri): + from urllib.parse import urlparse, unquote o = urlparse(uri) - srv = None - if o.scheme == "irc" or o.scheme == "ircs": - # https://www.w3.org/Addressing/draft-mirashi-url-irc-01.txt - # https://www-archive.mozilla.org/projects/rt-messaging/chatzilla/irc-urls.html - args = dict(init_args) - - if o.scheme == "ircs": ssl = True - if o.hostname is not None: args["host"] = o.hostname - if o.port is not None: args["port"] = o.port - if o.username is not None: args["username"] = o.username - if o.password is not None: args["password"] = unquote(o.password) + # http://www.w3.org/Addressing/draft-mirashi-url-irc-01.txt + # http://www-archive.mozilla.org/projects/rt-messaging/chatzilla/irc-urls.html + args = dict() modifiers = o.path.split(",") target = unquote(modifiers.pop(0)[1:]) - # Read query string - params = parse_qs(o.query) + if o.scheme == "ircs": args["ssl"] = True + if o.hostname is not None: args["host"] = o.hostname + if o.port is not None: args["port"] = o.port + if o.username is not None: args["username"] = o.username + if o.password is not None: args["password"] = o.password - if "msg" in params: - if "on_connect" not in args: - args["on_connect"] = [] - args["on_connect"].append("PRIVMSG %s :%s" % (target, params["msg"][0])) - - if "key" in params: - if "channels" not in args: - args["channels"] = [] - args["channels"].append((target, params["key"][0])) - - if "pass" in params: - args["password"] = params["pass"][0] - - if "charset" in params: - args["encoding"] = params["charset"][0] + queries = o.query.split("&") + for q in queries: + if "=" in q: + key, val = tuple(q.split("=", 1)) + else: + key, val = q, "" + if key == "msg": + args["on_connect"] = [ "PRIVMSG %s :%s" % (target, unquote(val)) ] + elif key == "key": + args["channels"] = [ (target, unquote(val)) ] + elif key == "pass": + args["password"] = unquote(val) + elif key == "charset": + args["encoding"] = unquote(val) if "channels" not in args and "isnick" not in modifiers: - args["channels"] = [target] + args["channels"] = [ target ] - args["ssl"] = ssl + from nemubot.server.IRC import IRC as IRCServer + return IRCServer(**args) + else: + return None - from nemubot.server.IRCLib import IRCLib as IRCServer - srv = IRCServer(**args) - elif o.scheme == "matrix": - # matrix://localpart:password@homeserver.tld/!room:homeserver.tld - # matrix://localpart:password@homeserver.tld/%23alias:homeserver.tld - # Use matrixs:// for https (default) vs http - args = dict(init_args) +def reload(): + import imp - homeserver = "https://" + o.hostname - if o.port is not None: - homeserver += ":%d" % o.port - args["homeserver"] = homeserver + import nemubot.server.abstract + imp.reload(nemubot.server.abstract) - if o.username is not None: - args["user_id"] = o.username - if o.password is not None: - args["password"] = unquote(o.password) + import nemubot.server.socket + imp.reload(nemubot.server.socket) - # Parse rooms from path (comma-separated, URL-encoded) - if o.path and o.path != "/": - rooms = [unquote(r) for r in o.path.lstrip("/").split(",") if r] - if rooms: - args.setdefault("channels", []).extend(rooms) - - params = parse_qs(o.query) - if "token" in params: - args["access_token"] = params["token"][0] - if "nick" in params: - args["nick"] = params["nick"][0] - if "owner" in params: - args["owner"] = params["owner"][0] - - from nemubot.server.Matrix import Matrix as MatrixServer - srv = MatrixServer(**args) - - return srv + import nemubot.server.IRC + imp.reload(nemubot.server.IRC) diff --git a/nemubot/server/abstract.py b/nemubot/server/abstract.py index 8fbb923..b3c70ca 100644 --- a/nemubot/server/abstract.py +++ b/nemubot/server/abstract.py @@ -1,5 +1,7 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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,65 +16,71 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import io import logging import queue -import traceback -from nemubot.bot import sync_act +from nemubot.server import _lock, _rlist, _wlist, _xlist - -class AbstractServer: +# Extends from IOBase in order to be compatible with select function +class AbstractServer(io.IOBase): """An abstract server: handle communication with an IM server""" - def __init__(self, name, fdClass, **kwargs): + def __init__(self, send_callback=None): """Initialize an abstract server Keyword argument: - name -- Identifier of the socket, for convinience - fdClass -- Class to instantiate as support file + send_callback -- Callback when developper want to send a message """ - self._name = name - self._fd = fdClass(**kwargs) + if not hasattr(self, "id"): + raise Exception("No id defined for this server. Please set one!") - self._logger = logging.getLogger("nemubot.server." + str(self.name)) - self._readbuffer = b'' + self.logger = logging.getLogger("nemubot.server." + self.id) self._sending_queue = queue.Queue() - - - @property - def name(self): - if self._name is not None: - return self._name + if send_callback is not None: + self._send_callback = send_callback else: - return self._fd.fileno() + self._send_callback = self._write_select # Open/close - def connect(self, *args, **kwargs): - """Register the server in _poll""" - - self._logger.info("Opening connection") - - self._fd.connect(*args, **kwargs) - - self._on_connect() - - def _on_connect(self): - sync_act("sckt", "register", self._fd.fileno()) + def __enter__(self): + self.open() + return self - def close(self, *args, **kwargs): - """Unregister the server from _poll""" + def __exit__(self, type, value, traceback): + self.close() - self._logger.info("Closing connection") - if self._fd.fileno() > 0: - sync_act("sckt", "unregister", self._fd.fileno()) + def open(self): + """Generic open function that register the server un _rlist in case + of successful _open""" + self.logger.info("Opening connection to %s", self.id) + if not hasattr(self, "_open") or self._open(): + _rlist.append(self) + _xlist.append(self) + return True + return False - self._fd.close(*args, **kwargs) + + def close(self): + """Generic close function that register the server un _{r,w,x}list in + case of successful _close""" + self.logger.info("Closing connection to %s", self.id) + with _lock: + if not hasattr(self, "_close") or self._close(): + if self in _rlist: + _rlist.remove(self) + if self in _wlist: + _wlist.remove(self) + if self in _xlist: + _xlist.remove(self) + return True + return False # Writes @@ -84,16 +92,13 @@ class AbstractServer: message -- message to send """ - self._sending_queue.put(self.format(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._fd.fileno()) + self._send_callback(message) - def async_write(self): - """Internal function used when the file descriptor is writable""" - + def write_select(self): + """Internal function used by the select function""" try: - sync_act("sckt", "unwrite", self._fd.fileno()) + _wlist.remove(self) while not self._sending_queue.empty(): self._write(self._sending_queue.get_nowait()) self._sending_queue.task_done() @@ -102,6 +107,19 @@ class AbstractServer: pass + def _write_select(self, message): + """Send a message to the server safely through select + + Argument: + message -- message to send + """ + + self._sending_queue.put(self.format(message)) + self.logger.debug("Message '%s' appended to write queue", message) + if self not in _wlist: + _wlist.append(self) + + def send_response(self, response): """Send a formated Message class @@ -124,44 +142,13 @@ class AbstractServer: # Read - def async_read(self): - """Internal function used when the file descriptor is readable - - Returns: - A list of fully received messages - """ - - ret, self._readbuffer = self.lex(self._readbuffer + self.read()) - - for r in ret: - yield r - - - def lex(self, buf): - """Assume lexing in default case is per line - - Argument: - buf -- buffer to lex - """ - - msgs = buf.split(b'\r\n') - partial = msgs.pop() - - return msgs, partial - - def parse(self, msg): raise NotImplemented # Exceptions - def exception(self, flags): - """Exception occurs on fd""" - - self._fd.close() - - # Proxy - - def fileno(self): - return self._fd.fileno() + def exception(self): + """Exception occurs in fd""" + self.logger.warning("Unhandle file descriptor exception on server %s", + self.id) diff --git a/nemubot/server/factory_test.py b/nemubot/server/factory_test.py new file mode 100644 index 0000000..1296414 --- /dev/null +++ b/nemubot/server/factory_test.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- + +# Nemubot is a smart and modulable IM bot. +# Copyright (C) 2012-2015 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import unittest + +from nemubot.server import factory + +class TestFactory(unittest.TestCase): + + def test_IRC1(self): + from nemubot.server.IRC import IRC as IRCServer + + # : If omitted, the client must connect to a prespecified default IRC server. + server = factory("irc:///") + self.assertIsInstance(server, IRCServer) + self.assertEqual(server.host, "localhost") + self.assertFalse(server.ssl) + + server = factory("ircs:///") + self.assertIsInstance(server, IRCServer) + self.assertEqual(server.host, "localhost") + self.assertTrue(server.ssl) + + server = factory("irc://host1") + self.assertIsInstance(server, IRCServer) + self.assertEqual(server.host, "host1") + self.assertFalse(server.ssl) + + server = factory("irc://host2:6667") + self.assertIsInstance(server, IRCServer) + self.assertEqual(server.host, "host2") + self.assertEqual(server.port, 6667) + self.assertFalse(server.ssl) + + server = factory("ircs://host3:194/") + self.assertIsInstance(server, IRCServer) + self.assertEqual(server.host, "host3") + self.assertEqual(server.port, 194) + self.assertTrue(server.ssl) + + +if __name__ == '__main__': + unittest.main() diff --git a/nemubot/server/message/IRC.py b/nemubot/server/message/IRC.py new file mode 100644 index 0000000..6249716 --- /dev/null +++ b/nemubot/server/message/IRC.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- + +# Nemubot is a smart and modulable IM bot. +# Copyright (C) 2012-2015 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from datetime import datetime, timezone +import re +import shlex + +import nemubot.message as message +from nemubot.server.message.abstract import Abstract + +mgx = re.compile(b'''^(?:@(?P[^ ]+)\ )? + (?::(?P + (?P[^!@ ]+) + (?: !(?P[^@ ]+))? + (?:@(?P[^ ]*))? + )\ )? + (?P(?:[a-zA-Z]+|[0-9]{3})) + (?P(?:\ [^:][^ ]*)*)(?:\ :(?P.*))? + $''', re.X) + +class IRC(Abstract): + + """Class responsible for parsing IRC messages""" + + def __init__(self, raw, encoding="utf-8"): + self.encoding = encoding + self.tags = { 'time': datetime.now(timezone.utc) } + self.params = list() + + p = mgx.match(raw.rstrip()) + + if p is None: + raise Exception("Not a valid IRC message: %s" % raw) + + # Parse tags if exists: @aaa=bbb;ccc;example.com/ddd=eee + if p.group("tags"): + for tgs in self.decode(p.group("tags")).split(';'): + tag = tgs.split('=') + if len(tag) > 1: + self.add_tag(tag[0], tag[1]) + else: + self.add_tag(tag[0]) + + # Parse prefix if exists: :nick!user@host.com + self.prefix = self.decode(p.group("prefix")) + self.nick = self.decode(p.group("nick")) + self.user = self.decode(p.group("user")) + self.host = self.decode(p.group("host")) + + # Parse command + self.cmd = self.decode(p.group("command")) + + # Parse params + if p.group("params") is not None and p.group("params") != b'': + for param in p.group("params").strip().split(b' '): + self.params.append(param) + + if p.group("trailing") is not None: + self.params.append(p.group("trailing")) + + + def add_tag(self, key, value=None): + """Add an IRCv3.2 Message Tags + + Arguments: + key -- tag identifier (unique for the message) + value -- optional value for the tag + """ + + # Treat special tags + if key == "time" and value is not None: + import calendar, time + value = datetime.fromtimestamp(calendar.timegm(time.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ")), timezone.utc) + + # Store tag + self.tags[key] = value + + + @property + def is_ctcp(self): + """Analyze a message, to determine if this is a CTCP one""" + return self.cmd == "PRIVMSG" and len(self.params) == 2 and len(self.params[1]) > 1 and (self.params[1][0] == 0x01 or self.params[1][1] == 0x01) + + + def decode(self, s): + """Decode the content string usign a specific encoding + + Argument: + s -- string to decode + """ + + if isinstance(s, bytes): + try: + s = s.decode() + except UnicodeDecodeError: + s = s.decode(self.encoding, 'replace') + return s + + + + def to_server_string(self, client=True): + """Pretty print the message to close to original input string + + Keyword argument: + client -- export as a client-side string if true + """ + + res = ";".join(["@%s=%s" % (k, v if not isinstance(v, datetime) else v.strftime("%Y-%m-%dT%H:%M:%S.%fZ")) for k, v in self.tags.items()]) + + if not client: + res += " :%s!%s@%s" % (self.nick, self.user, self.host) + + res += " " + self.cmd + + if len(self.params) > 0: + + if len(self.params) > 1: + res += " " + self.decode(b" ".join(self.params[:-1])) + res += " :" + self.decode(self.params[-1]) + + return res + + + def to_bot_message(self, srv): + """Convert to one of concrete implementation of AbstractMessage + + Argument: + srv -- the server from the message was received + """ + + if self.cmd == "PRIVMSG" or self.cmd == "NOTICE": + + receivers = self.decode(self.params[0]).split(',') + + common_args = { + "server": srv.id, + "date": self.tags["time"], + "to": receivers, + "to_response": [r if r != srv.nick else self.nick for r in receivers], + "frm": self.nick + } + + # If CTCP, remove 0x01 + if self.is_ctcp: + text = self.decode(self.params[1][1:len(self.params[1])-1]) + else: + text = self.decode(self.params[1]) + + if text.find(srv.nick) == 0 and len(text) > len(srv.nick) + 2 and text[len(srv.nick)] == ":": + designated = srv.nick + text = text[len(srv.nick) + 1:].strip() + else: + designated = None + + # Is this a command? + if len(text) > 1 and text[0] == '!': + text = text[1:].strip() + + # Split content by words + try: + args = shlex.split(text) + except ValueError: + args = text.split(' ') + + # Extract explicit named arguments: @key=value or just @key + kwargs = {} + for i in range(len(args) - 1, 0, -1): + arg = args[i] + if len(arg) > 2: + if arg[0:1] == '\\@': + args[i] = arg[1:] + elif arg[0] == '@': + arsp = arg[1:].split("=", 1) + if len(arsp) == 2: + kwargs[arsp[0]] = arsp[1] + else: + kwargs[arg[1:]] = None + args.pop(i) + + return message.Command(cmd=args[0], + args=args[1:], + kwargs=kwargs, + **common_args) + + # Is this an ask for this bot? + elif designated is not None: + return message.DirectAsk(designated=designated, message=text, **common_args) + + # Normal message + else: + return message.Text(message=text, **common_args) + + return None diff --git a/nemubot/message/response.py b/nemubot/server/message/abstract.py similarity index 64% rename from nemubot/message/response.py rename to nemubot/server/message/abstract.py index f9353ad..03e10cd 100644 --- a/nemubot/message/response.py +++ b/nemubot/server/message/abstract.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # @@ -14,16 +16,20 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from nemubot.message.abstract import Abstract +class Abstract: + + def to_bot_message(self, srv): + """Convert to one of concrete implementation of AbstractMessage + + Argument: + srv -- the server from the message was received + """ + + raise NotImplemented -class Response(Abstract): + def to_server_string(self, **kwargs): + """Pretty print the message to close to original input string + """ - def __init__(self, cmd, args=None, *nargs, **kargs): - super().__init__(*nargs, **kargs) - - self.cmd = cmd - self.args = args if args is not None else list() - - def __str__(self): - return self.cmd + " @" + ",@".join(self.args) + raise NotImplemented diff --git a/nemubot/server/socket.py b/nemubot/server/socket.py index bf55bf5..f810906 100644 --- a/nemubot/server/socket.py +++ b/nemubot/server/socket.py @@ -1,5 +1,7 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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,32 +16,100 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import os -import socket - import nemubot.message as message from nemubot.message.printer.socket import Socket as SocketPrinter from nemubot.server.abstract import AbstractServer -class _Socket(AbstractServer): +class SocketServer(AbstractServer): - """Concrete implementation of a socket connection""" + """Concrete implementation of a socket connexion (can be wrapped with TLS)""" - def __init__(self, printer=SocketPrinter, **kwargs): - """Create a server socket - """ - - super().__init__(**kwargs) + def __init__(self, sock_location=None, host=None, port=None, ssl=False, socket=None, id=None): + if id is not None: + self.id = id + AbstractServer.__init__(self) + if sock_location is not None: + self.filename = sock_location + elif host is not None: + self.host = host + self.port = int(port) + self.ssl = ssl + self.socket = socket self.readbuffer = b'' - self.printer = printer + self.printer = SocketPrinter + + + def fileno(self): + return self.socket.fileno() if self.socket else None + + + @property + def connected(self): + """Indicator of the connection aliveness""" + return self.socket is not None + + + # Open/close + + def _open(self): + import os + import socket + + if self.connected: + return True + + try: + if hasattr(self, "filename"): + self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.socket.connect(self.filename) + self.logger.info("Connected to %s", self.filename) + else: + self.socket = socket.create_connection((self.host, self.port)) + self.logger.info("Connected to %s:%d", self.host, self.port) + except socket.error as e: + self.socket = None + self.logger.critical("Unable to connect to %s:%d: %s", + self.host, self.port, + os.strerror(e.errno)) + return False + + # Wrap the socket for SSL + if self.ssl: + import ssl + ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1) + self.socket = ctx.wrap_socket(self.socket) + + return True + + + def _close(self): + import socket + + from nemubot.server import _lock + _lock.release() + self._sending_queue.join() + _lock.acquire() + if self.connected: + try: + self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() + except socket.error: + pass + + self.socket = None + + return True # Write def _write(self, cnt): - self._fd.sendall(cnt) + if not self.connected: + return + + self.socket.send(cnt) def format(self, txt): @@ -51,12 +121,19 @@ class _Socket(AbstractServer): # Read - def read(self, bufsize=1024, *args, **kwargs): - return self._fd.recv(bufsize, *args, **kwargs) + def read(self): + if not self.connected: + return [] + + raw = self.socket.recv(1024) + temp = (self.readbuffer + raw).split(b'\r\n') + self.readbuffer = temp.pop() + + for line in temp: + yield line def parse(self, line): - """Implement a default behaviour for socket""" import shlex line = line.strip().decode() @@ -65,108 +142,71 @@ class _Socket(AbstractServer): except ValueError: args = line.split(' ') - if len(args): - yield message.Command(cmd=args[0], args=args[1:], server=self._fd.fileno(), to=["you"], frm="you") + yield message.Command(cmd=args[0], args=args[1:], server=self) - def subparse(self, orig, cnt): - for m in self.parse(cnt): - m.to = orig.to - m.frm = orig.frm - m.date = orig.date - yield m +class SocketListener(AbstractServer): + + def __init__(self, new_server_cb, id, sock_location=None, host=None, port=None, ssl=None): + self.id = id + AbstractServer.__init__(self) + self.new_server_cb = new_server_cb + self.sock_location = sock_location + self.host = host + self.port = port + self.ssl = ssl + self.nb_son = 0 -class SocketServer(_Socket): - - def __init__(self, host, port, bind=None, trynb=0, **kwargs): - destlist = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP) - (family, type, proto, canonname, self._sockaddr) = destlist[trynb%len(destlist)] - - super().__init__(fdClass=socket.socket, family=family, type=type, proto=proto, **kwargs) - - self._bind = bind + def fileno(self): + return self.socket.fileno() if self.socket else None - def connect(self): - self._logger.info("Connecting to %s:%d", *self._sockaddr[:2]) - super().connect(self._sockaddr) - self._logger.info("Connected to %s:%d", *self._sockaddr[:2]) - - if self._bind: - self._fd.bind(self._bind) + @property + def connected(self): + """Indicator of the connection aliveness""" + return self.socket is not None -class UnixSocket: + def _open(self): + import os + import socket - def __init__(self, location, **kwargs): - super().__init__(fdClass=socket.socket, family=socket.AF_UNIX, **kwargs) + self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + if self.sock_location is not None: + try: + os.remove(self.sock_location) + except FileNotFoundError: + pass + self.socket.bind(self.sock_location) + elif self.host is not None and self.port is not None: + self.socket.bind((self.host, self.port)) + self.socket.listen(5) - self._socket_path = location + return True - def connect(self): - self._logger.info("Connection to unix://%s", self._socket_path) - self.connect(self._socket_path) - - -class SocketClient(_Socket): - - def __init__(self, **kwargs): - super().__init__(fdClass=socket.socket, **kwargs) - - -class _Listener: - - def __init__(self, new_server_cb, instanciate=SocketClient, **kwargs): - super().__init__(**kwargs) - - self._instanciate = instanciate - self._new_server_cb = new_server_cb - - - def read(self): - conn, addr = self._fd.accept() - fileno = conn.fileno() - self._logger.info("Accept new connection from %s (fd=%d)", addr, fileno) - - ss = self._instanciate(name=self.name + "#" + str(fileno), fileno=conn.detach()) - ss.connect = ss._on_connect - self._new_server_cb(ss, autoconnect=True) - - return b'' - - -class UnixSocketListener(_Listener, UnixSocket, _Socket): - - def connect(self): - self._logger.info("Creating Unix socket at unix://%s", self._socket_path) - - try: - os.remove(self._socket_path) - except FileNotFoundError: - pass - - self._fd.bind(self._socket_path) - self._fd.listen(5) - self._logger.info("Socket ready for accepting new connections") - - self._on_connect() - - - def close(self): + def _close(self): import os import socket try: - self._fd.shutdown(socket.SHUT_RDWR) + self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() + if self.sock_location is not None: + os.remove(self.sock_location) except socket.error: pass - super().close() + # Read - try: - if self._socket_path is not None: - os.remove(self._socket_path) - except: - pass + def read(self): + if not self.connected: + return [] + + conn, addr = self.socket.accept() + self.nb_son += 1 + ss = SocketServer(id=self.id + "#" + str(self.nb_son), socket=conn) + self.new_server_cb(ss) + + return [] diff --git a/nemubot/server/test_IRC.py b/nemubot/server/test_IRC.py new file mode 100644 index 0000000..552a1d3 --- /dev/null +++ b/nemubot/server/test_IRC.py @@ -0,0 +1,50 @@ +import unittest + +import nemubot.server.IRC as IRC + + +class TestIRCMessage(unittest.TestCase): + + + def setUp(self): + self.msg = IRC.IRCMessage(b":toto!titi@RZ-3je16g.re PRIVMSG #the-channel :Can you parse this message?") + + + def test_parsing(self): + self.assertEqual(self.msg.prefix, "toto!titi@RZ-3je16g.re") + self.assertEqual(self.msg.nick, "toto") + self.assertEqual(self.msg.user, "titi") + self.assertEqual(self.msg.host, "RZ-3je16g.re") + + self.assertEqual(len(self.msg.params), 2) + + self.assertEqual(self.msg.params[0], b"#the-channel") + self.assertEqual(self.msg.params[1], b"Can you parse this message?") + + + def test_prettyprint(self): + bst1 = self.msg.to_server_string(False) + msg2 = IRC.IRCMessage(bst1.encode()) + + bst2 = msg2.to_server_string(False) + msg3 = IRC.IRCMessage(bst2.encode()) + + bst3 = msg3.to_server_string(False) + + self.assertEqual(bst2, bst3) + + + def test_tags(self): + self.assertEqual(len(self.msg.tags), 1) + self.assertIn("time", self.msg.tags) + + self.msg.add_tag("time") + self.assertEqual(len(self.msg.tags), 1) + + self.msg.add_tag("toto") + self.assertEqual(len(self.msg.tags), 2) + self.assertIn("toto", self.msg.tags) + + +if __name__ == '__main__': + unittest.main() diff --git a/nemubot/server/threaded.py b/nemubot/server/threaded.py deleted file mode 100644 index eb1ae19..0000000 --- a/nemubot/server/threaded.py +++ /dev/null @@ -1,132 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2026 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import logging -import os -import queue - -from nemubot.bot import sync_act - - -class ThreadedServer: - - """A server backed by a library running in its own thread. - - Uses an os.pipe() as a fake file descriptor to integrate with the bot's - select.poll() main loop without requiring direct socket access. - - When the library thread has a message ready, it calls _push_message(), - which writes a wakeup byte to the pipe's write end. The bot's poll loop - sees the read end become readable, calls async_read(), which drains the - message queue and yields already-parsed bot-level messages. - - This abstraction lets any IM library (IRC via python-irc, Matrix via - matrix-nio, …) plug into nemubot without touching bot.py. - """ - - def __init__(self, name): - self._name = name - self._logger = logging.getLogger("nemubot.server." + name) - self._queue = queue.Queue() - self._pipe_r, self._pipe_w = os.pipe() - - - @property - def name(self): - return self._name - - def fileno(self): - return self._pipe_r - - - # Open/close - - def connect(self): - """Start the library and register the pipe read-end with the poll loop.""" - self._logger.info("Starting connection") - self._start() - sync_act("sckt", "register", self._pipe_r) - - def _start(self): - """Override: start the library's connection (e.g. launch a thread).""" - raise NotImplementedError - - def close(self): - """Unregister from poll, stop the library, and close the pipe.""" - self._logger.info("Closing connection") - sync_act("sckt", "unregister", self._pipe_r) - self._stop() - for fd in (self._pipe_w, self._pipe_r): - try: - os.close(fd) - except OSError: - pass - - def _stop(self): - """Override: stop the library thread gracefully.""" - pass - - - # Writes - - def send_response(self, response): - """Override: send a response via the underlying library.""" - raise NotImplementedError - - def async_write(self): - """No-op: writes go directly through the library, not via poll.""" - pass - - - # Read - - def _push_message(self, msg): - """Called from the library thread to enqueue a bot-level message. - - Writes a wakeup byte to the pipe so the main loop wakes up and - calls async_read(). - """ - self._queue.put(msg) - try: - os.write(self._pipe_w, b'\x00') - except OSError: - pass # pipe closed during shutdown - - def async_read(self): - """Called by the bot when the pipe is readable. - - Drains the wakeup bytes and yields all queued bot messages. - """ - try: - os.read(self._pipe_r, 256) - except OSError: - return - while not self._queue.empty(): - try: - yield self._queue.get_nowait() - except queue.Empty: - break - - def parse(self, msg): - """Messages pushed via _push_message are already bot-level — pass through.""" - yield msg - - - # Exceptions - - def exception(self, flags): - """Called by the bot on POLLERR/POLLHUP/POLLNVAL.""" - self._logger.warning("Exception on server %s: flags=0x%x", self._name, flags) diff --git a/nemubot/tools/__init__.py b/nemubot/tools/__init__.py index 57f3468..95be66a 100644 --- a/nemubot/tools/__init__.py +++ b/nemubot/tools/__init__.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # @@ -13,3 +15,26 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . + +def reload(): + import imp + + import nemubot.tools.config + imp.reload(nemubot.tools.config) + + import nemubot.tools.countdown + imp.reload(nemubot.tools.countdown) + + import nemubot.tools.date + imp.reload(nemubot.tools.date) + + import nemubot.tools.human + imp.reload(nemubot.tools.human) + + import nemubot.tools.web + imp.reload(nemubot.tools.web) + + import nemubot.tools.xmlparser + imp.reload(nemubot.tools.xmlparser) + import nemubot.tools.xmlparser.node + imp.reload(nemubot.tools.xmlparser.node) diff --git a/nemubot/tools/config.py b/nemubot/tools/config.py new file mode 100644 index 0000000..479b96f --- /dev/null +++ b/nemubot/tools/config.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- + +# Nemubot is a smart and modulable IM bot. +# Copyright (C) 2012-2015 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import logging + +logger = logging.getLogger("nemubot.tools.config") + + +def get_boolean(d, k, default=False): + return ((k in d and d[k].lower() != "false" and d[k].lower() != "off") or + (k not in d and default)) + + +def _load_server(config, xmlnode): + """Load a server configuration + + Arguments: + config -- the global configuration + xmlnode -- the current server configuration node + """ + + opts = { + "host": xmlnode["host"], + "ssl": xmlnode.hasAttribute("ssl") and xmlnode["ssl"].lower() == "true", + + "nick": xmlnode["nick"] if xmlnode.hasAttribute("nick") else config["nick"], + "owner": xmlnode["owner"] if xmlnode.hasAttribute("owner") else config["owner"], + } + + # Optional keyword arguments + for optional_opt in [ "port", "username", "realname", + "password", "encoding", "caps" ]: + if xmlnode.hasAttribute(optional_opt): + opts[optional_opt] = xmlnode[optional_opt] + elif optional_opt in config: + opts[optional_opt] = config[optional_opt] + + # Command to send on connection + if "on_connect" in xmlnode: + def on_connect(): + yield xmlnode["on_connect"] + opts["on_connect"] = on_connect + + # Channels to autojoin on connection + if xmlnode.hasNode("channel"): + opts["channels"] = list() + for chn in xmlnode.getNodes("channel"): + opts["channels"].append((chn["name"], chn["password"]) + if chn["password"] is not None + else chn["name"]) + + # Server/client capabilities + if "caps" in xmlnode or "caps" in config: + capsl = (xmlnode["caps"] if xmlnode.hasAttribute("caps") + else config["caps"]).lower() + if capsl == "no" or capsl == "off" or capsl == "false": + opts["caps"] = None + else: + opts["caps"] = capsl.split(',') + else: + opts["caps"] = list() + + # Bind the protocol asked to the corresponding implementation + if "protocol" not in xmlnode or xmlnode["protocol"] == "irc": + from nemubot.server.IRC import IRC as IRCServer + srvcls = IRCServer + else: + raise Exception("Unhandled protocol '%s'" % + xmlnode["protocol"]) + + # Initialize the server + return srvcls(**opts) + + +def load_file(filename, context): + """Load the configuration file + + Arguments: + filename -- the path to the file to load + """ + + import os + + if os.path.isfile(filename): + from nemubot.tools.xmlparser import parse_file + + config = parse_file(filename) + + # This is a true nemubot configuration file, load it! + if config.getName() == "nemubotconfig": + # Preset each server in this file + for server in config.getNodes("server"): + srv = _load_server(config, server) + + # Add the server in the context + if context.add_server(srv, get_boolean(server, "autoconnect")): + logger.info("Server '%s' successfully added." % srv.id) + else: + logger.error("Can't add server '%s'." % srv.id) + + # Load module and their configuration + for mod in config.getNodes("module"): + context.modules_configuration[mod["name"]] = mod + if get_boolean(mod, "autoload", default=True): + 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.getNodes("include"): + load_file(load["path"], context) + + # Other formats + else: + logger.error("Can't load `%s'; this is not a valid nemubot " + "configuration file." % filename) + + # Unexisting file, assume a name was passed, import the module! + else: + context.import_module(filename) diff --git a/nemubot/tools/countdown.py b/nemubot/tools/countdown.py index afd585f..58bdc55 100644 --- a/nemubot/tools/countdown.py +++ b/nemubot/tools/countdown.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # diff --git a/nemubot/tools/date.py b/nemubot/tools/date.py index 9e9bbad..da46756 100644 --- a/nemubot/tools/date.py +++ b/nemubot/tools/date.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # @@ -18,23 +20,8 @@ import re -month_binding = { - "janvier": 1, "january": 1, "januar": 1, - "fevrier": 2, "février": 2, "february": 2, - "march": 3, "mars": 3, - "avril": 4, "april": 4, - "mai": 5, "may": 5, "maï": 5, - "juin": 6, "juni": 6, "junni": 6, - "juillet": 7, "jully": 7, "july": 7, - "aout": 8, "août": 8, "august": 8, - "septembre": 9, "september": 9, - "october": 10, "oktober": 10, "octobre": 10, - "november": 11, "novembre": 11, - "decembre": 12, "décembre": 12, "december": 12, -} - xtrdt = re.compile(r'''^.*? (?P[0-9]{1,4}) .+? - (?P[0-9]{1,2}|"''' + "|".join(month_binding) + '''") + (?P[0-9]{1,2}|janvier|january|fevrier|février|february|mars|march|avril|april|mai|maï|may|juin|juni|juillet|july|jully|august|aout|août|septembre|september|october|octobre|oktober|novembre|november|decembre|décembre|december) (?:.+?(?P[0-9]{1,4}))? (?:[^0-9]+ (?:(?P[0-9]{1,2})[^0-9]*[h':] (?:[^0-9]*(?P[0-9]{1,2}) @@ -48,9 +35,30 @@ def extractDate(msg): if result is not None: day = result.group("day") month = result.group("month") - - if month in month_binding: - month = month_binding[month] + if month == "janvier" or month == "january" or month == "januar": + month = 1 + elif month == "fevrier" or month == "février" or month == "february": + month = 2 + elif month == "mars" or month == "march": + month = 3 + elif month == "avril" or month == "april": + month = 4 + elif month == "mai" or month == "may" or month == "maï": + month = 5 + elif month == "juin" or month == "juni" or month == "junni": + month = 6 + elif month == "juillet" or month == "jully" or month == "july": + month = 7 + elif month == "aout" or month == "août" or month == "august": + month = 8 + elif month == "september" or month == "septembre": + month = 9 + elif month == "october" or month == "october" or month == "oktober": + month = 10 + elif month == "november" or month == "novembre": + month = 11 + elif month == "december" or month == "decembre" or month == "décembre": + month = 12 year = result.group("year") diff --git a/nemubot/tools/feed.py b/nemubot/tools/feed.py index 6f8930d..0e1f313 100644 --- a/nemubot/tools/feed.py +++ b/nemubot/tools/feed.py @@ -1,4 +1,5 @@ #!/usr/bin/python3 +# coding=utf-8 import datetime import time @@ -82,16 +83,11 @@ class RSSEntry: else: self.summary = None - if len(node.getElementsByTagName("link")) > 0: - self.link = node.getElementsByTagName("link")[0].firstChild.nodeValue + if len(node.getElementsByTagName("link")) > 0 and node.getElementsByTagName("link")[0].hasAttribute("href"): + self.link = node.getElementsByTagName("link")[0].getAttribute("href") else: self.link = None - if len(node.getElementsByTagName("enclosure")) > 0 and node.getElementsByTagName("enclosure")[0].hasAttribute("url"): - self.enclosure = node.getElementsByTagName("enclosure")[0].getAttribute("url") - else: - self.enclosure = None - def __repr__(self): return "" % (self.title, self.pubDate) @@ -110,13 +106,13 @@ class Feed: self.updated = None self.entries = list() - if self.feed.tagName == "rdf:RDF" or self.feed.tagName == "rss": + if self.feed.tagName == "rss": self._parse_rss_feed() elif self.feed.tagName == "feed": self._parse_atom_feed() else: - from nemubot.exception import IMException - raise IMException("This is not a valid Atom or RSS feed.") + from nemubot.exception import IRCException + raise IRCException("This is not a valid Atom or RSS feed") def _parse_atom_feed(self): diff --git a/nemubot/tools/human.py b/nemubot/tools/human.py index a18cde2..588ac1f 100644 --- a/nemubot/tools/human.py +++ b/nemubot/tools/human.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # diff --git a/nemubot/tools/test_xmlparser.py b/nemubot/tools/test_xmlparser.py deleted file mode 100644 index 0feda73..0000000 --- a/nemubot/tools/test_xmlparser.py +++ /dev/null @@ -1,113 +0,0 @@ -import unittest - -import io -import xml.parsers.expat - -from nemubot.tools.xmlparser import XMLParser - - -class StringNode(): - def __init__(self): - self.string = "" - - 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): - self.option = option - self.mystr = None - - def addChild(self, name, child): - 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): - self.option = option - self.mystrs = list() - - def startElement(self, name, attrs): - if name == "string": - 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): - - def test_parser1(self): - p = xml.parsers.expat.ParserCreate() - mod = XMLParser({"string": StringNode}) - - p.StartElementHandler = mod.startElement - p.CharacterDataHandler = mod.characters - p.EndElementHandler = mod.endElement - - inputstr = "toto" - p.Parse(inputstr, 1) - - self.assertEqual(mod.root.string, "toto") - self.assertEqual(mod.saveDocument(header=False).getvalue(), inputstr) - - - def test_parser2(self): - p = xml.parsers.expat.ParserCreate() - mod = XMLParser({"string": StringNode, "test": TestNode}) - - p.StartElementHandler = mod.startElement - p.CharacterDataHandler = mod.characters - p.EndElementHandler = mod.endElement - - 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): - p = xml.parsers.expat.ParserCreate() - mod = XMLParser({"string": StringNode, "test": Test2Node}) - - p.StartElementHandler = mod.startElement - p.CharacterDataHandler = mod.characters - p.EndElementHandler = mod.endElement - - 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__': - unittest.main() diff --git a/nemubot/tools/web.py b/nemubot/tools/web.py index a545b19..4cec48a 100644 --- a/nemubot/tools/web.py +++ b/nemubot/tools/web.py @@ -1,3 +1,5 @@ +# coding=utf-8 + # Nemubot is a smart and modulable IM bot. # Copyright (C) 2012-2015 Mercier Pierre-Olivier # @@ -14,16 +16,15 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit -import socket +from urllib.parse import urlparse, urlsplit, urlunsplit -from nemubot.exception import IMException +from nemubot.exception import IRCException def isURL(url): """Return True if the URL can be parsed""" o = urlparse(_getNormalizedURL(url)) - return o.netloc != "" and o.path != "" + return o.netloc == "" and o.path == "" def _getNormalizedURL(url): @@ -68,7 +69,15 @@ def getPassword(url): # Get real pages -def _URLConn(cb, url, body=None, timeout=7, header=None, follow_redir=True): +def getURLContent(url, body=None, timeout=7, header=None): + """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 + """ + o = urlparse(_getNormalizedURL(url), "http") import http.client @@ -93,7 +102,7 @@ def _URLConn(cb, url, body=None, timeout=7, header=None, follow_redir=True): elif o.scheme is None or o.scheme == "": conn = http.client.HTTPConnection(**kwargs) else: - raise IMException("Invalid URL") + raise IRCException("Invalid URL") from nemubot import __version__ if header is None: @@ -101,9 +110,6 @@ def _URLConn(cb, url, body=None, timeout=7, header=None, follow_redir=True): 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 != '': @@ -116,61 +122,16 @@ def _URLConn(cb, url, body=None, timeout=7, header=None, follow_redir=True): o.path, body, header) - except socket.timeout as e: - raise IMException(e) except OSError as e: - raise IMException(e.strerror) + raise IRCException(e.strerror) try: res = conn.getresponse() - if follow_redir and ((res.status == http.client.FOUND or - res.status == http.client.MOVED_PERMANENTLY) and - res.getheader("Location") != url): - return _URLConn(cb, - url=urljoin(url, res.getheader("Location")), - body=body, - timeout=timeout, - header=header, - follow_redir=follow_redir) - return cb(res) - except http.client.BadStatusLine: - raise IMException("Invalid HTTP response") - finally: - conn.close() - - -def getURLHeaders(url, body=None, timeout=7, header=None, follow_redir=True): - """Return page headers 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 - """ - - def next(res): - return res.status, res.getheaders() - return _URLConn(next, url=url, body=body, timeout=timeout, header=header, follow_redir=follow_redir) - - -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: - 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 - max_size -- maximal size allow for the content - """ - - def _nextURLContent(res): size = int(res.getheader("Content-Length", 524288)) cntype = res.getheader("Content-Type") - if max_size >= 0 and (size > max_size or (cntype is not None and cntype[:4] != "text" and cntype[:4] != "appl")): - raise IMException("Content too large to be retrieved") + if size > 524288 or (cntype is not None and cntype[:4] != "text" and cntype[:4] != "appl"): + raise IRCException("Content too large to be retrieved") data = res.read(size) @@ -187,48 +148,52 @@ def getURLContent(url, body=None, timeout=7, header=None, decode_error=False, charset = cha[1] else: charset = cha[0] + except http.client.BadStatusLine: + raise IRCException("Invalid HTTP response") + finally: + conn.close() - import http.client - - if res.status == http.client.OK or res.status == http.client.SEE_OTHER: - return data.decode(charset, errors='ignore').strip() - elif decode_error: - return data.decode(charset, errors='ignore').strip() - else: - raise IMException("A HTTP error occurs: %d - %s" % - (res.status, http.client.responses[res.status])) - - return _URLConn(_nextURLContent, url=url, body=body, timeout=timeout, header=header) + if res.status == http.client.OK or res.status == http.client.SEE_OTHER: + return data.decode(charset).strip() + 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) + else: + raise IRCException("A HTTP error occurs: %d - %s" % + (res.status, http.client.responses[res.status])) -def getXML(*args, **kwargs): +def getXML(url, timeout=7): """Get content page and return XML parsed content - Arguments: same as getURLContent + Arguments: + url -- the URL to get + timeout -- maximum number of seconds to wait before returning an exception """ - cnt = getURLContent(*args, **kwargs) + cnt = getURLContent(url, timeout=timeout) if cnt is None: return None else: - from xml.dom.minidom import parseString - return parseString(cnt) + from nemubot.tools.xmlparser import parse_string + return parse_string(cnt.encode()) -def getJSON(*args, remove_callback=False, **kwargs): +def getJSON(url, timeout=7): """Get content page and return JSON content - Arguments: same as getURLContent + Arguments: + url -- the URL to get + timeout -- maximum number of seconds to wait before returning an exception """ - cnt = getURLContent(*args, **kwargs) + import json + + cnt = getURLContent(url, timeout=timeout) if cnt is None: return None else: - import json - if remove_callback: - import re - cnt = re.sub(r"^[^(]+\((.*)\)$", r"\1", cnt) return json.loads(cnt) @@ -241,7 +206,7 @@ def striphtml(data): data -- the string to strip """ - if not isinstance(data, str) and not isinstance(data, bytes): + if not isinstance(data, str) and not isinstance(data, buffer): return data try: @@ -270,5 +235,6 @@ def striphtml(data): import re - return re.sub(r' +', ' ', - unescape(re.sub(r'<.*?>', '', data)).replace('\n', ' ')) + r, _ = re.subn(r' +', ' ', + unescape(re.sub(r'<.*?>', '', data)).replace('\n', ' ')) + return r diff --git a/nemubot/tools/xmlparser/__init__.py b/nemubot/tools/xmlparser/__init__.py index 1bf60a8..4617b57 100644 --- a/nemubot/tools/xmlparser/__init__.py +++ b/nemubot/tools/xmlparser/__init__.py @@ -1,5 +1,7 @@ +# -*- coding: utf-8 -*- + # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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 @@ -46,119 +48,9 @@ class ModuleStatesFile: self.root = child -class XMLParser: - - def __init__(self, knodes): - self.knodes = knodes - - self.stack = list() - self.child = 0 - - - def parse_file(self, path): - p = xml.parsers.expat.ParserCreate() - - p.StartElementHandler = self.startElement - p.CharacterDataHandler = self.characters - p.EndElementHandler = self.endElement - - with open(path, "rb") as f: - p.ParseFile(f) - - return self.root - - - def parse_string(self, s): - p = xml.parsers.expat.ParserCreate() - - p.StartElementHandler = self.startElement - p.CharacterDataHandler = self.characters - p.EndElementHandler = self.endElement - - p.Parse(s, 1) - - return self.root - - - @property - def root(self): - if len(self.stack): - return self.stack[0][0] - else: - return None - - - @property - def current(self): - if len(self.stack): - return self.stack[-1][0] - else: - return None - - - def display_stack(self): - return " in ".join([str(type(s).__name__) for s,c in reversed(self.stack)]) - - - def startElement(self, name, attrs): - if not self.current or not hasattr(self.current, "startElement") or not self.current.startElement(name, attrs): - if name not in self.knodes: - raise TypeError(name + " is not a known type to decode") - else: - self.stack.append((self.knodes[name](**attrs), self.child)) - self.child = 0 - else: - self.child += 1 - - - def characters(self, content): - if self.current and hasattr(self.current, "characters"): - self.current.characters(content) - - - def endElement(self, name): - if hasattr(self.current, "endElement"): - self.current.endElement(None) - - if self.child: - self.child -= 1 - - # Don't remove root - elif len(self.stack) > 1: - last, self.child = self.stack.pop() - if hasattr(self.current, "addChild"): - if self.current.addChild(name, last): - 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() - mod = ModuleStatesFile() - - p.StartElementHandler = mod.startElement - p.EndElementHandler = mod.endElement - p.CharacterDataHandler = mod.characters - - with open(filename, "rb") as f: - p.ParseFile(f) - - return mod.root + with open(filename, "r") as f: + return parse_string(f.read()) def parse_string(string): diff --git a/nemubot/tools/xmlparser/basic.py b/nemubot/tools/xmlparser/basic.py deleted file mode 100644 index dadff23..0000000 --- a/nemubot/tools/xmlparser/basic.py +++ /dev/null @@ -1,153 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -class ListNode: - - """XML node representing a Python dictionnnary - """ - - def __init__(self, **kwargs): - self.items = list() - - - def addChild(self, name, child): - self.items.append(child) - return True - - - def __len__(self): - return len(self.items) - - def __getitem__(self, item): - return self.items[item] - - def __setitem__(self, item, v): - self.items[item] = v - - def __contains__(self, item): - return item in self.items - - def __repr__(self): - 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 - """ - - def __init__(self, **kwargs): - self.items = dict() - self._cur = None - - - def startElement(self, name, attrs): - if self._cur is None and "key" in attrs: - self._cur = (attrs["key"], "") - return True - return False - - - def characters(self, content): - if self._cur is not None: - key, cnt = self._cur - if isinstance(cnt, str): - cnt += content - self._cur = key, cnt - - - def endElement(self, name): - 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[0] - else: - self.items[key] = cnt - - self._cur = None - return True - - - def addChild(self, name, child): - if self._cur is None: - return False - - key, cnt = self._cur - if not isinstance(cnt, list): - cnt = [] - cnt.append(child) - self._cur = key, cnt - return True - - - def __getitem__(self, item): - return self.items[item] - - def __setitem__(self, item, v): - self.items[item] = v - - def __contains__(self, item): - return item in self.items - - 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: - 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() diff --git a/nemubot/tools/xmlparser/genericnode.py b/nemubot/tools/xmlparser/genericnode.py deleted file mode 100644 index 425934c..0000000 --- a/nemubot/tools/xmlparser/genericnode.py +++ /dev/null @@ -1,102 +0,0 @@ -# Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 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 -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -class ParsingNode: - - """Allow any kind of subtags, just keep parsed ones - """ - - def __init__(self, tag=None, **kwargs): - self.tag = tag - self.attrs = kwargs - self.content = "" - self.children = [] - - - def characters(self, content): - self.content += content - - - def addChild(self, name, child): - self.children.append(child) - return True - - - def hasNode(self, nodename): - return self.getNode(nodename) is not None - - - def getNode(self, nodename): - for c in self.children: - if c is not None and c.tag == nodename: - return c - return None - - - def __getitem__(self, item): - return self.attrs[item] - - def __contains__(self, item): - 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 - """ - - def __init__(self, tag, **kwargs): - super().__init__(tag, **kwargs) - self._cur = None - self._deep_cur = 0 - - - def startElement(self, name, attrs): - if self._cur is None: - self._cur = GenericNode(name, **attrs) - self._deep_cur = 0 - else: - self._deep_cur += 1 - self._cur.startElement(name, attrs) - return True - - - def characters(self, content): - if self._cur is None: - super().characters(content) - else: - self._cur.characters(content) - - - def endElement(self, name): - if name is None: - return - - if self._deep_cur: - self._deep_cur -= 1 - self._cur.endElement(name) - else: - self.children.append(self._cur) - self._cur = None - return True diff --git a/nemubot/tools/xmlparser/node.py b/nemubot/tools/xmlparser/node.py index 7df255e..5f8a509 100644 --- a/nemubot/tools/xmlparser/node.py +++ b/nemubot/tools/xmlparser/node.py @@ -1,5 +1,7 @@ +# coding=utf-8 + # Nemubot is a smart and modulable IM bot. -# Copyright (C) 2012-2016 Mercier Pierre-Olivier +# Copyright (C) 2012-2015 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 @@ -35,7 +37,7 @@ class ModuleState: """Get the name of the current node""" return self.name - def display(self, level=0): + def display(self, level = 0): ret = "" out = list() for k in self.attributes: @@ -49,9 +51,6 @@ class ModuleState: def __str__(self): return self.display() - def __repr__(self): - return self.display() - def __getitem__(self, i): """Return the attribute asked""" return self.getAttribute(i) @@ -196,7 +195,7 @@ class ModuleState: if self.index_fieldname is not None: self.setIndex(self.index_fieldname, self.index_tagname) - def saveElement(self, gen): + def save_node(self, gen): """Serialize this node as a XML node""" from datetime import datetime attribs = {} @@ -215,9 +214,29 @@ class ModuleState: gen.startElement(self.name, attrs) for child in self.childs: - child.saveElement(gen) + child.save_node(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) diff --git a/nemubot/treatment.py b/nemubot/treatment.py index ed7cacb..8bbdabb 100644 --- a/nemubot/treatment.py +++ b/nemubot/treatment.py @@ -36,29 +36,17 @@ class MessageTreater: """ try: - handled = False - # Run pre-treatment: from Message to [ Message ] msg_gen = self._pre_treat(msg) m = next(msg_gen, None) # Run in-treatment: from Message to [ Response ] while m is not None: - - hook_gen = self._in_hooks(m) - hook = next(hook_gen, None) - if hook is not None: - handled = True - - for response in self._in_treat(m, hook, hook_gen): - # Run post-treatment: from Response to [ Response ] - yield from self._post_treat(response) + for response in self._in_treat(m): + # Run post-treatment: from Response to [ Response ] + yield from self._post_treat(response) m = next(msg_gen, None) - - if not handled: - for m in self._in_miss(msg): - yield from self._post_treat(m) except BaseException as e: logger.exception("Error occurred during the processing of the %s: " "%s", type(msg).__name__, msg) @@ -77,60 +65,44 @@ class MessageTreater: """ for h in self.hm.get_hooks("pre", type(msg).__name__): - if h.can_read(msg.to, msg.server) and h.match(msg): - for res in flatify(h.run(msg)): - if res is not None and res != msg: - yield from self._pre_treat(res) + if h.match(msg): + res = h.run(msg) - elif res is None or res is False: - break + if isinstance(res, list): + for i in range(len(res)): + # Avoid infinite loop + if res[i] != msg: + yield from self._pre_treat(res[i]) + + elif res is not None and res != msg: + yield from self._pre_treat(res) + + elif res is None or res is False: + break else: yield msg - def _in_hooks(self, msg): - for h in self.hm.get_hooks("in", type(msg).__name__): - if h.can_read(msg.to, msg.server) and h.match(msg): - yield h - - - def _in_treat(self, msg, hook, hook_gen): + def _in_treat(self, msg): """Treats Messages and returns Responses Arguments: msg -- message to treat """ - if hasattr(msg, "frm_owner"): - msg.frm_owner = (not hasattr(msg.server, "owner") or msg.server.owner == msg.frm) + for h in self.hm.get_hooks("in", type(msg).__name__): + if h.match(msg): + res = h.run(msg) - while hook is not None: - for res in flatify(hook.run(msg)): - if not hasattr(res, "server") or res.server is None: - res.server = msg.server - yield res + if isinstance(res, list): + for r in res: + yield r - hook = next(hook_gen, None) + elif res is not None: + if not hasattr(res, "server") or res.server is None: + res.server = msg.server - - def _in_miss(self, msg): - from nemubot.message.command import Command as CommandMessage - from nemubot.message.directask import DirectAsk as DirectAskMessage - - if isinstance(msg, CommandMessage): - from nemubot.hooks import Command as CommandHook - from nemubot.tools.human import guess - hooks = self.hm.get_reverse_hooks("in", type(msg).__name__) - suggest = [s for s in guess(msg.cmd, [h.name for h in hooks if isinstance(h, CommandHook) and h.name is not None])] - if len(suggest) >= 1: - yield DirectAskMessage(msg.frm, - "Unknown command %s. Would you mean: %s?" % (msg.cmd, ", ".join(suggest)), - to=msg.to_response) - - elif isinstance(msg, DirectAskMessage): - yield DirectAskMessage(msg.frm, - "Sorry, I'm just a bot and your sentence is too complex for me :( But feel free to teach me some tricks at https://github.com/nemunaire/nemubot/!", - to=msg.to_response) + yield res def _post_treat(self, msg): @@ -140,22 +112,21 @@ class MessageTreater: msg -- response to treat """ - for h in self.hm.get_hooks("post", type(msg).__name__): - if h.can_write(msg.to, msg.server) and h.match(msg): - for res in flatify(h.run(msg)): - if res is not None and res != msg: - yield from self._post_treat(res) + for h in self.hm.get_hooks("post"): + if h.match(msg): + res = h.run(msg) - elif res is None or res is False: - break + if isinstance(res, list): + for i in range(len(res)): + # Avoid infinite loop + if res[i] != msg: + yield from self._post_treat(res[i]) + + elif res is not None and res != msg: + yield from self._post_treat(res) + + elif res is None or res is False: + break else: yield msg - - -def flatify(g): - if hasattr(g, "__iter__"): - for i in g: - yield from flatify(i) - else: - yield g diff --git a/requirements.txt b/requirements.txt index e037895..e69de29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +0,0 @@ -irc -matrix-nio diff --git a/setup.py b/setup.py index 7b5bdcd..dc448ca 100755 --- a/setup.py +++ b/setup.py @@ -61,16 +61,13 @@ setup( packages=[ 'nemubot', - 'nemubot.config', 'nemubot.datastore', 'nemubot.event', - 'nemubot.exception', 'nemubot.hooks', - 'nemubot.hooks.keywords', 'nemubot.message', 'nemubot.message.printer', - 'nemubot.module', 'nemubot.server', + 'nemubot.server.message', 'nemubot.tools', 'nemubot.tools.xmlparser', ],