Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 15 additions & 11 deletions matrix_webhook/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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)
84 changes: 84 additions & 0 deletions matrix_webhook/media.py
Original file line number Diff line number Diff line change
@@ -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"]),
}
208 changes: 208 additions & 0 deletions tests/test_image.py
Original file line number Diff line number Diff line change
@@ -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("<strong>Title</strong>", 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)