aboutsummaryrefslogtreecommitdiff
path: root/http2irc.py
diff options
context:
space:
mode:
authorGravatar JustAnotherArchivist2019-12-22 05:33:18 +0000
committerGravatar JustAnotherArchivist2019-12-22 05:33:18 +0000
commita91e61b84c2b485edd3786aa0d31645b130d39ab (patch)
tree66e4fb20a0ef857a53db73bec385c67b9bc505b3 /http2irc.py
parentSupport web server config changes (diff)
signature
Add support for transformation/translation modules that do arbitrary request processing to generate the message
Fixes #3
Diffstat (limited to 'http2irc.py')
-rw-r--r--http2irc.py61
1 files changed, 54 insertions, 7 deletions
diff --git a/http2irc.py b/http2irc.py
index 1ead513..7a09642 100644
--- a/http2irc.py
+++ b/http2irc.py
@@ -4,6 +4,8 @@ import asyncio
import base64
import collections
import concurrent.futures
+import importlib.util
+import inspect
import logging
import os.path
import signal
@@ -114,9 +116,11 @@ class Config(dict):
raise InvalidConfig(f'Invalid map key {key!r}')
if not isinstance(map_, collections.abc.Mapping):
raise InvalidConfig(f'Invalid map for {key!r}')
- if any(x not in ('webpath', 'ircchannel', 'auth') for x in map_):
+ if any(x not in ('webpath', 'ircchannel', 'auth', 'module') for x in map_):
raise InvalidConfig(f'Unknown key(s) found in map {key!r}')
#TODO: Check values
+ if 'module' in map_ and not os.path.isfile(map_['module']):
+ raise InvalidConfig(f'Module {map_["module"]!r} in map {key!r} is not a file')
# Default values
finalObj = {'logging': {'level': 'INFO', 'format': '{asctime} {levelname} {message}'}, 'irc': {'host': 'irc.hackint.org', 'port': 6697, 'ssl': 'yes', 'nick': 'h2ibot', 'real': 'I am an http2irc bot.', 'certfile': None, 'certkeyfile': None}, 'web': {'host': '127.0.0.1', 'port': 8080}, 'maps': {}}
@@ -129,6 +133,33 @@ class Config(dict):
map_['ircchannel'] = f'#{key}'
if 'auth' not in map_:
map_['auth'] = False
+ if 'module' not in map_:
+ map_['module'] = None
+
+ # Load modules
+ modulePaths = {map_['module'] for map_ in obj['maps'].values() if 'module' in map_ and map_['module'] is not None}
+ modules = {} # path: str -> module: module
+ for i, path in enumerate(modulePaths):
+ try:
+ # Build a name that is virtually guaranteed to be unique across a process.
+ # Although importlib does not seem to perform any caching as of CPython 3.8, this is not guaranteed by spec.
+ spec = importlib.util.spec_from_file_location(f'http2irc-module-{id(self)}-{i}', path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ except Exception as e: # This is ugly, but exec_module can raise virtually any exception
+ raise InvalidConfig(f'Loading module {path!r} failed: {e!s}')
+ if not hasattr(module, 'process'):
+ raise InvalidConfig(f'Module {path!r} does not have a process function')
+ if not inspect.iscoroutinefunction(module.process):
+ raise InvalidConfig(f'Module {path!r} process attribute is not a coroutine function')
+ if len(inspect.signature(module.process).parameters) != 1:
+ raise InvalidConfig(f'Module {path!r} process function does not take exactly 1 parameter')
+ modules[path] = module
+
+ # Replace module value in maps
+ for map_ in obj['maps'].values():
+ if 'module' in map_ and map_['module'] is not None:
+ map_['module'] = modules[map_['module']]
# Merge in what was read from the config file and set keys on self
for key in ('logging', 'irc', 'web', 'maps'):
@@ -370,7 +401,7 @@ class WebServer:
self.messageQueue = messageQueue
self.config = config
- self._paths = {} # '/path' => ('#channel', auth) where auth is either False (no authentication) or the HTTP header value for basic auth
+ self._paths = {} # '/path' => ('#channel', auth, module) where auth is either False (no authentication) or the HTTP header value for basic auth
self._app = aiohttp.web.Application()
self._app.add_routes([aiohttp.web.post('/{path:.+}', self.post)])
@@ -379,7 +410,7 @@ class WebServer:
self._configChanged = asyncio.Event()
def update_config(self, config):
- self._paths = {map_['webpath']: (map_['ircchannel'], f'Basic {base64.b64encode(map_["auth"].encode("utf-8")).decode("utf-8")}' if map_['auth'] else False) for map_ in config['maps'].values()}
+ self._paths = {map_['webpath']: (map_['ircchannel'], f'Basic {base64.b64encode(map_["auth"].encode("utf-8")).decode("utf-8")}' if map_['auth'] else False, map_['module']) for map_ in config['maps'].values()}
needRebind = self.config['web'] != config['web']
self.config = config
if needRebind:
@@ -400,7 +431,7 @@ class WebServer:
async def post(self, request):
logging.info(f'Received request for {request.path!r}')
try:
- channel, auth = self._paths[request.path]
+ channel, auth, module = self._paths[request.path]
except KeyError:
logging.info(f'Bad request: no path {request.path!r}')
raise aiohttp.web.HTTPNotFound()
@@ -409,6 +440,24 @@ class WebServer:
if not authHeader or authHeader != auth:
logging.info(f'Bad request: authentication failed: {authHeader!r} != {auth}')
raise aiohttp.web.HTTPForbidden()
+ if module is not None:
+ try:
+ message = await module.process(request)
+ except aiohttp.web.HTTPException as e:
+ raise e
+ except Exception as e:
+ logging.error(f'Bad request: exception in module process function: {e!s}')
+ raise aiohttp.web.HTTPBadRequest()
+ if '\r' in message or '\n' in message:
+ logging.error(f'Bad request: module process function returned message with linebreaks: {message!r}')
+ raise aiohttp.web.HTTPBadRequest()
+ else:
+ message = await self._default_process(request)
+ logging.debug(f'Putting message {message!r} for {channel} into message queue')
+ self.messageQueue.put_nowait((channel, message))
+ raise aiohttp.web.HTTPOk()
+
+ async def _default_process(self, request):
try:
message = await request.text()
except Exception as e:
@@ -423,9 +472,7 @@ class WebServer:
if '\r' in message or '\n' in message:
logging.info('Bad request: linebreaks in message')
raise aiohttp.web.HTTPBadRequest()
- logging.debug(f'Putting message {message!r} for {channel} into message queue')
- self.messageQueue.put_nowait((channel, message))
- raise aiohttp.web.HTTPOk()
+ return message
def configure_logging(config):