mirror of
https://github.com/jlu5/SupyPlugins.git
synced 2025-04-26 04:51:08 -05:00
Merge branch 'grapnel-hooks'
This commit is contained in:
commit
f7f6a011e8
@ -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"),
|
||||
|
68
Grapnel/__init__.py
Normal file
68
Grapnel/__init__.py
Normal file
@ -0,0 +1,68 @@
|
||||
###
|
||||
# Copyright (c) 2022, James Lu <james@overdrivenetworks.com>
|
||||
# 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:
|
57
Grapnel/config.py
Normal file
57
Grapnel/config.py
Normal file
@ -0,0 +1,57 @@
|
||||
###
|
||||
# Copyright (c) 2022, James Lu <james@overdrivenetworks.com>
|
||||
# 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:
|
1
Grapnel/local/__init__.py
Normal file
1
Grapnel/local/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Stub so local is a module, used for third-party modules
|
284
Grapnel/plugin.py
Normal file
284
Grapnel/plugin.py
Normal file
@ -0,0 +1,284 @@
|
||||
###
|
||||
# Copyright (c) 2022, James Lu <james@overdrivenetworks.com>
|
||||
# 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', ['<unknown>'])[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):
|
||||
"""[<network>] [<channel>]
|
||||
|
||||
Creates a new Slack-compatible webhook endpoint for a given network + channel.
|
||||
<network> and <channel> 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):
|
||||
"""[<network>] [<channel>]
|
||||
|
||||
Lists webhooks set on the network + channel pair.
|
||||
<network> and <channel> 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):
|
||||
"""<webhook ID>
|
||||
|
||||
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):
|
||||
"""<webhook ID>
|
||||
|
||||
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):
|
||||
"""<webhook ID>
|
||||
|
||||
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:
|
35
Grapnel/setup.py
Normal file
35
Grapnel/setup.py
Normal file
@ -0,0 +1,35 @@
|
||||
###
|
||||
# Copyright (c) 2022, James Lu <james@overdrivenetworks.com>
|
||||
# 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',
|
||||
)
|
188
Grapnel/test.py
Normal file
188
Grapnel/test.py
Normal file
@ -0,0 +1,188 @@
|
||||
###
|
||||
# Copyright (c) 2022, James Lu <james@overdrivenetworks.com>
|
||||
# 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, "<html></html>")
|
||||
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:
|
@ -1,7 +1,7 @@
|
||||
# SupyPlugins
|
||||
|
||||
[](https://drone.overdrivenetworks.com/jlu5/SupyPlugins)
|
||||

|
||||

|
||||
|
||||
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:
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user