Grapnel: first commit

Announce Slack-style webhooks to IRC
This commit is contained in:
James Lu 2022-07-07 23:17:58 -07:00
parent f4d77173ac
commit d526f44d1d
6 changed files with 481 additions and 0 deletions

68
Grapnel/__init__.py Normal file
View 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
View 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:

View File

@ -0,0 +1 @@
# Stub so local is a module, used for third-party modules

282
Grapnel/plugin.py Normal file
View File

@ -0,0 +1,282 @@
###
# 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, getopts, wrap
from supybot.i18n import PluginInternationalization
_ = PluginInternationalization('Grapnel')
class GrappleHTTPCallback(httpserver.SupyHTTPServerCallback):
name = 'Grapnel'
def _send_response(self, handler, code, text):
handler.send_response(code)
handler.send_header('Content-type', 'text/plain')
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
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 _get_target(self, irc, msg, opts):
opts = dict(opts)
network = opts.get('network', irc.network)
channel = opts.get('channel', msg.channel)
if not channel:
raise callbacks.Error(_("No webhook channel specified"))
return network, ircutils.toLower(channel)
def _format_url(self, hookID, token):
baseurl = self.registryValue("baseURL")
url = urllib.parse.urljoin(baseurl, f"/{HTTP_ENDPOINT_NAME}/{hookID}?token={token}&sender=change-this")
return url
@wrap([getopts({'network': 'something', 'channel': 'channel'}), ('checkCapability', 'grapnel')])
def add(self, irc, msg, args, opts):
"""[--network <network>] [--channel <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.
"""
network, channel = self._get_target(irc, msg, opts)
if not self.registryValue("baseurl"):
raise callbacks.Error(_("Webhook base URL missing; set the config option plugins.grapnel.baseurl"))
if not world.getIrc(network):
raise callbacks.Error(_("Network %r is not connected.") % network)
token = secrets.token_hex(TOKEN_LENGTH)
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([getopts({'network': 'something', 'channel': 'channel'}), ('checkCapability', 'grapnel')])
def listhooks(self, irc, msg, args, opts):
"""[--network <network>] [--channel <channel>]
<network> and <channel> default to the current network and channel if not specified.
"""
network, channel = self._get_target(irc, msg, opts)
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', ('checkCapability', 'grapnel')])
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', ('checkCapability', 'grapnel')])
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', ('checkCapability', 'grapnel')])
def remove(self, irc, msg, args, hookID):
"""<webhook ID>
Removes 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
View 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',
)

38
Grapnel/test.py Normal file
View File

@ -0,0 +1,38 @@
###
# 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.test import *
class GrapnelTestCase(PluginTestCase):
plugins = ('Grapnel',)
# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: