Skip to content

Captioned images support#249

Open
eebette wants to merge 9 commits into
nim65s:masterfrom
eebette:feat/image-upload
Open

Captioned images support#249
eebette wants to merge 9 commits into
nim65s:masterfrom
eebette:feat/image-upload

Conversation

@eebette

@eebette eebette commented Apr 28, 2026

Copy link
Copy Markdown

What

When payload includes image_url, fetches the URL, uploads to the homeserver media repo, and emits an m.image event with body as the caption. Falls back to m.text of body object when image_url is missing or download fails.

Tests

tests/test_image.py: image-with-caption, no image_url, empty image_url, 404 fallback, DNS-fail fallback. Test PNG generated inline; fixture server runs in a stdlib thread.

Markdown image references in the body that point at http(s) URLs
(`![alt](https://...)`) now cause the bot to fetch the URL, upload to
the homeserver's media repo, and emit a single `m.image` event whose
`url` is the resulting `mxc://` URI. The remaining body — with all
markdown image links stripped — becomes the caption (`body` and
`formatted_body`). The `filename` field carries the URL's basename so
that MSC4193-aware clients render the message as image-with-caption
rather than treating `body` as the file name.

Bodies without image links continue to send as `m.text` events
unchanged. If the fetch or upload fails, the original body is sent as
`m.text` and a warning is logged, so a flaky upstream CDN never drops
the message.

The previous inline `<img src="mxc://...">` approach in m.text
formatted_body rendered correctly on Element Web but was a known
no-op on Element X Android (missing feature, see
element-hq/element-x-android#1874). MSC2530/MSC4193-style captioned
m.image is now the standard way both clients render image-with-text
in a single event.

New module `matrix_webhook/media.py` exposes:
- `upload_from_url(url)` — async fetch + media-repo upload, returns
  the mxc URI plus mimetype, size, and filename.
- `captioned_image_or_text(body)` — returns an m.image event content
  dict if `body` has any markdown image references, else None.

`handler.py` calls `captioned_image_or_text` between body parse and
send; if it returns content, that is what is sent. Otherwise the
existing m.text composition is used. The `formatted_body` escape hatch
is unchanged.

Tests cover four cases: image-with-caption, image-only-no-caption,
no-image-falls-back-to-text, and failed-upload-falls-back-to-text.
Tests use a stdlib threaded HTTPServer fixture to avoid event-loop
deadlocks with the bot's async client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@eebette eebette force-pushed the feat/image-upload branch from 5f42590 to 0ac346e Compare April 28, 2026 07:20
@eebette eebette changed the title Inline image upload via markdown image links Captioned m.image upload via markdown image links Apr 28, 2026
eebette and others added 3 commits April 28, 2026 16:45
When `body` contains a markdown image reference whose URL is empty or
non-http (e.g. `![poster]()` produced by a templating engine like
Jellyseerr's `{{image}}` resolving to empty for events with no
associated media), the fallback m.text path would otherwise emit a
broken `<img>` tag. The new `strip_orphan_image_links` helper drops
those references before the markdown-to-HTML render. http(s) refs are
preserved so the upload-failure fallback path still surfaces the
attempted URL.

The helper only normalizes whitespace if it actually stripped
something — bodies without orphan refs (including formatter outputs
with intentional trailing newlines) pass through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously `upload_from_url` only raised `ValueError` on HTTP-status
failure; transport-level errors (DNS resolution failure, connection
refused, TLS errors) propagated as `aiohttp.ClientError` subclasses
straight to the request handler, crashing it with HTTP 500 and dropping
the entire message — even the text-only fallback `captioned_image_or_text`
was meant to provide.

Wrap the fetch in a `try`/`except aiohttp.ClientError` that re-raises
as `ValueError`. The existing `captioned_image_or_text` handler already
catches `ValueError` and logs a warning + falls back to `m.text`, so any
network-level failure now degrades the same way an HTTP 4xx/5xx already
does.

Adds an integration test against an unreachable hostname
(`http://this-host-does-not-exist.invalid/...`) that confirms the
request returns 200 with the text body intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@eebette eebette changed the title Captioned m.image upload via markdown image links Captioned m.image via explicit image_url field May 16, 2026
@eebette eebette changed the title Captioned m.image via explicit image_url field Captioned images support May 16, 2026
eebette and others added 3 commits May 17, 2026 00:22
Removed detailed description of captioned image support from CHANGELOG.
Updated the section on captioned images to clarify how to send images with captions in messages.
@eebette eebette marked this pull request as ready for review May 16, 2026 15:28
@mergify

mergify Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant