diff --git a/.drone.jsonnet b/.drone.jsonnet index 6ad0e27..c84bc60 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -32,7 +32,6 @@ local test_with(version, use_network=false) = { }; [ - test_with("3.7"), test_with("3.8"), test_with("3.9"), test_with("3.10"), diff --git a/Grapnel/__init__.py b/Grapnel/__init__.py new file mode 100644 index 0000000..8bc0c5e --- /dev/null +++ b/Grapnel/__init__.py @@ -0,0 +1,68 @@ +### +# Copyright (c) 2022, James Lu +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +""" +Grapnel: announce Slack-compatible webhooks to IRC +""" + +import sys +import supybot +from supybot import world + +# Use this for the version of this plugin. +__version__ = "" + +__author__ = getattr(supybot.authors, 'jlu', + supybot.Author('James Lu', 'jlu5', 'james@overdrivenetworks.com')) + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +# This is a url where the most recent plugin package can be downloaded. +__url__ = '' + +from . import config +from . import plugin +from importlib import reload +# In case we're being reloaded. +reload(config) +reload(plugin) +# Add more reloads here if you add third-party modules and want them to be +# reloaded when this plugin is reloaded. Don't forget to import them as well! + +if world.testing: + from . import test + +Class = plugin.Class +configure = config.configure + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/Grapnel/config.py b/Grapnel/config.py new file mode 100644 index 0000000..012855f --- /dev/null +++ b/Grapnel/config.py @@ -0,0 +1,57 @@ +### +# Copyright (c) 2022, James Lu +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +from supybot import conf, registry +try: + from supybot.i18n import PluginInternationalization + _ = PluginInternationalization('Grapnel') +except: + # Placeholder that allows to run the plugin on a bot + # without the i18n module + _ = lambda x: x + + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified themself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('Grapnel', True) + + +Grapnel = conf.registerPlugin('Grapnel') +conf.registerGlobalValue(Grapnel, 'baseurl', + registry.String('', _("""Sets the base URL for webhook endpoints, excluding the /grapnel path."""))) +conf.registerChannelValue(Grapnel, 'format', + registry.String('[$name] $text', _("""Sets the output format for webhooks. Along with Limnoria's standard substitutions, these fields are used: $name, $text"""))) + + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/Grapnel/local/__init__.py b/Grapnel/local/__init__.py new file mode 100644 index 0000000..e86e97b --- /dev/null +++ b/Grapnel/local/__init__.py @@ -0,0 +1 @@ +# Stub so local is a module, used for third-party modules diff --git a/Grapnel/plugin.py b/Grapnel/plugin.py new file mode 100644 index 0000000..c82e94d --- /dev/null +++ b/Grapnel/plugin.py @@ -0,0 +1,284 @@ +### +# Copyright (c) 2022, James Lu +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +import json +import secrets +import sqlite3 +import urllib.parse + +from supybot import ircmsgs, ircutils, callbacks, httpserver, log, world +from supybot.commands import conf, wrap +from supybot.i18n import PluginInternationalization + + +_ = PluginInternationalization('Grapnel') + +class GrappleHTTPCallback(httpserver.SupyHTTPServerCallback): + name = 'Grapnel' + + def _send_response(self, handler, code, text, extra_headers=None): + handler.send_response(code) + handler.send_header('Content-type', 'text/plain') + if extra_headers: + for header_pair in extra_headers: + handler.send_header(*header_pair) + handler.end_headers() + handler.wfile.write(text.encode('utf-8')) + handler.wfile.write(b'\n') + + def doPost(self, handler, path, form=None): + try: + log.info(path) + if handler.headers['Content-type'] != 'application/json': + self._send_response(handler, 400, "Bad HTTP Content-Type (expected JSON)") + return + try: + data = json.loads(form.decode('utf-8')) + except json.JSONDecodeError as e: + self._send_response(handler, 400, f"Invalid JSON input: {e}") + return + if not isinstance(data, dict): + self._send_response(handler, 400, "Incorrect JSON data type (expected object)") + return + if not (text := data.get('text')): + self._send_response(handler, 400, "Message is missing a text field") + return + + url_parts = urllib.parse.urlparse(path) + url_qs = urllib.parse.parse_qs(url_parts.query) + hookID = url_parts.path.strip('/') + log.error(str(url_qs)) + + if not (req_tokens := url_qs.get('token')): + self._send_response(handler, 401, "Missing webhook token") + return + + # Optional sender for formatting + sender_name = url_qs.get('sender', [''])[0] + + cur = self.plugin.conn.cursor() # pylint: disable=no-member + cur.execute(""" + SELECT network, channel, token FROM webhooks + WHERE id=? + """, (hookID,)) + + result = cur.fetchone() + if not result: + self._send_response(handler, 404, f"No such webhook ID {hookID!r}") + return + network, channel, real_token = result + + # query string keys can contain a list of values + if req_tokens[0] != real_token: + self._send_response(handler, 403, "Incorrect webhook token") + return + + if not (irc := world.getIrc(network)): + self._send_response(handler, 500, f"Network {network!r} is not connected") + return + + fields = { + "text": str(text), + "name": sender_name + } + # pylint: disable=no-member + tmpl = self.plugin.registryValue("format", channel=channel, network=network) + out_s = ircutils.standardSubstitute(irc, None, tmpl, env=fields) + if not out_s: + log.warning("Grapnel: output text for webhook %s / %s@%s is empty", hookID, channel, network) + self._send_response(handler, 500, "Output text is empty (misconfigured output template?)") + return + m = ircmsgs.privmsg(channel, out_s) + irc.queueMsg(m) + + self._send_response(handler, 200, "OK") + + except: + self._send_response(handler, 500, "Unspecified internal error") + raise + + def doGet(self, handler, path): + self._send_response(handler, 405, "Only POST requests are supported by this service", + extra_headers=[('Allow', 'POST')]) + +HTTP_ENDPOINT_NAME = 'grapnel' + +# https://docs.python.org/3.10/library/secrets.html#how-many-bytes-should-tokens-use +TOKEN_LENGTH = 32 + +class Grapnel(callbacks.Plugin): + """Grapnel plugin: announce Slack-compatible webhooks to IRC""" + threaded = False # sqlite3 not thread safe for writes + + def __init__(self, irc): + super().__init__(irc) + + self.callback = GrappleHTTPCallback() + self.callback.plugin = self + httpserver.hook(HTTP_ENDPOINT_NAME, self.callback) + + dbfile = conf.supybot.directories.data.dirize("Grapnel.sqlite") + # ASSUME: writes only come from main thread handling IRC commands + self.conn = sqlite3.connect(dbfile, check_same_thread=False) + + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS webhooks( + id INTEGER PRIMARY KEY, + network TEXT, + channel TEXT, + token TEXT + ) + """) + + def die(self): + httpserver.unhook(HTTP_ENDPOINT_NAME) + self.conn.close() + super().die() + + def _format_url(self, hookID, token): + baseurl = self.registryValue("baseURL") + url = urllib.parse.urljoin(baseurl, f"/{HTTP_ENDPOINT_NAME}/{hookID}?" + urllib.parse.urlencode({ + 'token': token, + 'sender': 'your-cool-app-name' + })) + return url + + @wrap(['networkIrc', 'channel', 'admin']) + def add(self, irc, msg, args, networkIrc, channel): + """[] [] + + Creates a new Slack-compatible webhook endpoint for a given network + channel. + and default to the current network and channel if not specified. + """ + if not self.registryValue("baseurl"): + raise callbacks.Error(_("Webhook base URL missing; set the config option plugins.grapnel.baseurl")) + + network = networkIrc.network + token = secrets.token_hex(TOKEN_LENGTH) + channel = ircutils.toLower(channel) + + cur = self.conn.cursor() + cur.execute(""" + INSERT INTO webhooks(network, channel, token) + VALUES (?, ?, ?) + """, (network, channel, token)) + self.conn.commit() + newID = cur.lastrowid + url = self._format_url(newID, token) + + #s = _("Webhook #%d created. This link (with the token) will only be shown once; keep it private!: %s") % (newID, url) + s = _("Webhook #%d created. Keep this link private! %s") % (newID, url) + log.debug("Grapnel: created webhook %s for %s@%s: %s", newID, channel, network, url) + irc.reply(s, private=True) + + @wrap(['networkIrc', 'channel', 'admin']) + def listhooks(self, irc, msg, args, networkIrc, channel): + """[] [] + + Lists webhooks set on the network + channel pair. + and default to the current network and channel if not specified. + """ + network = networkIrc.network + channel = ircutils.toLower(channel) + cur = self.conn.cursor() + cur.execute(""" + SELECT id FROM webhooks + WHERE network=? AND channel=? + """, (network, channel)) + results = [row[0] for row in cur.fetchall()] + if results: + results_s = ' '.join([f"#{i}" for i in results]) + else: + results_s = _('(none)') + irc.reply(_("Webhooks for %s@%s: %s") % (channel, network, results_s)) + + @wrap(['nonNegativeInt', 'admin']) + def get(self, irc, msg, args, hookID): + """ + + Returns metadata and the URL for the webhook ID. + """ + cur = self.conn.cursor() + cur.execute(""" + SELECT * FROM webhooks + WHERE id=? + """, (hookID,)) + result = cur.fetchone() + if result is None: + irc.error(_("No such webhook #%d.") % hookID) + else: + __, network, channel, token = result + url = self._format_url(hookID, token) + s = _("Webhook #%d for %s@%s: %s") % (hookID, channel, network, url) + irc.reply(s, private=True) + + @wrap(['nonNegativeInt', 'admin']) + def resettoken(self, irc, msg, args, hookID): + """ + + Regenerates the token for the given webhook ID. + """ + cur = self.conn.cursor() + token = secrets.token_hex(TOKEN_LENGTH) + cur.execute(""" + UPDATE webhooks + SET token=? + WHERE id=? + """, (token, hookID)) + self.conn.commit() + if cur.rowcount: + url = self._format_url(hookID, token) + s = _("Updated webhook #%d: %s") % (hookID, url) + irc.reply(s, private=True) + else: + irc.error(_("No such webhook #%d.") % hookID) + + @wrap(['nonNegativeInt', 'admin']) + def remove(self, irc, msg, args, hookID): + """ + + Removes the given webhook ID. + """ + cur = self.conn.cursor() + cur.execute(""" + DELETE FROM webhooks + WHERE id=? + """, (hookID,)) + self.conn.commit() + if cur.rowcount: + irc.replySuccess() + else: + irc.error(_("No such webhook #%d.") % hookID) + + +Class = Grapnel + + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/Grapnel/setup.py b/Grapnel/setup.py new file mode 100644 index 0000000..89f8e0c --- /dev/null +++ b/Grapnel/setup.py @@ -0,0 +1,35 @@ +### +# Copyright (c) 2022, James Lu +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +from supybot.setup import plugin_setup + +plugin_setup( + 'Grapnel', +) diff --git a/Grapnel/test.py b/Grapnel/test.py new file mode 100644 index 0000000..466400f --- /dev/null +++ b/Grapnel/test.py @@ -0,0 +1,188 @@ +### +# Copyright (c) 2022, James Lu +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +import io +import json +import re +import urllib.parse + +from supybot import conf +from supybot.test import ChannelHTTPPluginTestCase, FakeHTTPConnection, TestRequestHandler + +TEST_BASEURL = 'http://grapnel.test' + +class GrapnelTestCase(ChannelHTTPPluginTestCase): + plugins = ('Grapnel',) + config = { + 'plugins.grapnel.baseurl': TEST_BASEURL + } + timeout = 10 + + # Like HTTPPluginTestCase.request, but with JSON Content-Type header and data + def jsonPost(self, url, json_data): + assert url.startswith('/') + wfile = io.BytesIO() + rfile = io.BytesIO() + # wfile and rfile are reversed in Limnoria's HTTPPluginTestCase.request too? + connection = FakeHTTPConnection(wfile, rfile) + headers = {'Content-Type': 'application/json'} + connection.request('POST', url, json_data, headers) + rfile.seek(0) + handler = TestRequestHandler(rfile, wfile) + wfile.seek(0) + return (handler._response, wfile.read()) # pylint: disable=protected-access + + # def setUp(self): + # super().setUp() + # self.myVerbose = verbosity.MESSAGES + + def _getLinkFragment(self, m): + url_re = re.search(fr'{TEST_BASEURL}(\/grapnel\/\d+\?.*)', m.args[1]) + url_fragment = url_re.group(1) + assert url_fragment + return url_fragment + + def _addHook(self, channel='#test'): + m = self.assertRegexp(f"grapnel add {channel}", fr"Webhook #\d+ created.*{TEST_BASEURL}/grapnel") + return self._getLinkFragment(m) + + def testPOSTSuccess(self): + url_fragment = self._addHook() + (respCode, body) = self.jsonPost(url_fragment, json.dumps({"text": "123456"})) + self.assertEqual(respCode, 200, body.decode()) + self.assertSnarfRegexp(' ', r'\[.*?\] 123456') + + def testPOSTCustomFormat(self): + url_fragment = self._addHook() + sp = urllib.parse.urlsplit(url_fragment) + sp_query = urllib.parse.parse_qs(sp.query) + #print("OLD", url_fragment, sp_query) + url_fragment = sp.path + '?' + urllib.parse.urlencode({ + 'token': sp_query['token'][0], 'sender': 'foobar'}) + #print("NEW", url_fragment) + + with conf.supybot.plugins.grapnel.format.context("Limnoria says: [$name] $text"): + (respCode, body) = self.jsonPost(url_fragment, json.dumps({"text": "123456"})) + self.assertEqual(respCode, 200, body.decode()) + self.assertSnarfResponse(' ', 'Limnoria says: [foobar] 123456') + + def testPOSTInvalidJSON(self): + url_fragment = self._addHook() + (respCode, body) = self.jsonPost(url_fragment, "") + self.assertEqual(respCode, 400, body.decode()) + self.assertSnarfNoResponse(' ') + + def testPOSTMissingToken(self): + url_fragment = self._addHook() + + sp = urllib.parse.urlsplit(url_fragment) + url_fragment = sp.path + + (respCode, body) = self.jsonPost(url_fragment, json.dumps({"text": "hello world"})) + self.assertEqual(respCode, 401, body.decode()) + self.assertSnarfNoResponse(' ') + + def testPOSTWrongToken(self): + url_fragment = self._addHook() + + # parse the URL and change the token + sp = urllib.parse.urlsplit(url_fragment) + url_fragment = sp.path + '?' + urllib.parse.urlencode({'token': 'obvious-incorrect'}) + + (respCode, body) = self.jsonPost(url_fragment, json.dumps({"text": "hello world"})) + self.assertEqual(respCode, 403, body.decode()) + self.assertSnarfNoResponse(' ') + + def testPOSTUnknownHookID(self): + url_fragment = self._addHook() + + # parse the URL and change the path + sp = urllib.parse.urlsplit(url_fragment) + url_fragment = '/grapnel/12345?' + sp.query + + (respCode, body) = self.jsonPost(url_fragment, json.dumps({"text": "hello world"})) + self.assertEqual(respCode, 404, body.decode()) + self.assertSnarfNoResponse(' ') + + def testPOSTBadContentType(self): + url_fragment = self._addHook() + (respCode, body) = self.request(url_fragment, method='POST') + self.assertEqual(respCode, 400, body.decode()) + self.assertSnarfNoResponse(' ') + + def testGet(self): + url_fragment_1 = self._addHook() + url_fragment_2 = self._addHook(channel='#limnoria') + self.assertRegexp('grapnel get 1', fr'Webhook #1 for #test@test.*{re.escape(url_fragment_1)}') + self.assertRegexp('grapnel get 2', fr'Webhook #2 for #limnoria@test.*{re.escape(url_fragment_2)}') + + def testRemove(self): + url_fragment_1 = self._addHook(channel='#bots') + url_fragment_2 = self._addHook(channel='#bots') + url_fragment_3 = self._addHook(channel='#bots') + self.assertNotError('grapnel remove 2') + self.assertRegexp('grapnel listhooks #bots', '#1 #3$') + + # check that the removed hook errors + (respCode, body) = self.jsonPost(url_fragment_2, json.dumps({"text": "this goes nowhere"})) + self.assertEqual(respCode, 404, body.decode()) + self.assertSnarfNoResponse(' ') + + # can't remove it twice + self.assertError('grapnel remove 2') + + def testListAndCaseNormalization(self): + url_fragment_1 = self._addHook(channel='#dev') + url_fragment_2 = self._addHook(channel='#Dev') + url_fragment_3 = self._addHook(channel='#limnoria') + self.assertResponse('grapnel listhooks #dev', 'Webhooks for #dev@test: #1 #2') + self.assertResponse('grapnel listhooks #DEV', 'Webhooks for #dev@test: #1 #2') + self.assertResponse('grapnel listhooks #Limnoria', 'Webhooks for #limnoria@test: #3') + self.assertRegexp('grapnel listhooks #', r'\#\@test.*\(none\)') + + def testResetTokenWorks(self): + url_fragment_old = self._addHook() + m = self.assertRegexp("grapnel resettoken 1", fr"{TEST_BASEURL}/grapnel") + url_fragment_new = self._getLinkFragment(m) + + # check that they're the same hook ID + self.assertNotEqual(url_fragment_old, url_fragment_new, "Webhook URLs should be different") + self.assertIn("grapnel/1?", url_fragment_old) + self.assertIn("grapnel/1?", url_fragment_new) + + # old token gives 403 + (respCode, body) = self.jsonPost(url_fragment_old, json.dumps({"text": "hello world"})) + self.assertEqual(respCode, 403, body.decode()) + self.assertSnarfNoResponse(' ') + # new token is OK + (respCode, body) = self.jsonPost(url_fragment_new, json.dumps({"text": "the game"})) + self.assertEqual(respCode, 200, body.decode()) + +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: diff --git a/README.md b/README.md index 1cfebc7..56c67aa 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SupyPlugins [![Build Status](https://drone.overdrivenetworks.com/api/badges/jlu5/SupyPlugins/status.svg)](https://drone.overdrivenetworks.com/jlu5/SupyPlugins) -![Supported Python versions](https://img.shields.io/badge/python-3.7%20and%20later-blue.svg) +![Supported Python versions](https://img.shields.io/badge/python-3.8%20and%20later-blue.svg) My collection of plugins for [Limnoria](https://github.com/ProgVal/Limnoria). @@ -12,7 +12,7 @@ The recommended way of fetching plugins in this repository is to clone the git r and add the folder to your bot's `config directories.plugins`. -**You will need a working copy of [Limnoria](https://github.com/ProgVal/Limnoria) running on Python >= 3.7** (prefer the latest Python 3.x when available). +**You will need a working copy of [Limnoria](https://github.com/ProgVal/Limnoria) running on Python >= 3.8** (prefer the latest Python 3.x when available). If you are using a recent version of Limnoria's PluginDownloader, you can also fetch [individual plugins](#list-of-plugins) by running: