Bladeren bron

Add support for transformation/translation modules that do arbitrary request processing to generate the message

Fixes #3
master
JustAnotherArchivist 4 jaren geleden
bovenliggende
commit
a91e61b84c
2 gewijzigde bestanden met toevoegingen van 56 en 7 verwijderingen
  1. +2
    -0
      config.example.toml
  2. +54
    -7
      http2irc.py

+ 2
- 0
config.example.toml Bestand weergeven

@@ -22,3 +22,5 @@
#ircchannel = '#spam' #ircchannel = '#spam'
# auth can be either 'user:pass' for basic authentication or false to disable auth # auth can be either 'user:pass' for basic authentication or false to disable auth
#auth = false #auth = false
# module is the path to a Python source file that handles the message transformation. It must contain a coroutine function 'process' that takes one argument, the aiohttp.web.Request object, and returns the message string to be sent to IRC, which must not contain any linebreaks (CR or LF). It may raise an aiohttp.web.HTTPException to stop processing; any other exception will also cause the processing to be stopped and a '400 Bad Request' response to be returned to the client. The empty default value (None) causes the default processor to be used, which expects a straight message in the request body (with an optional trailing CR+LF or LF) and performs no transformations other than encoding the message as UTF-8.
#module =

+ 54
- 7
http2irc.py Bestand weergeven

@@ -4,6 +4,8 @@ import asyncio
import base64 import base64
import collections import collections
import concurrent.futures import concurrent.futures
import importlib.util
import inspect
import logging import logging
import os.path import os.path
import signal import signal
@@ -114,9 +116,11 @@ class Config(dict):
raise InvalidConfig(f'Invalid map key {key!r}') raise InvalidConfig(f'Invalid map key {key!r}')
if not isinstance(map_, collections.abc.Mapping): if not isinstance(map_, collections.abc.Mapping):
raise InvalidConfig(f'Invalid map for {key!r}') 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}') raise InvalidConfig(f'Unknown key(s) found in map {key!r}')
#TODO: Check values #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 # 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': {}} 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}' map_['ircchannel'] = f'#{key}'
if 'auth' not in map_: if 'auth' not in map_:
map_['auth'] = False 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 # Merge in what was read from the config file and set keys on self
for key in ('logging', 'irc', 'web', 'maps'): for key in ('logging', 'irc', 'web', 'maps'):
@@ -370,7 +401,7 @@ class WebServer:
self.messageQueue = messageQueue self.messageQueue = messageQueue
self.config = config 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 = aiohttp.web.Application()
self._app.add_routes([aiohttp.web.post('/{path:.+}', self.post)]) self._app.add_routes([aiohttp.web.post('/{path:.+}', self.post)])
@@ -379,7 +410,7 @@ class WebServer:
self._configChanged = asyncio.Event() self._configChanged = asyncio.Event()


def update_config(self, config): 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'] needRebind = self.config['web'] != config['web']
self.config = config self.config = config
if needRebind: if needRebind:
@@ -400,7 +431,7 @@ class WebServer:
async def post(self, request): async def post(self, request):
logging.info(f'Received request for {request.path!r}') logging.info(f'Received request for {request.path!r}')
try: try:
channel, auth = self._paths[request.path]
channel, auth, module = self._paths[request.path]
except KeyError: except KeyError:
logging.info(f'Bad request: no path {request.path!r}') logging.info(f'Bad request: no path {request.path!r}')
raise aiohttp.web.HTTPNotFound() raise aiohttp.web.HTTPNotFound()
@@ -409,6 +440,24 @@ class WebServer:
if not authHeader or authHeader != auth: if not authHeader or authHeader != auth:
logging.info(f'Bad request: authentication failed: {authHeader!r} != {auth}') logging.info(f'Bad request: authentication failed: {authHeader!r} != {auth}')
raise aiohttp.web.HTTPForbidden() 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: try:
message = await request.text() message = await request.text()
except Exception as e: except Exception as e:
@@ -423,9 +472,7 @@ class WebServer:
if '\r' in message or '\n' in message: if '\r' in message or '\n' in message:
logging.info('Bad request: linebreaks in message') logging.info('Bad request: linebreaks in message')
raise aiohttp.web.HTTPBadRequest() 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): def configure_logging(config):


Laden…
Annuleren
Opslaan