diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..dccc156 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,26 @@ +--- +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/.travis.yml b/.travis.yml index d109d2a..8efd20f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,9 @@ 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 new file mode 100644 index 0000000..b830622 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +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 c1d8fd2..6977c9f 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,10 @@ An extremely modulable IRC bot, built around XML configuration files! Requirements ------------ -*nemubot* requires at least Python 3.4 to work, as it uses `asyncio`. - -Connecting to SSL server requires [this patch](http://bugs.python.org/issue27629). +*nemubot* requires at least Python 3.3 to work. Some modules (like `cve`, `nextstop` or `laposte`) require the -[BeautifulSoup module](http://www.crummy.com/software/BeautifulSoup/), +[BeautifulSoup module](https://www.crummy.com/software/BeautifulSoup/), but the core and framework has no dependency. diff --git a/modules/alias.py b/modules/alias.py index a246d2c..c432a85 100644 --- a/modules/alias.py +++ b/modules/alias.py @@ -272,7 +272,6 @@ def treat_alias(msg): # Avoid infinite recursion if not isinstance(rpl_msg, Command) or msg.cmd != rpl_msg.cmd: - # Also return origin message, if it can be treated as well - return [msg, rpl_msg] + return rpl_msg return msg diff --git a/modules/bonneannee.py b/modules/bonneannee.py index ede30ef..1829bce 100644 --- a/modules/bonneannee.py +++ b/modules/bonneannee.py @@ -4,6 +4,7 @@ from datetime import datetime, timezone +from nemubot.event import ModuleEvent from nemubot.hooks import hook from nemubot.tools.countdown import countdown_format @@ -37,8 +38,10 @@ def load(context): chan = sayon["channel"] context.send_response(srv, Response(txt, chan)) - context.call_at(datetime(yrn, 1, 1, 0, 0, 0, 0, timezone.utc), - bonneannee) + d = datetime(yrn, 1, 1, 0, 0, 0, 0, + timezone.utc) - datetime.now(timezone.utc) + context.add_event(ModuleEvent(interval=0, offset=d.total_seconds(), + call=bonneannee)) # MODULE INTERFACE #################################################### diff --git a/modules/conjugaison.py b/modules/conjugaison.py index 42d78c6..c953da3 100644 --- a/modules/conjugaison.py +++ b/modules/conjugaison.py @@ -36,7 +36,7 @@ for k, v in s: # MODULE CORE ######################################################### def get_conjug(verb, stringTens): - url = ("http://leconjugueur.lefigaro.fr/conjugaison/verbe/%s.html" % + url = ("https://leconjugueur.lefigaro.fr/conjugaison/verbe/%s.html" % quote(verb.encode("ISO-8859-1"))) page = web.getURLContent(url) diff --git a/modules/ctfs.py b/modules/ctfs.py index 169ee46..ac27c4a 100644 --- a/modules/ctfs.py +++ b/modules/ctfs.py @@ -25,10 +25,8 @@ def get_info_yt(msg): for line in soup.body.find_all('tr'): n = line.find_all('td') - if len(n) == 5: - try: - res.append_message("\x02%s:\x0F from %s type %s at %s. %s" % - tuple([striphtml(x.text) for x in n])) - except: - pass + if len(n) == 7: + res.append_message("\x02%s:\x0F from %s type %s at %s. Weight: %s. %s%s" % + tuple([striphtml(x.text).strip() for x in n])) + return res diff --git a/modules/cve.py b/modules/cve.py index b9cf1c3..18d9898 100644 --- a/modules/cve.py +++ b/modules/cve.py @@ -23,7 +23,6 @@ VULN_DATAS = { "description": "vuln-description", "published": "vuln-published-on", "last_modified": "vuln-last-modified-on", - "source": "vuln-source", "base_score": "vuln-cvssv3-base-score-link", "severity": "vuln-cvssv3-base-score-severity", @@ -92,9 +91,9 @@ def get_cve_desc(msg): alert = "" if "base_score" not in cve and "description" in cve: - res.append_message("{alert}From \x02{source}\x0F, last modified on \x02{last_modified}\x0F. {description}".format(alert=alert, **cve), title=cve_id) + res.append_message("{alert}Last modified on \x02{last_modified}\x0F. {description}".format(alert=alert, **cve), title=cve_id) else: metrics = display_metrics(**cve) - res.append_message("{alert}Base score: \x02{base_score} {severity}\x0F (impact: \x02{impact_score}\x0F, exploitability: \x02{exploitability_score}\x0F; {metrics}), from \x02{source}\x0F, last modified on \x02{last_modified}\x0F. {description}".format(alert=alert, metrics=metrics, **cve), title=cve_id) + res.append_message("{alert}Base score: \x02{base_score} {severity}\x0F (impact: \x02{impact_score}\x0F, exploitability: \x02{exploitability_score}\x0F; {metrics}), last modified on \x02{last_modified}\x0F. {description}".format(alert=alert, metrics=metrics, **cve), title=cve_id) return res diff --git a/modules/events.py b/modules/events.py index 0e3d4a1..acac196 100644 --- a/modules/events.py +++ b/modules/events.py @@ -78,12 +78,12 @@ def load(context): }) if context.data is None: - context.data = DictNode() + context.set_default(DictNode()) # Relaunch all timers for kevt in context.data: if context.data[kevt].end: - context.data[kevt]._evt = context.call_at(context.data[kevt].end, partial(fini, kevt, context.data[kevt])) + 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)) def fini(name, evt): @@ -166,6 +166,9 @@ def start_countdown(msg): else: evt.end += timedelta(seconds=int(t)) + 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() diff --git a/modules/grep.py b/modules/grep.py index 5c25c7d..fde8ecb 100644 --- a/modules/grep.py +++ b/modules/grep.py @@ -73,7 +73,7 @@ def cmd_grep(msg): only = "only" in msg.kwargs - l = [m for m in grep(msg.args[0] if msg.args[0][0] == "^" else ".*?(" + msg.args[0] + ").*?", + 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, diff --git a/modules/imdb.py b/modules/imdb.py index d5ff158..7a42935 100644 --- a/modules/imdb.py +++ b/modules/imdb.py @@ -24,20 +24,17 @@ def get_movie_by_id(imdbid): return { "imdbID": imdbid, - "Title": soup.body.find(attrs={"itemprop": "name"}).next_element.strip(), - "Year": soup.body.find(id="titleYear").find("a").text.strip() if soup.body.find(id="titleYear") else ", ".join([y.text.strip() for y in soup.body.find(attrs={"class": "seasons-and-year-nav"}).find_all("div")[3].find_all("a")[:-1]]), - "Duration": soup.body.find_all(attrs={"itemprop": "duration"})[-1].text.strip(), - "imdbRating": soup.body.find(attrs={"itemprop": "ratingValue"}).text.strip(), - "imdbVotes": soup.body.find(attrs={"itemprop": "ratingCount"}).text.strip(), - "Plot": re.sub(r"\s+", " ", soup.body.find(id="titleStoryLine").find(attrs={"itemprop": "description"}).text).strip(), + "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(attrs={"class": "np_episode_guide"}) else "Movie", - "Country": ", ".join([c.find("a").text.strip() for c in soup.body.find(id="titleDetails").find_all(attrs={"class": "txt-block"}) if c.text.find("Country") != -1]), - "Released": soup.body.find(attrs={"itemprop": "datePublished"}).attrs["content"] if "content" in soup.body.find(attrs={"itemprop": "datePublished"}).attrs else "N\A", - "Genre": ", ".join([g.text.strip() for g in soup.body.find_all(attrs={"itemprop": "genre"})[:-1]]), - "Director": ", ".join([d.find(attrs={"itemprop": "name"}).text.strip() for d in soup.body.find_all(attrs={"itemprop": "director"})]), - "Writer": ", ".join([d.find(attrs={"itemprop": "name"}).text.strip() for d in soup.body.find_all(attrs={"itemprop": "creator"})]), - "Actors": ", ".join([d.find(attrs={"itemprop": "name"}).text.strip() for d in soup.body.find_all(attrs={"itemprop": "actors"})]), + "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"})]), } @@ -52,7 +49,9 @@ def find_movies(title, year=None): # Make the request data = web.getJSON(url, remove_callback=True) - if year is None: + 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] @@ -92,9 +91,9 @@ def cmd_imdb(msg): 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("%s \x02from\x0F %s \x02released on\x0F %s; \x02directed by:\x0F %s; \x02written by:\x0F %s; \x02main actors:\x0F %s" - % (data['Type'], data['Country'], data['Released'], data['Director'], data['Writer'], data['Actors'])) return res diff --git a/modules/mapquest.py b/modules/mapquest.py index 5662a49..f328e1d 100644 --- a/modules/mapquest.py +++ b/modules/mapquest.py @@ -13,7 +13,7 @@ from nemubot.module.more import Response # GLOBALS ############################################################# -URL_API = "http://open.mapquestapi.com/geocoding/v1/address?key=%s&location=%%s" +URL_API = "https://open.mapquestapi.com/geocoding/v1/address?key=%s&location=%%s" # LOADING ############################################################# @@ -23,7 +23,7 @@ def load(context): raise ImportError("You need a MapQuest API key in order to use this " "module. Add it to the module configuration file:\n" "\nRegister at http://developer.mapquest.com/") + "/>\nRegister at https://developer.mapquest.com/") global URL_API URL_API = URL_API % context.config["apikey"].replace("%", "%%") diff --git a/modules/networking/isup.py b/modules/networking/isup.py index c518900..99e2664 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("http://isitup.org/%s.json" % o.netloc) + isup = getJSON("https://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/w3c.py b/modules/networking/w3c.py index 83056dd..3c8084f 100644 --- a/modules/networking/w3c.py +++ b/modules/networking/w3c.py @@ -17,7 +17,7 @@ def validator(url): raise IMException("Indicate a valid URL!") try: - 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__}) + 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__}) raw = urllib.request.urlopen(req, timeout=10) except urllib.error.HTTPError as e: raise IMException("HTTP error occurs: %s %s" % (e.code, e.reason)) diff --git a/modules/networking/watchWebsite.py b/modules/networking/watchWebsite.py index adedbee..d6b806f 100644 --- a/modules/networking/watchWebsite.py +++ b/modules/networking/watchWebsite.py @@ -1,5 +1,6 @@ """Alert on changes on websites""" +from functools import partial import logging from random import randint import urllib.parse @@ -209,15 +210,14 @@ def start_watching(site, offset=0): offset -- offset time to delay the launch of the first check """ - o = urlparse(getNormalizedURL(site["url"]), "http") - #print_debug("Add %s event for site: %s" % (site["type"], o.netloc)) + #o = urlparse(getNormalizedURL(site["url"]), "http") + #print("Add %s event for site: %s" % (site["type"], o.netloc)) try: - evt = ModuleEvent(func=fwatch, - cmp_data=site["lastcontent"], - func_data=site["url"], offset=offset, - interval=site.getInt("time"), - call=alert_change, call_data=site) + evt = ModuleEvent(func=partial(fwatch, url=site["url"]), + cmp=site["lastcontent"], + offset=offset, interval=site.getInt("time"), + call=partial(alert_change, site=site)) site["_evt_id"] = add_event(evt) except IMException: logger.exception("Unable to watch %s", site["url"]) diff --git a/modules/networking/whois.py b/modules/networking/whois.py index 787cd17..999dc01 100644 --- a/modules/networking/whois.py +++ b/modules/networking/whois.py @@ -9,7 +9,7 @@ from nemubot.tools.web import getJSON from nemubot.module.more import Response URL_AVAIL = "https://www.whoisxmlapi.com/whoisserver/WhoisService?cmd=GET_DN_AVAILABILITY&domainName=%%s&outputFormat=json&username=%s&password=%s" -URL_WHOIS = "http://www.whoisxmlapi.com/whoisserver/WhoisService?da=2&domainName=%%s&outputFormat=json&userName=%s&password=%s" +URL_WHOIS = "https://www.whoisxmlapi.com/whoisserver/WhoisService?da=2&domainName=%%s&outputFormat=json&userName=%s&password=%s" # LOADING ############################################################# @@ -22,7 +22,7 @@ def load(CONF, add_hook): "the !netwhois feature. Add it to the module " "configuration file:\n\nRegister at " - "http://www.whoisxmlapi.com/newaccount.php") + "https://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"])) diff --git a/modules/news.py b/modules/news.py index 40daa92..c4c967a 100644 --- a/modules/news.py +++ b/modules/news.py @@ -13,6 +13,7 @@ from nemubot.hooks import hook from nemubot.tools import web from nemubot.module.more import Response +from nemubot.module.urlreducer import reduce_inline from nemubot.tools.feed import Feed, AtomEntry @@ -50,10 +51,11 @@ def cmd_news(msg): 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) + res = Response(channel=msg.channel, nomore="No more news from %s" % url, line_treat=reduce_inline) 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/nntp.py b/modules/nntp.py new file mode 100644 index 0000000..7fdceb4 --- /dev/null +++ b/modules/nntp.py @@ -0,0 +1,229 @@ +"""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 new file mode 100644 index 0000000..b9b6e21 --- /dev/null +++ b/modules/openai.py @@ -0,0 +1,87 @@ +"""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/reddit.py b/modules/reddit.py index 2de7612..d4def85 100644 --- a/modules/reddit.py +++ b/modules/reddit.py @@ -40,7 +40,7 @@ def cmd_subreddit(msg): else: where = "r" - sbr = web.getJSON("http://www.reddit.com/%s/%s/about.json" % + sbr = web.getJSON("https://www.reddit.com/%s/%s/about.json" % (where, sub.group(2))) if sbr is None: diff --git a/modules/repology.py b/modules/repology.py new file mode 100644 index 0000000..8dbc6da --- /dev/null +++ b/modules/repology.py @@ -0,0 +1,94 @@ +# 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/sap.py b/modules/sap.py index a6168a2..0b9017f 100644 --- a/modules/sap.py +++ b/modules/sap.py @@ -25,7 +25,7 @@ def cmd_tcode(msg): raise IMException("indicate a transaction code or " "a keyword to search!") - url = ("http://www.tcodesearch.com/tcodes/search?q=%s" % + url = ("https://www.tcodesearch.com/tcodes/search?q=%s" % urllib.parse.quote(msg.args[0])) page = web.getURLContent(url) diff --git a/modules/smmry.py b/modules/smmry.py new file mode 100644 index 0000000..b1fe72c --- /dev/null +++ b/modules/smmry.py @@ -0,0 +1,116 @@ +"""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 7db172b..57ab3ae 100644 --- a/modules/sms.py +++ b/modules/sms.py @@ -46,29 +46,22 @@ def send_sms(frm, api_usr, api_key, content): return None - -@hook.command("sms") -def cmd_sms(msg): - if not len(msg.args): - raise IMException("À qui veux-tu envoyer ce SMS ?") - - # Check dests - cur_epoch = time.mktime(time.localtime()); - for u in msg.args[0].split(","): +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: if u not in context.data.index: raise IMException("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 - # Go! + +def send_sms_to_list(msg, frm, dests, content, cur_epoch): fails = list() - for u in msg.args[0].split(","): + for u in dests: context.data.index[u]["lastuse"] = cur_epoch - 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:])) + test = send_sms(frm, context.data.index[u]["user"], context.data.index[u]["key"], content) if test is not None: fails.append( "%s: %s" % (u, test) ) @@ -77,6 +70,55 @@ def cmd_sms(msg): 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) + + 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) diff --git a/modules/suivi.py b/modules/suivi.py index 4bc079e..a54b722 100644 --- a/modules/suivi.py +++ b/modules/suivi.py @@ -9,7 +9,7 @@ import re from nemubot.hooks import hook from nemubot.exception import IMException -from nemubot.tools.web import getURLContent, getJSON +from nemubot.tools.web import getURLContent, getURLHeaders, getJSON from nemubot.module.more import Response @@ -17,7 +17,7 @@ from nemubot.module.more import Response def get_tnt_info(track_id): values = [] - data = getURLContent('www.tnt.fr/public/suivi_colis/recherche/visubontransport.do?bonTransport=%s' % track_id) + data = getURLContent('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: @@ -31,21 +31,22 @@ def get_tnt_info(track_id): def get_colissimo_info(colissimo_id): - colissimo_data = getURLContent("http://www.colissimo.fr/portail_colissimo/suivre.do?colispart=%s" % colissimo_id) + colissimo_data = getURLContent("https://www.laposte.fr/particulier/outils/suivre-vos-envois?code=%s" % colissimo_id) soup = BeautifulSoup(colissimo_data) - dataArray = soup.find(class_='dataArray') - if dataArray and dataArray.tbody and dataArray.tbody.tr: - date = dataArray.tbody.tr.find(headers="Date").get_text() - libelle = re.sub(r'[\n\t\r]', '', - dataArray.tbody.tr.find(headers="Libelle").get_text()) - site = dataArray.tbody.tr.find(headers="site").get_text().strip() - return (date, libelle, site.strip()) + 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()) def get_chronopost_info(track_id): data = urllib.parse.urlencode({'listeNumeros': track_id}) - track_baseurl = "http://www.chronopost.fr/expedier/inputLTNumbersNoJahia.do?lang=fr_FR" + track_baseurl = "https://www.chronopost.fr/expedier/inputLTNumbersNoJahia.do?lang=fr_FR" track_data = getURLContent(track_baseurl, data.encode('utf-8')) soup = BeautifulSoup(track_data) @@ -74,33 +75,29 @@ def get_colisprive_info(track_id): 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): - data = urllib.parse.urlencode({'id': laposte_id}) - laposte_baseurl = "http://www.part.csuivi.courrier.laposte.fr/suivi/index" + status, laposte_headers = getURLHeaders("https://www.laposte.fr/outils/suivre-vos-envois?" + urllib.parse.urlencode({'code': laposte_id})) - laposte_data = getURLContent(laposte_baseurl, data.encode('utf-8')) - soup = BeautifulSoup(laposte_data) - search_res = soup.find(class_='resultat_rech_simple_table').tbody.tr - if (soup.find(class_='resultat_rech_simple_table').thead - 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_cookie = None + for k,v in laposte_headers: + if k.lower() == "set-cookie" and v.find("access_token") >= 0: + laposte_cookie = v.split(";")[0] - field = field.find_next('td') - poste_type = 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_date = field.get_text() - - field = field.find_next('td') - poste_location = field.get_text() - - field = field.find_next('td') - poste_status = field.get_text() - - return (poste_type.lower(), poste_id.strip(), poste_status.lower(), - poste_location, poste_date) + shipment = laposte_data["shipment"] + return (shipment["product"], shipment["idShip"], shipment["event"][0]["label"], shipment["event"][0]["date"]) def get_postnl_info(postnl_id): @@ -131,7 +128,7 @@ def get_usps_info(usps_id): usps_data = getURLContent(usps_parcelurl) soup = BeautifulSoup(usps_data) - if (soup.find(class_="tracking_history") + 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() @@ -175,7 +172,8 @@ def get_fedex_info(fedex_id, lang="en_US"): if ("TrackPackagesResponse" in fedex_data and "packageList" in fedex_data["TrackPackagesResponse"] and len(fedex_data["TrackPackagesResponse"]["packageList"]) and - not fedex_data["TrackPackagesResponse"]["errorList"][0]["code"] 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] @@ -208,11 +206,10 @@ def handle_tnt(tracknum): def handle_laposte(tracknum): info = get_laposte_info(tracknum) if info: - poste_type, poste_id, poste_status, poste_location, poste_date = info - return ("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)) + 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): @@ -228,7 +225,14 @@ def handle_usps(tracknum): info = get_usps_info(tracknum) if info: notif, last_date, last_status, last_location = info - return ("USPS \x02{tracknum}\x0F is {last_status} in \x02{last_location}\x0F as of {last_date}: {notif}".format(tracknum=tracknum, notif=notif, last_date=last_date, last_status=last_status.lower(), last_location=last_location)) + 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): @@ -281,6 +285,7 @@ TRACKING_HANDLERS = { 'fedex': handle_fedex, 'dhl': handle_dhl, 'usps': handle_usps, + 'ups': handle_ups, } diff --git a/modules/syno.py b/modules/syno.py index bda0456..78f0b7d 100644 --- a/modules/syno.py +++ b/modules/syno.py @@ -29,7 +29,7 @@ def load(context): # MODULE CORE ######################################################### def get_french_synos(word): - url = "http://www.crisco.unicaen.fr/des/synonymes/" + quote(word) + url = "https://crisco.unicaen.fr/des/synonymes/" + quote(word) page = web.getURLContent(url) best = list(); synos = list(); anton = list() @@ -53,7 +53,7 @@ def get_french_synos(word): def get_english_synos(key, word): - cnt = web.getJSON("http://words.bighugelabs.com/api/2/%s/%s/json" % + cnt = web.getJSON("https://words.bighugelabs.com/api/2/%s/%s/json" % (quote(key), quote(word.encode("ISO-8859-1")))) best = list(); synos = list(); anton = list() diff --git a/modules/urbandict.py b/modules/urbandict.py index a897fad..b561e89 100644 --- a/modules/urbandict.py +++ b/modules/urbandict.py @@ -14,7 +14,7 @@ from nemubot.module.more import Response def search(terms): return web.getJSON( - "http://api.urbandictionary.com/v0/define?term=%s" + "https://api.urbandictionary.com/v0/define?term=%s" % quote(' '.join(terms))) diff --git a/modules/urlreducer.py b/modules/urlreducer.py index 8fedfe1..86f4d42 100644 --- a/modules/urlreducer.py +++ b/modules/urlreducer.py @@ -21,7 +21,7 @@ def default_reducer(url, data): def ycc_reducer(url, data): - return "http://ycc.fr/%s" % default_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), @@ -36,8 +36,8 @@ def lstu_reducer(url, data): # MODULE VARIABLES #################################################### PROVIDERS = { - "tinyurl": (default_reducer, "http://tinyurl.com/api-create.php?url="), - "ycc": (ycc_reducer, "http://ycc.fr/redirection/create/"), + "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"), @@ -60,12 +60,20 @@ def load(context): # MODULE CORE ######################################################### -def reduce(url, provider=DEFAULT_PROVIDER): +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) diff --git a/modules/virtualradar.py b/modules/virtualradar.py index 9382d3b..2c87e79 100644 --- a/modules/virtualradar.py +++ b/modules/virtualradar.py @@ -15,7 +15,7 @@ from nemubot.module import mapquest # GLOBALS ############################################################# -URL_API = "http://public-api.adsbexchange.com/VirtualRadar/AircraftList.json?fcallC=%s" +URL_API = "https://public-api.adsbexchange.com/VirtualRadar/AircraftList.json?fcallC=%s" SPEED_TYPES = { 0: 'Ground speed', diff --git a/modules/weather.py b/modules/weather.py index bee0d20..9b36470 100644 --- a/modules/weather.py +++ b/modules/weather.py @@ -19,25 +19,63 @@ from nemubot.module.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", + }, +} + def load(context): if not context.config or "darkskyapikey" not in context.config: 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 http://developer.forecast.io/") + "Register at https://developer.forecast.io/") context.data.setIndex("name", "city") global URL_DSAPI URL_DSAPI = URL_DSAPI % context.config["darkskyapikey"] -def format_wth(wth): - return ("{temperature} °C {summary}; precipitation ({precipProbability:.0%} chance) intensity: {precipIntensity} mm/h; relative humidity: {humidity:.0%}; wind speed: {windSpeed} km/s {windBearing}°; cloud coverage: {cloudCover:.0%}; pressure: {pressure} hPa; ozone: {ozone} DU" - .format(**wth) +def format_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 format_forecast_daily(wth): - return ("{summary}; between {temperatureMin}-{temperatureMax} °C; precipitation ({precipProbability:.0%} chance) intensity: maximum {precipIntensity} mm/h; relative humidity: {humidity:.0%}; wind speed: {windSpeed} km/h {windBearing}°; cloud coverage: {cloudCover:.0%}; pressure: {pressure} hPa; ozone: {ozone} DU".format(**wth)) +def format_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 format_timestamp(timestamp, tzname, tzoffset, format="%c"): @@ -88,7 +126,7 @@ def treat_coord(msg): raise IMException("indique-moi un nom de ville ou des coordonnées.") -def get_json_weather(coords, lang="en", units="auto"): +def get_json_weather(coords, lang="en", units="ca"): wth = web.getJSON(URL_DSAPI % (float(coords[0]), float(coords[1]), lang, units)) # First read flags @@ -114,13 +152,13 @@ def cmd_coordinates(msg): @hook.command("alert", keywords={ "lang=LANG": "change the output language of weather sumarry; default: en", - "units=UNITS": "return weather conditions in the requested units; default: auto", + "units=UNITS": "return weather conditions in the requested units; default: ca", }) 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 "auto") + units=msg.kwargs["units"] if "units" in msg.kwargs else "ca") res = Response(channel=msg.channel, nomore="No more weather alert", count=" (%d more alerts)") @@ -141,13 +179,13 @@ def cmd_alert(msg): }, keywords={ "lang=LANG": "change the output language of weather sumarry; default: en", - "units=UNITS": "return weather conditions in the requested units; default: auto", + "units=UNITS": "return weather conditions in the requested units; default: ca", }) 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 "auto") + units=msg.kwargs["units"] if "units" in msg.kwargs else "ca") res = Response(channel=msg.channel, nomore="No more weather information") @@ -169,17 +207,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))) + res.append_message("\x03\x02At %sh:\x03\x02 %s" % (format_timestamp(int(hour["time"]), wth["timezone"], wth["offset"], '%H'), format_wth(hour, wth["flags"]))) 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))) + 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"]))) 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"])) + res.append_message("\x03\x02Currently:\x03\x02 " + format_wth(wth["currently"], wth["flags"])) nextres = "\x03\x02Today:\x03\x02 %s " % wth["daily"]["data"][0]["summary"] if "minutely" in wth: @@ -189,11 +227,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))) + format_wth(hour, wth["flags"]))) 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))) + format_forecast_daily(day, wth["flags"]))) return res diff --git a/modules/whois.py b/modules/whois.py index d6106dd..1a5f598 100644 --- a/modules/whois.py +++ b/modules/whois.py @@ -14,7 +14,7 @@ from nemubot.module.more import Response from nemubot.module.networking.page import headers PASSWD_FILE = None -# You can get one with: curl -b "sessionid=YOURSESSIONID" 'https://accounts.cri.epita.net/api/users/' > users.json +# 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): @@ -39,17 +39,13 @@ def load(context): 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") class Login: - def __init__(self, line=None, login=None, uidNumber=None, cn=None, promo=None, **kwargs): + 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] @@ -61,19 +57,23 @@ class Login: self.login = login self.uid = uidNumber self.promo = promo - self.cn = cn - self.gid = "epita" + promo + self.cn = firstname + " " + lastname + try: + self.gid = "epita" + str(int(promo)) + except: + self.gid = promo def get_promo(self): if hasattr(self, "promo"): return self.promo if hasattr(self, "home"): - return self.home.split("/")[2].replace("_", " ") + try: + return self.home.split("/")[2].replace("_", " ") + except: + return self.gid def get_photo(self): - if self.login in context.data.getNode("pics").index: - return context.data.getNode("pics").index[self.login]["url"] - for url in [ "https://photos.cri.epita.net/%s", "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 ]: + for url in [ "https://photos.cri.epita.fr/%s", "https://intra-bocal.epitech.eu/trombi/%s.jpg" ]: url = url % self.login try: _, status, _, _ = headers(url) @@ -91,7 +91,7 @@ def login_lookup(login, search=False): if APIEXTRACT_FILE: with open(APIEXTRACT_FILE, encoding="utf-8") as f: api = json.load(f) - for l in api: + 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) diff --git a/modules/wolframalpha.py b/modules/wolframalpha.py index b7cc7fb..fc83815 100644 --- a/modules/wolframalpha.py +++ b/modules/wolframalpha.py @@ -15,7 +15,7 @@ from nemubot.module.more import Response # LOADING ############################################################# -URL_API = "http://api.wolframalpha.com/v2/query?input=%%s&format=plaintext&appid=%s" +URL_API = "https://api.wolframalpha.com/v2/query?input=%%s&format=plaintext&appid=%s" def load(context): global URL_API @@ -24,7 +24,7 @@ def load(context): "this module. Add it to the module configuration: " "\n\n" - "Register at http://products.wolframalpha.com/api/") + "Register at https://products.wolframalpha.com/api/") URL_API = URL_API % quote(context.config["apikey"]).replace("%", "%%") diff --git a/modules/worldcup.py b/modules/worldcup.py index b12ca30..e72f1ac 100644 --- a/modules/worldcup.py +++ b/modules/worldcup.py @@ -1,14 +1,16 @@ # coding=utf-8 -"""The 2014 football worldcup module""" +"""The 2014,2018 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.hooks import hook from nemubot.tools.xmlparser.node import ModuleState @@ -20,8 +22,7 @@ from nemubot.module.more import Response API_URL="http://worldcup.sfg.io/%s" def load(context): - 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)) + 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)) def help_full (): @@ -65,10 +66,10 @@ def cmd_watch(msg): context.save() raise IMException("This channel will not anymore receives world cup events.") -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) +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)) for match in matches: if is_valid(match): @@ -120,20 +121,19 @@ def detail_event(evt): return evt + " par" def txt_event(e): - return "%se minutes : %s %s (%s)" % (e["time"], detail_event(e["type_of_event"]), e["player"], e["team"]["code"]) + return "%s minute : %s %s (%s)" % (e["time"], detail_event(e["type_of_event"]), e["player"], e["team"]["code"]) def prettify(match): - 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)) + matchdate = datetime.strptime(match["datetime"].replace(':', ''), "%Y-%m-%dT%H%M%SZ").replace(tzinfo=timezone.utc) if match["status"] == "future": - 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"])] + 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"])] else: msgs = list() msg = "" if match["status"] == "completed": - msg += "Match (%s) du %s terminé : " % (match["match_number"], matchdate.strftime("%A %d à %H:%M")) + msg += "Match (%s) du %s terminé : " % (match["fifa_id"], matchdate.strftime("%A %d à %H:%M")) else: - msg += "Match en cours (%s) depuis %d minutes : " % (match["match_number"], (datetime.now(matchdate.tzinfo) - matchdate_local).total_seconds() / 60) + msg += "Match en cours (%s) depuis %d minutes : " % (match["fifa_id"], (datetime.now(tz=timezone.utc) - matchdate).total_seconds() / 60) msg += "%s %d - %d %s" % (match["home_team"]["country"], match["home_team"]["goals"], match["away_team"]["goals"], match["away_team"]["country"]) @@ -163,7 +163,7 @@ def is_valid(match): def get_match(url, matchid): allm = get_matches(url) for m in allm: - if int(m["match_number"]) == matchid: + if int(m["fifa_id"]) == matchid: return [ m ] def get_matches(url): @@ -192,7 +192,7 @@ 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.arg[0]) + url = int(msg.args[0]) else: raise IMException("unrecognized request; choose between 'today', 'tomorrow', a FIFA country code or a match identifier") diff --git a/nemubot/__init__.py b/nemubot/__init__.py index 48de6ea..62807c6 100644 --- a/nemubot/__init__.py +++ b/nemubot/__init__.py @@ -39,10 +39,14 @@ def requires_version(min=None, max=None): "but this is nemubot v%s." % (str(max), __version__)) -def attach(pid, socketfile): +def attach(pidfile, 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)) sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) @@ -106,28 +110,13 @@ def attach(pid, socketfile): return 0 -def daemonize(socketfile=None, autoattach=True): +def daemonize(socketfile=None): """Detach the running process to run as a daemon """ import os import sys - if socketfile is not None: - try: - pid = os.fork() - if pid > 0: - if autoattach: - import time - os.waitpid(pid, 0) - time.sleep(1) - sys.exit(attach(pid, socketfile)) - else: - sys.exit(0) - except OSError as err: - sys.stderr.write("Unable to fork: %s\n" % err) - sys.exit(1) - try: pid = os.fork() if pid > 0: diff --git a/nemubot/__main__.py b/nemubot/__main__.py index 2338090..7070639 100644 --- a/nemubot/__main__.py +++ b/nemubot/__main__.py @@ -71,12 +71,26 @@ 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)) - args.socketfile = os.path.abspath(os.path.expanduser(args.socketfile)) + 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.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)] + # 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: + 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) + # Setup logging interface import logging logger = logging.getLogger("nemubot") @@ -106,7 +120,7 @@ def main(): pass else: from nemubot import attach - sys.exit(attach(pid, args.socketfile)) + sys.exit(attach(args.pidfile, args.socketfile)) # Add modules dir paths modules_paths = list() @@ -141,12 +155,18 @@ def main(): # Preset each server in this file for server in config.servers: - srv = server.server(config) # Add the server in the context - if context.add_server(srv): - logger.info("Server '%s' successfully added.", srv.name) - else: - logger.error("Can't add server '%s'.", srv.name) + 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: @@ -175,16 +195,28 @@ def main(): # Daemonize if not args.debug: from nemubot import daemonize - daemonize(args.socketfile, not args.no_attach) + daemonize(args.socketfile) # Signals handling - def sigtermhandler(): + def sigtermhandler(signum, frame): """On SIGTERM and SIGINT, quit nicely""" context.quit() - context.loop.add_signal_handler(signal.SIGINT, sigtermhandler) - context.loop.add_signal_handler(signal.SIGTERM, sigtermhandler) + signal.signal(signal.SIGINT, sigtermhandler) + signal.signal(signal.SIGTERM, sigtermhandler) - def sigusr1handler(): + def sighuphandler(signum, frame): + """On SIGHUP, perform a deep reload""" + nonlocal context + + logger.debug("SIGHUP receive, iniate reload procedure...") + + # Reload configuration file + for path in args.files: + if os.path.isfile(path): + sync_act("loadconf", path) + signal.signal(signal.SIGHUP, sighuphandler) + + def sigusr1handler(signum, frame): """On SIGHUSR1, display stacktraces""" import threading, traceback for threadId, stack in sys._current_frames().items(): @@ -196,22 +228,24 @@ def main(): logger.debug("########### Thread %s:\n%s", thName, "".join(traceback.format_stack(stack))) - context.loop.add_signal_handler(signal.SIGUSR1, sigusr1handler) + 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())) - context.start() - context.loop.set_debug(args.verbose > 0) - context.loop.run_forever() - context.join() + # context can change when performing an hotswap, always join the latest context + oldcontext = None + while oldcontext != context: + oldcontext = context + context.start() + context.join() # Wait for consumers logger.info("Waiting for other threads shuts down...") if args.debug: - sigusr1handler() + sigusr1handler(0, None) sys.exit(0) diff --git a/nemubot/bot.py b/nemubot/bot.py index e400e34..2b6e15c 100644 --- a/nemubot/bot.py +++ b/nemubot/bot.py @@ -14,12 +14,10 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import asyncio from datetime import datetime, timezone import logging from multiprocessing import JoinableQueue import threading -import traceback import select import sys import weakref @@ -42,7 +40,7 @@ class Bot(threading.Thread): """Class containing the bot context and ensuring key goals""" def __init__(self, ip="127.0.0.1", modules_paths=list(), - data_store=datastore.Abstract(), debug=False, loop=None): + data_store=datastore.Abstract(), debug=False): """Initialize the bot context Keyword arguments: @@ -59,17 +57,7 @@ class Bot(threading.Thread): sys.version_info.major, sys.version_info.minor, sys.version_info.micro) self.debug = debug - self.stop = None - - # - self.loop = loop if loop is not None else asyncio.get_event_loop() - - # Those events are used to ensure there is always one event in the next 24h, else overflow can occurs on loop timeout - def event_sentinel(offset=43210): - logger.debug("Defining new event sentinelle in %ss", 43210 + offset) - self.loop.call_later(43210 + offset, event_sentinel) - event_sentinel(0) - event_sentinel(43210) + self.stop = True # External IP for accessing this bot import ipaddress @@ -86,6 +74,10 @@ class Bot(threading.Thread): self.modules = dict() self.modules_configuration = dict() + # Events + self.events = list() + self.event_timer = None + # Own hooks from nemubot.treatment import MessageTreater self.treater = MessageTreater() @@ -143,19 +135,24 @@ class Bot(threading.Thread): "Vous pouvez le consulter, le dupliquer, " "envoyer des rapports de bogues ou bien " "contribuer au projet sur GitHub : " - "http://github.com/nemunaire/nemubot/") + "https://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.replace("nemubot.module.", ""), 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]() is not None and self.modules[im]().__doc__]) return res self.treater.hm.add_hook(nemubot.hooks.Command(_help_msg, "help"), "in", "Command") + import os from queue import Queue - # Messages to be treated - self.cnsr_queue = Queue() - self.cnsr_thrd = list() - self.cnsr_thrd_size = -1 + # 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): @@ -172,8 +169,13 @@ class Bot(threading.Thread): self._poll.register(sync_queue._reader, select.POLLIN | select.POLLPRI) - logger.info("Starting main loop") + self.stop = False + + # Relaunch events + self._update_event_timer() + + logger.info("Starting main loop") while not self.stop: for fd, flag in self._poll.poll(): # Handle internal socket passing orders @@ -221,7 +223,10 @@ class Bot(threading.Thread): elif args[0] == "register": self._poll.register(int(args[1]), select.POLLIN | select.POLLPRI) elif args[0] == "unregister": - self._poll.unregister(int(args[1])) + try: + self._poll.unregister(int(args[1])) + except KeyError: + pass except: logger.exception("Unhandled excpetion during action:") @@ -234,14 +239,15 @@ class Bot(threading.Thread): sync_queue.task_done() - # 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() + # 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 logger.info("Ending main loop") @@ -249,26 +255,7 @@ class Bot(threading.Thread): # Events methods - @asyncio.coroutine - def _call_at(self, when, *args, **kwargs): - @asyncio.coroutine - def _add_event(): - return self.loop.call_at(when, *args, **kwargs) - future = yield from asyncio.run_coroutine_threadsafe(_add_event(), loop=self.loop) - logger.debug("New event registered, scheduled in %ss", when - self.loop.time()) - return future.result() - - - def call_at(self, when, *args, **kwargs): - delay = (when - datetime.now(timezone.utc)).total_seconds() - return self._call_at(self.loop.time() + delay, *args, **kwargs) - - - def call_delay(self, delay, *args, **kwargs): - return self._call_at(self.loop.time() + delay, *args, **kwargs) - - - def add_event(self, evt): + def add_event(self, evt, eid=None, module_src=None): """Register an event and return its identifiant for futur update Return: @@ -277,26 +264,125 @@ class Bot(threading.Thread): Argument: evt -- The event object to add + + Keyword arguments: + eid -- The desired event ID (object or string UUID) + module_src -- The module to which the event is attached to """ - if hasattr(evt, "handle") and evt.handle is not None: - raise Exception("Try to launch an already launched event.") + import uuid - def _end_event_timer(event): - """Function called at the end of the event timer""" + # Generate the event id if no given + if eid is None: + eid = uuid.uuid1() - logger.debug("Trigering event") - event.handle = None - self.cnsr_queue.put_nowait(EventConsumer(event)) + # Fill the id field of the event + if type(eid) is uuid.UUID: + evt.id = str(eid) + else: + # Ok, this is quiet useless... + try: + evt.id = str(uuid.UUID(eid)) + except ValueError: + evt.id = eid + + # TODO: mutex here plz + + # Add the event in its place + t = evt.current + i = 0 # sentinel + for i in range(0, len(self.events)): + if self.events[i].current > t: + break + self.events.insert(i, evt) + + if i == 0 and not self.stop: + # First event changed, reset timer + self._update_event_timer() + if len(self.events) <= 0 or self.events[i] != evt: + # Our event has been executed and removed from queue + return None + + # Register the event in the source module + if module_src is not None: + module_src.__nemubot_context__.events.append((evt, evt.id)) + evt.module_src = module_src + + logger.info("New event registered in %d position: %s", i, t) + return evt.id + + + def del_event(self, evt, module_src=None): + """Find and remove an event from list + + Return: + True if the event has been found and removed, False else + + Argument: + evt -- The ModuleEvent object to remove or just the event identifier + + Keyword arguments: + module_src -- The module to which the event is attached to (ignored if evt is a ModuleEvent) + """ + + logger.info("Removing event: %s from %s", evt, module_src) + + from nemubot.event import ModuleEvent + if type(evt) is ModuleEvent: + id = evt.id + module_src = evt.module_src + else: + id = evt + + if len(self.events) > 0 and id == self.events[0].id: + 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() + return True + + for evt in self.events: + if evt.id == id: + self.events.remove(evt) + + if module_src is not None: + module_src.__nemubot_context__.events.remove((evt, evt.id)) + return True + return False + + + def _update_event_timer(self): + """(Re)launch the timer to end with the closest event""" + + # Reset the timer if this is the first item + if self.event_timer is not None: + self.event_timer.cancel() + + if len(self.events): + try: + remaining = self.events[0].time_left.total_seconds() + except: + logger.exception("An error occurs during event time calculation:") + self.events.pop(0) + return self._update_event_timer() + + logger.debug("Update timer: next event in %d seconds", remaining) + self.event_timer = threading.Timer(remaining if remaining > 0 else 0, self._end_event_timer) + self.event_timer.start() + + else: + logger.debug("Update timer: no timer left") + + + def _end_event_timer(self): + """Function called at the end of the event timer""" + + while len(self.events) > 0 and datetime.now(timezone.utc) >= self.events[0].current: + evt = self.events.pop(0) + self.cnsr_queue.put_nowait(EventConsumer(evt)) sync_act("launch_consumer") - evt.start(self.loop) - evt.handle = call_at(evt._next, _end_event_timer, evt) - - logger.debug("New event registered in %ss", evt._next - self.loop.time()) - - return evt.handle - + self._update_event_timer() # Consumers methods @@ -339,23 +425,8 @@ class Bot(threading.Thread): def add_module(self, module): """Add a module to the context, if already exists, unload the old one before""" - - import nemubot.hooks - - self.loop.call_soon_threadsafe(self._add_module, - module, - nemubot.hooks.hook.last_registered) - - nemubot.hooks.hook.last_registered = [] - - - def _add_module(self, module, registered_functions): module_name = module.__spec__.name if hasattr(module, "__spec__") else module.__name__ - if hasattr(self, "stop") and self.stop: - 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) @@ -377,12 +448,14 @@ class Bot(threading.Thread): # Replace imported context by real one for attr in module.__dict__: - if attr != "__nemubot_context__" and isinstance(module.__dict__[attr], _ModuleContext): + if attr != "__nemubot_context__" and type(module.__dict__[attr]) == _ModuleContext: module.__dict__[attr] = module.__nemubot_context__ # Register decorated functions - for s, h in registered_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 = [] # Launch the module if hasattr(module, "load"): @@ -438,6 +511,10 @@ class Bot(threading.Thread): def quit(self): """Save and unload modules and disconnect servers""" + if self.event_timer is not None: + logger.info("Stop the event timer...") + self.event_timer.cancel() + logger.info("Save and unload all modules...") for mod in [m for m in self.modules.keys()]: self.unload_module(mod) @@ -447,13 +524,11 @@ class Bot(threading.Thread): srv.close() logger.info("Stop consumers") - k = self.cnsr_thrd + with self.cnsr_lock: + k = list(self.cnsr_thrd) for cnsr in k: cnsr.stop = True - logger.info("Closing event loop") - self.loop.stop() - if self.stop is False or sync_queue is not None: self.stop = True sync_act("end") diff --git a/nemubot/config/server.py b/nemubot/config/server.py index 14ca9a8..17bfaee 100644 --- a/nemubot/config/server.py +++ b/nemubot/config/server.py @@ -33,7 +33,7 @@ class Server: return True - def server(self, parent): + def server(self, parent, trynb=0): from nemubot.server import factory for a in ["nick", "owner", "realname", "encoding"]: @@ -42,4 +42,4 @@ class Server: self.caps += parent.caps - return factory(self.uri, caps=self.caps, channels=self.channels, **self.args) + 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 792fc9b..a9a4146 100644 --- a/nemubot/consumer.py +++ b/nemubot/consumer.py @@ -88,8 +88,13 @@ class EventConsumer: logger.exception("Error during event end") # Reappend the event in the queue if it has next iteration - if self.evt.next(): - context.add_event(self.evt) + if self.evt.next is not None: + context.add_event(self.evt, eid=self.evt.id) + + # Or remove reference of this event + elif (hasattr(self.evt, "module_src") and + self.evt.module_src is not None): + self.evt.module_src.__nemubot_context__.events.remove((self.evt, self.evt.id)) @@ -100,18 +105,25 @@ class Consumer(threading.Thread): def __init__(self, context): self.context = context self.stop = False - super().__init__(name="Nemubot consumer") + super().__init__(name="Nemubot consumer", daemon=True) def run(self): try: while not self.stop: - stm = self.context.cnsr_queue.get(True, 1) - stm.run(self.context) - self.context.cnsr_queue.task_done() + try: + stm = self.context.cnsr_queue.get(True, 1) + except queue.Empty: + break - except queue.Empty: - pass + 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 finally: - self.context.cnsr_thrd_size -= 2 - self.context.cnsr_thrd.remove(self) + with self.context.cnsr_lock: + self.context.cnsr_thrd.remove(self) diff --git a/nemubot/event/__init__.py b/nemubot/event/__init__.py index 8ec036c..49c6902 100644 --- a/nemubot/event/__init__.py +++ b/nemubot/event/__init__.py @@ -21,56 +21,84 @@ class ModuleEvent: """Representation of a event initiated by a bot module""" - def __init__(self, call=None, cmp=None, interval=60, offset=0, times=1): + def __init__(self, call=None, func=None, cmp=None, interval=60, offset=0, times=1): """Initialize the event Keyword arguments: call -- Function to call when the event is realized - cmp -- Boolean function called to check changes + func -- Function called to check + cmp -- Boolean function called to check changes or value to compare with interval -- Time in seconds between each check (default: 60) offset -- Time in seconds added to interval before the first check (default: 0) times -- Number of times the event has to be realized before being removed; -1 for no limit (default: 1) """ + # What have we to check? + self.func = func + # How detect a change? self.cmp = cmp # What should we call when? self.call = call - # Time to wait before the first check + # Store times if isinstance(offset, timedelta): - self.offset = offset + self.offset = offset # Time to wait before the first check else: - self.offset = timedelta(seconds=offset) - self.interval = timedelta(seconds=interval) - self._next = None # Cache + 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 # How many times do this event? self.times = times - - def start(self, loop): - if self._next is None: - self._next = loop.time() + self.offset.total_seconds() + self.interval.total_seconds() - - - def schedule(self, end): - self.interval = timedelta(seconds=0) - self.offset = end - datetime.now(timezone.utc) - - - def next(self): + @property + def current(self): + """Return the date of the near check""" if self.times != 0: - self._next += self.interval.total_seconds() - return True - return False + if self._end is None: + self._end = datetime.now(timezone.utc) + self.offset + self.interval + return self._end + return None + @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 def check(self): """Run a check and realized the event if this is time""" - if self.cmp(): + # Get new data + if self.func is not None: + d_new = self.func() + else: + d_new = None + + # 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): self.times -= 1 - self.call() + + # Call attended function + if self.func is not None: + self.call(d_new) + else: + self.call() diff --git a/nemubot/hooks/abstract.py b/nemubot/hooks/abstract.py index eac4b20..ffe79fb 100644 --- a/nemubot/hooks/abstract.py +++ b/nemubot/hooks/abstract.py @@ -14,6 +14,8 @@ # 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 @@ -119,10 +121,18 @@ class Abstract: 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 = e.fill_response(data1) finally: if self.times == 0: self.call_end(ret) - return ret + if isinstance(ret, list): + for r in ret: + yield ret + elif ret is not None: + yield ret diff --git a/nemubot/hooks/keywords/dict.py b/nemubot/hooks/keywords/dict.py index e1429fc..c2d3f2e 100644 --- a/nemubot/hooks/keywords/dict.py +++ b/nemubot/hooks/keywords/dict.py @@ -43,7 +43,7 @@ class Dict(Abstract): def check(self, mkw): for k in mkw: - if (mkw[k] and k not in self.chk_args) or (not mkw[k] and k not in self.chk_noarg): + 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: diff --git a/nemubot/message/printer/IRC.py b/nemubot/message/printer/IRC.py deleted file mode 100644 index df9cb9f..0000000 --- a/nemubot/message/printer/IRC.py +++ /dev/null @@ -1,25 +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.message import Text -from nemubot.message.printer.socket import Socket as SocketPrinter - - -class IRC(SocketPrinter): - - def visit_Text(self, msg): - self.pp += "PRIVMSG %s :" % ",".join(msg.to) - super().visit_Text(msg) diff --git a/nemubot/message/printer/IRCLib.py b/nemubot/message/printer/IRCLib.py new file mode 100644 index 0000000..abd1f2f --- /dev/null +++ b/nemubot/message/printer/IRCLib.py @@ -0,0 +1,67 @@ +# 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 new file mode 100644 index 0000000..ad1b99e --- /dev/null +++ b/nemubot/message/printer/Matrix.py @@ -0,0 +1,69 @@ +# 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/module/more.py b/nemubot/module/more.py index 018a1ae..206d97a 100644 --- a/nemubot/module/more.py +++ b/nemubot/module/more.py @@ -181,13 +181,16 @@ class Response: return self.nomore if self.line_treat is not None and self.elt == 0: - 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()) + 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) msg = "" if self.title is not None: diff --git a/nemubot/modulecontext.py b/nemubot/modulecontext.py index bf9b54e..4af3731 100644 --- a/nemubot/modulecontext.py +++ b/nemubot/modulecontext.py @@ -14,27 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import asyncio - - -class _TinyEvent: - - def __init__(self, handle): - self.handle = handle - - -class _FakeHandle: - - def __init__(self, true_handle, callback): - self.handle = true_handle - self.callback = callback - - def cancel(self): - self.handle.cancel() - if self.callback: - return self.callback() - - class _ModuleContext: def __init__(self, module=None, knodes=None): @@ -45,8 +24,8 @@ class _ModuleContext: else: self.module_name = "" - self.events = list() self.hooks = list() + self.events = list() self.debug = False from nemubot.config.module import Module @@ -58,6 +37,13 @@ class _ModuleContext: from nemubot.tools.xmlparser import module_state return module_state.ModuleState("nemubotstate") + 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 @@ -69,22 +55,19 @@ class _ModuleContext: assert isinstance(hook, AbstractHook), hook self.hooks.remove((triggers, hook)) - def subtreat(self, msg): return None - - def set_knodes(self, knodes): - self._knodes = knodes - - - def add_event(self, evt): - self.events.append(evt) - return evt + def add_event(self, evt, eid=None): + return self.events.append((evt, eid)) def del_event(self, evt): - return self.events.remove(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) @@ -102,15 +85,6 @@ class _ModuleContext: self._data = self.load_data() return self._data - @data.setter - def data(self, data): - self._data = data - return self._data - - @data.deleter - def data(self): - self._data = None - def unload(self): """Perform actions for unloading the module""" @@ -120,7 +94,7 @@ class _ModuleContext: self.del_hook(h, *s) # Remove registered events - for evt in self.events: + for evt, eid in self.events: self.del_event(evt) self.save() @@ -149,10 +123,6 @@ class ModuleContext(_ModuleContext): def load_data(self): return self.context.datastore.load(self.module_name, self._knodes) - def save(self): - self.context.datastore.save(self.module_name, self.data) - - def add_hook(self, hook, *triggers): from nemubot.hooks import Abstract as AbstractHook assert isinstance(hook, AbstractHook), hook @@ -165,41 +135,14 @@ class ModuleContext(_ModuleContext): self.hooks.remove((triggers, hook)) return self.context.treater.hm.del_hooks(*triggers, hook=hook) - def subtreat(self, msg): yield from self.context.treater.treat_msg(msg) - - def _add_event(self, evt, call_add, *args, **kwargs): - if evt in self.events: - return None - - def _cancel_event(): - self.module.logger.debug("Cancel event") - evt.handle = None - return super(ModuleContext, self).del_event(evt) - - hd = call_add(*args, **kwargs) - evt.handle = _FakeHandle(hd, _cancel_event) - - return super().add_event(evt) - - - def add_event(self, evt): - return self._add_event(evt, self.context.add_event, evt) - - def call_at(self, *args, **kwargs): - evt = _TinyEvent(None) - return self._add_event(evt, self.context.call_at, *args, **kwargs) - - def call_later(self, *args, **kwargs): - evt = _TinyEvent(None) - return self._add_event(evt, self.context.call_later, *args, **kwargs) + def add_event(self, evt, eid=None): + return self.context.add_event(evt, eid, module_src=self.module) def del_event(self, evt): - # Call to super().del_event is done in the _FakeHandle.cancel - return evt.handle.cancel() - + return self.context.del_event(evt, module_src=self.module) def send_response(self, server, res): if server in self.context.servers: diff --git a/nemubot/server/DCC.py b/nemubot/server/DCC.py deleted file mode 100644 index c1a6852..0000000 --- a/nemubot/server/DCC.py +++ /dev/null @@ -1,239 +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 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): - super().__init__(name="Nemubot DCC server") - - 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 deleted file mode 100644 index 7adc484..0000000 --- a/nemubot/server/IRC.py +++ /dev/null @@ -1,283 +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 . - -from datetime import datetime -import re -import socket - -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, SecureSocketServer - - -class _IRC: - - """Concrete implementation of a connexion to an IRC server""" - - def __init__(self, host="localhost", port=6667, owner=None, - nick="nemubot", username=None, password=None, - realname="Nemubot", encoding="utf-8", caps=None, - channels=list(), on_connect=None, **kwargs): - """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 - 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 - - super().__init__(name=self.username + "@" + host + ":" + str(port), - host=host, port=port, **kwargs) - 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 chn.password: - self.write("JOIN %s %s" % (chn.name, chn.password)) - else: - self.write("JOIN %s" % chn.name) - 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.frm == self.nick: - del self.channels[chname] - elif msg.frm in self.channels[chname].people: - del self.channels[chname].people[msg.frm] - self.hookscmd["PART"] = _on_part - # Respond to 331/RPL_NOTOPIC,332/RPL_TOPIC,TOPIC - def _on_topic(msg): - 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.frm, res)) - self.hookscmd["PRIVMSG"] = _on_ctcp - - - # Open/close - - def connect(self): - super().connect() - - 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, socket.getfqdn(), self.realname)) - - - def close(self): - if not self._closed: - self.write("QUIT") - return super().close() - - - # Writes: as inherited - - # Read - - def async_read(self): - for line in super().async_read(): - # 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 - - - def subparse(self, orig, cnt): - msg = IRCMessage(("@time=%s :%s!user@host.com PRIVMSG %s :%s" % (orig.date.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), orig.frm, ",".join(orig.to), cnt)).encode(self.encoding), self.encoding) - return msg.to_bot_message(self) - - -class IRC(_IRC, SocketServer): - pass - -class IRC_secure(_IRC, SecureSocketServer): - pass diff --git a/nemubot/server/IRCLib.py b/nemubot/server/IRCLib.py new file mode 100644 index 0000000..eb7c16f --- /dev/null +++ b/nemubot/server/IRCLib.py @@ -0,0 +1,375 @@ +# 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 new file mode 100644 index 0000000..ed4b746 --- /dev/null +++ b/nemubot/server/Matrix.py @@ -0,0 +1,200 @@ +# 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 a533491..db9ad87 100644 --- a/nemubot/server/__init__.py +++ b/nemubot/server/__init__.py @@ -22,25 +22,15 @@ def factory(uri, ssl=False, **init_args): srv = None if o.scheme == "irc" or o.scheme == "ircs": - # http://www.w3.org/Addressing/draft-mirashi-url-irc-01.txt - # http://www-archive.mozilla.org/projects/rt-messaging/chatzilla/irc-urls.html - args = init_args + # 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"] = o.password - - if ssl: - try: - from ssl import create_default_context - args["_context"] = create_default_context() - except ImportError: - # Python 3.3 compat - from ssl import SSLContext, PROTOCOL_TLSv1 - args["_context"] = SSLContext(PROTOCOL_TLSv1) - args["server_hostname"] = o.hostname + if o.password is not None: args["password"] = unquote(o.password) modifiers = o.path.split(",") target = unquote(modifiers.pop(0)[1:]) @@ -51,28 +41,58 @@ def factory(uri, ssl=False, **init_args): if "msg" in params: if "on_connect" not in args: args["on_connect"] = [] - args["on_connect"].append("PRIVMSG %s :%s" % (target, params["msg"])) + 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"])) + args["channels"].append((target, params["key"][0])) if "pass" in params: - args["password"] = params["pass"] + args["password"] = params["pass"][0] if "charset" in params: - args["encoding"] = params["charset"] + args["encoding"] = params["charset"][0] - # if "channels" not in args and "isnick" not in modifiers: - args["channels"] = [ target ] + args["channels"] = [target] - if ssl: - from nemubot.server.IRC import IRC_secure as SecureIRCServer - srv = SecureIRCServer(**args) - else: - from nemubot.server.IRC import IRC as IRCServer - srv = IRCServer(**args) + args["ssl"] = ssl + + 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) + + homeserver = "https://" + o.hostname + if o.port is not None: + homeserver += ":%d" % o.port + args["homeserver"] = homeserver + + if o.username is not None: + args["user_id"] = o.username + if o.password is not None: + args["password"] = unquote(o.password) + + # 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 diff --git a/nemubot/server/abstract.py b/nemubot/server/abstract.py index 1c05447..8fbb923 100644 --- a/nemubot/server/abstract.py +++ b/nemubot/server/abstract.py @@ -25,18 +25,18 @@ class AbstractServer: """An abstract server: handle communication with an IM server""" - def __init__(self, name=None, **kwargs): + def __init__(self, name, fdClass, **kwargs): """Initialize an abstract server Keyword argument: name -- Identifier of the socket, for convinience + fdClass -- Class to instantiate as support file """ self._name = name + self._fd = fdClass(**kwargs) - super().__init__(**kwargs) - - self.logger = logging.getLogger("nemubot.server." + str(self.name)) + self._logger = logging.getLogger("nemubot.server." + str(self.name)) self._readbuffer = b'' self._sending_queue = queue.Queue() @@ -46,7 +46,7 @@ class AbstractServer: if self._name is not None: return self._name else: - return self.fileno() + return self._fd.fileno() # Open/close @@ -54,25 +54,25 @@ class AbstractServer: def connect(self, *args, **kwargs): """Register the server in _poll""" - self.logger.info("Opening connection") + self._logger.info("Opening connection") - super().connect(*args, **kwargs) + self._fd.connect(*args, **kwargs) self._on_connect() def _on_connect(self): - sync_act("sckt", "register", self.fileno()) + sync_act("sckt", "register", self._fd.fileno()) def close(self, *args, **kwargs): """Unregister the server from _poll""" - self.logger.info("Closing connection") + self._logger.info("Closing connection") - if self.fileno() > 0: - sync_act("sckt", "unregister", self.fileno()) + if self._fd.fileno() > 0: + sync_act("sckt", "unregister", self._fd.fileno()) - super().close(*args, **kwargs) + self._fd.close(*args, **kwargs) # Writes @@ -85,15 +85,15 @@ class AbstractServer: """ 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.fileno()) + 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()) def async_write(self): """Internal function used when the file descriptor is writable""" try: - sync_act("sckt", "unwrite", self.fileno()) + sync_act("sckt", "unwrite", self._fd.fileno()) while not self._sending_queue.empty(): self._write(self._sending_queue.get_nowait()) self._sending_queue.task_done() @@ -159,4 +159,9 @@ class AbstractServer: def exception(self, flags): """Exception occurs on fd""" - self.close() + self._fd.close() + + # Proxy + + def fileno(self): + return self._fd.fileno() diff --git a/nemubot/server/factory_test.py b/nemubot/server/factory_test.py deleted file mode 100644 index 358591e..0000000 --- a/nemubot/server/factory_test.py +++ /dev/null @@ -1,52 +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 unittest - -from nemubot.server import factory - -class TestFactory(unittest.TestCase): - - def test_IRC1(self): - from nemubot.server.IRC import IRC as IRCServer - from nemubot.server.IRC import IRC_secure as IRCSServer - - # : If omitted, the client must connect to a prespecified default IRC server. - server = factory("irc:///") - self.assertIsInstance(server, IRCServer) - self.assertEqual(server.host, "localhost") - - server = factory("ircs:///") - self.assertIsInstance(server, IRCSServer) - self.assertEqual(server.host, "localhost") - - server = factory("irc://host1") - self.assertIsInstance(server, IRCServer) - self.assertEqual(server.host, "host1") - - server = factory("irc://host2:6667") - self.assertIsInstance(server, IRCServer) - self.assertEqual(server.host, "host2") - self.assertEqual(server.port, 6667) - - server = factory("ircs://host3:194/") - self.assertIsInstance(server, IRCSServer) - self.assertEqual(server.host, "host3") - self.assertEqual(server.port, 194) - - -if __name__ == '__main__': - unittest.main() diff --git a/nemubot/server/message/IRC.py b/nemubot/server/message/IRC.py deleted file mode 100644 index 5ccd735..0000000 --- a/nemubot/server/message/IRC.py +++ /dev/null @@ -1,210 +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 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.name, - "date": self.tags["time"], - "to": receivers, - "to_response": [r if r != srv.nick else self.nick for r in receivers], - "frm": self.nick, - "frm_owner": self.nick == srv.owner - } - - # 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, only at begening - kwargs = {} - while len(args) > 1: - arg = args[1] - if len(arg) > 2: - if arg[0:2] == '\\@': - args[1] = 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(1) - continue - # Futher argument are considered as normal argument (this helps for subcommand treatment) - break - - 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/server/message/__init__.py b/nemubot/server/message/__init__.py deleted file mode 100644 index 57f3468..0000000 --- a/nemubot/server/message/__init__.py +++ /dev/null @@ -1,15 +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 . diff --git a/nemubot/server/message/abstract.py b/nemubot/server/message/abstract.py deleted file mode 100644 index 624e453..0000000 --- a/nemubot/server/message/abstract.py +++ /dev/null @@ -1,33 +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 to_bot_message(self, srv): - """Convert to one of concrete implementation of AbstractMessage - - Argument: - srv -- the server from the message was received - """ - - raise NotImplemented - - - def to_server_string(self, **kwargs): - """Pretty print the message to close to original input string - """ - - raise NotImplemented diff --git a/nemubot/server/socket.py b/nemubot/server/socket.py index 8a0950c..bf55bf5 100644 --- a/nemubot/server/socket.py +++ b/nemubot/server/socket.py @@ -16,7 +16,6 @@ import os import socket -import ssl import nemubot.message as message from nemubot.message.printer.socket import Socket as SocketPrinter @@ -40,7 +39,7 @@ class _Socket(AbstractServer): # Write def _write(self, cnt): - self.sendall(cnt) + self._fd.sendall(cnt) def format(self, txt): @@ -52,8 +51,8 @@ class _Socket(AbstractServer): # Read - def recv(self, n=1024): - return super().recv(n) + def read(self, bufsize=1024, *args, **kwargs): + return self._fd.recv(bufsize, *args, **kwargs) def parse(self, line): @@ -67,7 +66,7 @@ class _Socket(AbstractServer): args = line.split(' ') if len(args): - yield message.Command(cmd=args[0], args=args[1:], server=self.fileno(), to=["you"], frm="you") + yield message.Command(cmd=args[0], args=args[1:], server=self._fd.fileno(), to=["you"], frm="you") def subparse(self, orig, cnt): @@ -78,50 +77,43 @@ class _Socket(AbstractServer): yield m -class _SocketServer(_Socket): +class SocketServer(_Socket): - def __init__(self, host, port, bind=None, **kwargs): - (family, type, proto, canonname, sockaddr) = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)[0] + 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__(family=family, type=type, proto=proto, **kwargs) + super().__init__(fdClass=socket.socket, family=family, type=type, proto=proto, **kwargs) - self._sockaddr = sockaddr self._bind = bind def connect(self): - self.logger.info("Connection to %s:%d", *self._sockaddr[:2]) + 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: - super().bind(self._bind) - - -class SocketServer(_SocketServer, socket.socket): - pass - - -class SecureSocketServer(_SocketServer, ssl.SSLSocket): - pass + self._fd.bind(self._bind) class UnixSocket: def __init__(self, location, **kwargs): - super().__init__(family=socket.AF_UNIX, **kwargs) + super().__init__(fdClass=socket.socket, family=socket.AF_UNIX, **kwargs) self._socket_path = location def connect(self): - self.logger.info("Connection to unix://%s", self._socket_path) - super().connect(self._socket_path) + self._logger.info("Connection to unix://%s", self._socket_path) + self.connect(self._socket_path) -class SocketClient(_Socket, socket.socket): +class SocketClient(_Socket): - def read(self): - return self.recv() + def __init__(self, **kwargs): + super().__init__(fdClass=socket.socket, **kwargs) class _Listener: @@ -134,9 +126,9 @@ class _Listener: def read(self): - conn, addr = self.accept() + conn, addr = self._fd.accept() fileno = conn.fileno() - self.logger.info("Accept new connection from %s (fd=%d)", addr, 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 @@ -145,23 +137,19 @@ class _Listener: return b'' -class UnixSocketListener(_Listener, UnixSocket, _Socket, socket.socket): - - def __init__(self, **kwargs): - super().__init__(**kwargs) - +class UnixSocketListener(_Listener, UnixSocket, _Socket): def connect(self): - self.logger.info("Creating Unix socket at unix://%s", self._socket_path) + self._logger.info("Creating Unix socket at unix://%s", self._socket_path) try: os.remove(self._socket_path) except FileNotFoundError: pass - self.bind(self._socket_path) - self.listen(5) - self.logger.info("Socket ready for accepting new connections") + self._fd.bind(self._socket_path) + self._fd.listen(5) + self._logger.info("Socket ready for accepting new connections") self._on_connect() @@ -171,7 +159,7 @@ class UnixSocketListener(_Listener, UnixSocket, _Socket, socket.socket): import socket try: - self.shutdown(socket.SHUT_RDWR) + self._fd.shutdown(socket.SHUT_RDWR) except socket.error: pass diff --git a/nemubot/server/test_IRC.py b/nemubot/server/test_IRC.py deleted file mode 100644 index 37d7437..0000000 --- a/nemubot/server/test_IRC.py +++ /dev/null @@ -1,50 +0,0 @@ -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.frm, "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 new file mode 100644 index 0000000..eb1ae19 --- /dev/null +++ b/nemubot/server/threaded.py @@ -0,0 +1,132 @@ +# 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/feed.py b/nemubot/tools/feed.py index 7e63cd2..6f8930d 100644 --- a/nemubot/tools/feed.py +++ b/nemubot/tools/feed.py @@ -82,11 +82,16 @@ class RSSEntry: else: self.summary = None - if len(node.getElementsByTagName("link")) > 0 and node.getElementsByTagName("link")[0].hasAttribute("href"): - self.link = node.getElementsByTagName("link")[0].getAttribute("href") + if len(node.getElementsByTagName("link")) > 0: + self.link = node.getElementsByTagName("link")[0].firstChild.nodeValue 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) @@ -105,13 +110,13 @@ class Feed: self.updated = None self.entries = list() - if self.feed.tagName == "rss": + if self.feed.tagName == "rdf:RDF" or 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") + raise IMException("This is not a valid Atom or RSS feed.") def _parse_atom_feed(self): diff --git a/nemubot/tools/web.py b/nemubot/tools/web.py index c3ba42a..a545b19 100644 --- a/nemubot/tools/web.py +++ b/nemubot/tools/web.py @@ -23,7 +23,7 @@ from nemubot.exception import IMException 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,18 +68,7 @@ def getPassword(url): # Get real pages -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 _URLConn(cb, url, body=None, timeout=7, header=None, follow_redir=True): o = urlparse(_getNormalizedURL(url), "http") import http.client @@ -134,10 +123,53 @@ def getURLContent(url, body=None, timeout=7, header=None, decode_error=False, 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 size > max_size or (cntype is not None and cntype[:4] != "text" and cntype[:4] != "appl"): + 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") data = res.read(size) @@ -155,28 +187,18 @@ def getURLContent(url, body=None, timeout=7, header=None, decode_error=False, charset = cha[1] else: charset = cha[0] - except http.client.BadStatusLine: - raise IMException("Invalid HTTP response") - finally: - conn.close() - 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( - urljoin(url, res.getheader("Location")), - body=body, - timeout=timeout, - header=header, - decode_error=decode_error, - max_size=max_size) - elif decode_error: - return data.decode(charset).strip() - else: - raise IMException("A HTTP error occurs: %d - %s" % - (res.status, http.client.responses[res.status])) + 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) def getXML(*args, **kwargs): diff --git a/nemubot/tools/xmlparser/__init__.py b/nemubot/tools/xmlparser/__init__.py index c8d393a..1bf60a8 100644 --- a/nemubot/tools/xmlparser/__init__.py +++ b/nemubot/tools/xmlparser/__init__.py @@ -83,7 +83,7 @@ class XMLParser: @property def root(self): if len(self.stack): - return self.stack[0] + return self.stack[0][0] else: return None @@ -91,13 +91,13 @@ class XMLParser: @property def current(self): if len(self.stack): - return self.stack[-1] + return self.stack[-1][0] else: return None def display_stack(self): - return " in ".join([str(type(s).__name__) for s in reversed(self.stack)]) + return " in ".join([str(type(s).__name__) for s,c in reversed(self.stack)]) def startElement(self, name, attrs): @@ -105,7 +105,8 @@ class XMLParser: 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.stack.append((self.knodes[name](**attrs), self.child)) + self.child = 0 else: self.child += 1 @@ -116,19 +117,15 @@ class XMLParser: def endElement(self, name): - if self.child: - self.child -= 1 - - if hasattr(self.current, "endElement"): - self.current.endElement(name) - return - if hasattr(self.current, "endElement"): self.current.endElement(None) + if self.child: + self.child -= 1 + # Don't remove root - if len(self.stack) > 1: - last = self.stack.pop() + elif len(self.stack) > 1: + last, self.child = self.stack.pop() if hasattr(self.current, "addChild"): if self.current.addChild(name, last): return diff --git a/nemubot/treatment.py b/nemubot/treatment.py index 4f629e0..ed7cacb 100644 --- a/nemubot/treatment.py +++ b/nemubot/treatment.py @@ -15,7 +15,6 @@ # along with this program. If not, see . import logging -import types logger = logging.getLogger("nemubot.treatment") @@ -79,19 +78,12 @@ 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): - res = h.run(msg) + for res in flatify(h.run(msg)): + if res is not None and res != msg: + yield from self._pre_treat(res) - 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 + elif res is None or res is False: + break else: yield msg @@ -113,25 +105,10 @@ class MessageTreater: msg.frm_owner = (not hasattr(msg.server, "owner") or msg.server.owner == msg.frm) while hook is not None: - res = hook.run(msg) - - if isinstance(res, list): - for r in res: - yield r - - elif res is not None: - if isinstance(res, types.GeneratorType): - for r in res: - if not hasattr(r, "server") or r.server is None: - r.server = msg.server - - yield r - - else: - if not hasattr(res, "server") or res.server is None: - res.server = msg.server - - yield res + for res in flatify(hook.run(msg)): + if not hasattr(res, "server") or res.server is None: + res.server = msg.server + yield res hook = next(hook_gen, None) @@ -165,19 +142,20 @@ class MessageTreater: for h in self.hm.get_hooks("post", type(msg).__name__): if h.can_write(msg.to, msg.server) and h.match(msg): - res = h.run(msg) + for res in flatify(h.run(msg)): + if res is not None and res != msg: + yield from self._post_treat(res) - 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 + 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 e69de29..e037895 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,2 @@ +irc +matrix-nio diff --git a/setup.py b/setup.py index 36dddb4..7b5bdcd 100755 --- a/setup.py +++ b/setup.py @@ -69,8 +69,8 @@ setup( 'nemubot.hooks.keywords', 'nemubot.message', 'nemubot.message.printer', + 'nemubot.module', 'nemubot.server', - 'nemubot.server.message', 'nemubot.tools', 'nemubot.tools.xmlparser', ],