diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ce298d..0a47a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 in [#169](https://github.com/nim65s/matrix-webhook/pull/243) by [@nim65s](https://github.com/nim65s) - setup mergify +- captioned image support ## [v3.9.1] - 2024-03-09 diff --git a/README.md b/README.md index 65afe39..53f7544 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,13 @@ curl -d '{"body":"new contrib from toto: [44](http://radio.localhost/map/#44)", (or localhost:4785 without docker) +### Captioned images + +Supports sending images as messages by including an `image_url` field in the payload along with `body`. When `image_url` is detected in the payload, the message will be sent as an image type with the `body` field included as the image caption. + +Requests without `image_url` continue to send as `m.text` events. +If the fetch or upload fails, `body` is sent as `m.text` and a warning is logged. + ### For Github Add a JSON webhook with `?formatter=github`, and put the `API_KEY` as secret diff --git a/matrix_webhook/handler.py b/matrix_webhook/handler.py index ed40449..377a4bd 100644 --- a/matrix_webhook/handler.py +++ b/matrix_webhook/handler.py @@ -7,7 +7,7 @@ from markdown import markdown -from . import conf, formatters, utils +from . import conf, formatters, media, utils LOGGER = logging.getLogger("matrix_webhook.handler") @@ -80,20 +80,24 @@ async def matrix_webhook(request): if data["key"] != conf.API_KEY: return utils.create_json_response(HTTPStatus.UNAUTHORIZED, "Invalid API key") - if "formatted_body" in data: - formatted_body = data["formatted_body"] - else: - formatted_body = markdown(str(data["body"]), extensions=["extra"]) + body = str(data["body"]) + formatted_body = data.get("formatted_body") + image_url = data.get("image_url") + + content = None + if image_url: + content = await media.captioned_image(image_url, body, formatted_body) + if content is None: + content = { + "msgtype": "m.text", + "body": body, + "format": "org.matrix.custom.html", + "formatted_body": formatted_body or markdown(body, extensions=["extra"]), + } # try to join room first -> non none response means error resp = await utils.join_room(data["room_id"]) if resp is not None: return resp - content = { - "msgtype": "m.text", - "body": data["body"], - "format": "org.matrix.custom.html", - "formatted_body": formatted_body, - } return await utils.send_room_message(data["room_id"], content) diff --git a/matrix_webhook/media.py b/matrix_webhook/media.py new file mode 100644 index 0000000..f325fa6 --- /dev/null +++ b/matrix_webhook/media.py @@ -0,0 +1,84 @@ +"""Matrix Webhook media upload helpers.""" + +import logging +from http import HTTPStatus +from pathlib import PurePosixPath +from urllib.parse import urlparse + +import aiohttp +from markdown import markdown +from nio.responses import UploadError + +from . import utils + +LOGGER = logging.getLogger("matrix_webhook.media") + + +async def upload_from_url(url): + """Fetch ``url`` and upload it to the homeserver media repo. + + Returns ``(mxc_uri, mimetype, size, filename)``. Raises ``ValueError`` + if either the fetch or the upload fails. + """ + msg = f"Fetching image from {url=}" + LOGGER.debug(msg) + + try: + async with aiohttp.ClientSession() as session, session.get(url) as resp: + if resp.status != HTTPStatus.OK: + msg = f"Failed to fetch {url}: HTTP {resp.status}" + raise ValueError(msg) + content_type = resp.headers.get( + "Content-Type", + "application/octet-stream", + ) + image_bytes = await resp.read() + except aiohttp.ClientError as e: + msg = f"Failed to fetch {url}: {e!r}" + raise ValueError(msg) from e + + filename = PurePosixPath(urlparse(url).path).name or "image" + + msg = f"Uploading {len(image_bytes)} bytes as {filename=} ({content_type=})" + LOGGER.debug(msg) + + upload_resp, _ = await utils.CLIENT.upload( + lambda got_429, got_timeouts: image_bytes, + content_type=content_type, + filename=filename, + filesize=len(image_bytes), + ) + + if isinstance(upload_resp, UploadError): + msg = f"Failed to upload {url}: {upload_resp.message}" + raise ValueError(msg) + + return upload_resp.content_uri, content_type, len(image_bytes), filename + + +async def captioned_image(image_url, body, formatted_body=None): + """Build an ``m.image`` event content from an explicit URL + caption. + + Fetches and uploads ``image_url`` to the homeserver media repo and + returns an ``m.image`` content dict with the resulting ``mxc://`` URI. + ``body`` is used as the caption (plain text) and rendered as HTML for + ``formatted_body``, unless an explicit ``formatted_body`` is supplied. + + Returns ``None`` if the upload fails; the caller falls back to ``m.text``. + """ + try: + mxc, mimetype, size, filename = await upload_from_url(image_url) + except ValueError as e: + msg = f"Image upload skipped, falling back to text: {e}" + LOGGER.warning(msg) + return None + + return { + "msgtype": "m.image", + "url": mxc, + "filename": filename, + "info": {"mimetype": mimetype, "size": size}, + "body": body, + "format": "org.matrix.custom.html", + "formatted_body": formatted_body or markdown(body, extensions=["extra"]), + } diff --git a/tests/test_image.py b/tests/test_image.py new file mode 100644 index 0000000..3166292 --- /dev/null +++ b/tests/test_image.py @@ -0,0 +1,208 @@ +"""Test module for the captioned ``m.image`` upload feature.""" + +import struct +import threading +import unittest +import zlib +from http.server import BaseHTTPRequestHandler, HTTPServer + +import httpx +import nio + +from .start import BOT_URL, FULL_ID, KEY, MATRIX_ID, MATRIX_PW, MATRIX_URL + + +def _tiny_png(): + """Generate a minimal valid 1x1 RGBA PNG, no external fixture needed.""" + + def chunk(name, data): + return ( + struct.pack(">I", len(data)) + + name + + data + + struct.pack(">I", zlib.crc32(name + data)) + ) + + ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 6, 0, 0, 0) + idat = zlib.compress(b"\x00\x00\x00\x00\x00") # filter + transparent pixel + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", idat) + + chunk(b"IEND", b"") + ) + + +PNG_BYTES = _tiny_png() +FIXTURE_PORT = 4786 +FIXTURE_URL = f"http://localhost:{FIXTURE_PORT}/poster.png" + + +class _FixtureHandler(BaseHTTPRequestHandler): + """Serve PNG_BYTES at /poster.png; 404 elsewhere.""" + + def do_GET(self): + """Handle the GET request.""" + if self.path == "/poster.png": + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.send_header("Content-Length", str(len(PNG_BYTES))) + self.end_headers() + self.wfile.write(PNG_BYTES) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): # noqa: A002 + """Silence the default access log.""" + + +class CaptionedImageTest(unittest.IsolatedAsyncioTestCase): + """Verify explicit ``image_url`` produces captioned ``m.image`` events.""" + + @classmethod + def setUpClass(cls): + """Start a threaded HTTP fixture server for the whole test class.""" + cls.server = HTTPServer(("localhost", FIXTURE_PORT), _FixtureHandler) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + + @classmethod + def tearDownClass(cls): + """Shut down the fixture server.""" + cls.server.shutdown() + cls.server.server_close() + cls.thread.join(timeout=2) + + async def test_image_with_caption(self): + """Body + image_url -> m.image with body as caption.""" + body = "**Title**\n\nDescription text." + client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) + + await client.login(MATRIX_PW) + room = await client.room_create() + + self.assertEqual( + httpx.post( + f"{BOT_URL}/{room.room_id}", + json={"body": body, "image_url": FIXTURE_URL, "key": KEY}, + ).json(), + {"status": 200, "ret": "OK"}, + ) + + sync = await client.sync() + messages = await client.room_messages(room.room_id, sync.next_batch) + await client.close() + + msg = messages.chunk[0] + self.assertEqual(msg.sender, FULL_ID) + self.assertIsInstance(msg, nio.RoomMessageImage) + self.assertTrue(msg.url.startswith("mxc://")) + self.assertEqual(msg.body, body) + src = msg.source["content"] + self.assertEqual(src["filename"], "poster.png") + self.assertEqual(src["info"]["mimetype"], "image/png") + self.assertEqual(src["info"]["size"], len(PNG_BYTES)) + self.assertIn("Title", src["formatted_body"]) + + async def test_no_image_url_sends_text(self): + """No image_url -> m.text.""" + body = "Plain text, no image." + client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) + + await client.login(MATRIX_PW) + room = await client.room_create() + + self.assertEqual( + httpx.post( + f"{BOT_URL}/{room.room_id}", + json={"body": body, "key": KEY}, + ).json(), + {"status": 200, "ret": "OK"}, + ) + + sync = await client.sync() + messages = await client.room_messages(room.room_id, sync.next_batch) + await client.close() + + msg = messages.chunk[0] + self.assertIsInstance(msg, nio.RoomMessageText) + self.assertEqual(msg.body, body) + + async def test_empty_image_url_sends_text(self): + """Empty image_url string -> m.text.""" + body = "Plain text, no image." + client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) + + await client.login(MATRIX_PW) + room = await client.room_create() + + self.assertEqual( + httpx.post( + f"{BOT_URL}/{room.room_id}", + json={"body": body, "image_url": "", "key": KEY}, + ).json(), + {"status": 200, "ret": "OK"}, + ) + + sync = await client.sync() + messages = await client.room_messages(room.room_id, sync.next_batch) + await client.close() + + msg = messages.chunk[0] + self.assertIsInstance(msg, nio.RoomMessageText) + self.assertEqual(msg.body, body) + + async def test_failed_image_falls_back_to_text(self): + """If the upload fails, body is sent as m.text without the URL.""" + bad_url = f"http://localhost:{FIXTURE_PORT}/missing.png" + body = "caption text" + client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) + + await client.login(MATRIX_PW) + room = await client.room_create() + + self.assertEqual( + httpx.post( + f"{BOT_URL}/{room.room_id}", + json={"body": body, "image_url": bad_url, "key": KEY}, + ).json(), + {"status": 200, "ret": "OK"}, + ) + + sync = await client.sync() + messages = await client.room_messages(room.room_id, sync.next_batch) + await client.close() + + msg = messages.chunk[0] + self.assertIsInstance(msg, nio.RoomMessageText) + self.assertEqual(msg.body, body) + self.assertNotIn(bad_url, msg.body) + for event in messages.chunk: + self.assertNotIsInstance(event, nio.RoomMessageImage) + + async def test_unreachable_image_host_falls_back_to_text(self): + """An image_url whose host doesn't resolve must NOT crash the request.""" + bad_url = "http://this-host-does-not-exist.invalid/poster.png" + body = "caption text" + client = nio.AsyncClient(MATRIX_URL, MATRIX_ID) + + await client.login(MATRIX_PW) + room = await client.room_create() + + self.assertEqual( + httpx.post( + f"{BOT_URL}/{room.room_id}", + json={"body": body, "image_url": bad_url, "key": KEY}, + ).json(), + {"status": 200, "ret": "OK"}, + ) + + sync = await client.sync() + messages = await client.room_messages(room.room_id, sync.next_batch) + await client.close() + + msg = messages.chunk[0] + self.assertIsInstance(msg, nio.RoomMessageText) + self.assertEqual(msg.body, body) + self.assertNotIn(bad_url, msg.body)