Skip to content

feat(attachments): full attachment support — any file type, multiple per message (CHOO-1802) - #93

Merged
amaudruz merged 3 commits into
mainfrom
feature-request/full-attachment-support
Jul 30, 2026
Merged

feat(attachments): full attachment support — any file type, multiple per message (CHOO-1802)#93
amaudruz merged 3 commits into
mainfrom
feature-request/full-attachment-support

Conversation

@amaudruz

@amaudruz amaudruz commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Closes the gap described in CHOO-1802: attachments were effectively images-only, one at a time.

What was actually broken

Mapping the pipeline first paid off — most of the plumbing was already type-agnostic. The blocks were at the edges:

  • Inbound: three literal if not mimetype.startswith("image/"): continue guards (Slack, Mattermost, Discord) that dropped non-image files to a debug log — a silent drop, and a direct violation of the repo's fail-loud rule.
  • Outbound: a single if msgtype != "m.image" branch in bridge_core that posted _sent a file that isn't relayed yet_ instead of uploading. The else branch below it already handled any mimetype.
  • Multiple attachments: the whole chain was one-file-per-call, and N inbound files became N separate messages.
  • A real hole: the 20MB cap was enforced on the agent-upload and bridge-relay paths but not inbound at all.

Changes

Any file type, both directions

  • Removed the three inbound image guards; removed the outbound m.image special case.
  • Widened the channel tool's extension→mimetype map so type fidelity survives the round trip.

Multiple attachments per message

Matrix has no multi-attachment event — one media event carries one mxc, and MSC4274 / MSC2881 are unmerged. So a message with n files is sent as n events sharing a group marker (com.switch.attachment_group = {id, index, total}) and coalesced at both edges:

  • bridge_core stamps the group inbound (a platform post hands us all its files at once, so the count is known up front — no guessing) and buffers outbound groups into one platform post via a new adapter send_attachments (Slack file_uploads, Mattermost file_ids — both native).
  • agent_client buffers parts and emits a single payload carrying all attachments.
  • send_attachment accepts paths; the media endpoint accepts repeated files parts.

An event with no marker is a group of one, so this is backward compatible with media already in rooms.

Fail loud, never fake

  • Inbound size cap enforced (checked pre-download against the platform-reported size where available, and again post-download).
  • Oversize / failed files are carried as AttachmentFailure and disclosed in the room, never dropped.
  • A multi-file send validates every file before sending any — a bad file fails the whole call rather than half-posting a message.
  • An incomplete group flushes after a timeout with an explicit "n of m" notice rather than hanging or vanishing.
  • Non-image files reach the agent as file_path; unretrievable ones as failed_attachments.

Platform coverage — stated plainly

  • Slack — implemented and verified (the reported case), including a genuine single multi-file post.
  • Mattermost — implemented, including native multi-file posts. Not manually tested against a live server.
  • Discord — inbound parity only (guard removed, failures disclosed).
  • Teams — unchanged; it has no inbound byte path at all.

Testing

644 passed, 0 failed; ruff + mypy clean. (141 errors in a local run are the pre-existing Postgres-dependent store tests — no DB in that environment.)

New coverage: parse_attachment_group edge cases, inbound group stamping + failure disclosure, outbound batching, and agent-side coalescing (in-order, out-of-order, ungrouped, and incomplete-with-timeout, asserting no buffer/timer leak).

Writing the tests surfaced three real defects in the first pass, fixed in 6548217 — each with a regression test verified to fail against the prior code:

  1. bool is a subclass of int, so {"index": false} parsed as index 0 instead of being rejected.
  2. The flush timer was re-armed per part, so the timeout bounded the gap between parts, not the group — a slow dribble could hold the buffer open indefinitely. Now armed once per group.
  3. Coalesced payloads were anchored on whichever part arrived last, making message_id unstable. Both paths now anchor on part 0.

Notes

  • No migration needed; alembic heads prints exactly one head (b3f36489c258).
  • SKILL.md updated for the changed agent-facing contract; plugin version bumped 0.2.0 → 0.3.0 per repo policy.

🤖 Generated with Claude Code

switch-worker-louis-remote added 3 commits July 30, 2026 07:05
Attachments were effectively images-only and one file per message.

Any file type, both directions:
- Inbound: drop the `image/` guards in the Slack, Mattermost and Discord
  adapters so every file type is downloaded. Everything downstream
  (bridge_core, client_base, read_context, download_media) was already
  mimetype-agnostic.
- Outbound: remove the `m.image` branch in bridge_core; `m.file` now
  takes the same native upload path instead of degrading to a
  "sent a file that isn't relayed yet" notice.
- Widen the channel tool's extension→mimetype map so type fidelity
  survives the round trip.

Multiple attachments per message. Matrix has no multi-attachment event —
one media event carries one mxc, and MSC4274 / MSC2881 are unmerged — so
a message with n files is sent as n events sharing a group marker
(`com.switch.attachment_group`) and coalesced back at both edges:
- bridge_core stamps the group inbound (a platform post hands us all its
  files at once, so the count is known up front) and buffers outbound
  groups to relay them as ONE platform post, via a new adapter
  `send_attachments` (Slack `file_uploads`, Mattermost `file_ids`).
- agent_client buffers group parts and emits a single MessagePayload
  carrying all attachments.
- `send_attachment` accepts `paths`, and the media endpoint accepts
  repeated `files` parts.
An event with no marker is simply a group of one, so this is backward
compatible with media already in a room.

Fail loud, never fake:
- Enforce the size cap inbound (it was only checked on the agent upload
  and bridge relay paths), plumbed to adapters from
  `agent_media_max_bytes`.
- A file that is oversize or fails to download is carried as an
  `AttachmentFailure` and disclosed in the room, never dropped.
- Multi-file sends validate every file before sending any, so a bad file
  fails the whole call rather than half-posting a message.
- An incomplete group is flushed after a timeout with an explicit
  "n of m" notice rather than being held forever or dropped.
- Non-image attachments reach the agent as `file_path`, and ones that
  could not be fetched as `failed_attachments`.
Update the agent-facing contract in SKILL.md: any file type in both
directions, several files per message, the new file_path /
failed_attachments notification fields, paths on send_attachment, and
the all-or-nothing validation on a multi-file send. Bump the plugin
version so installs pick it up.
Add coverage for parse_attachment_group, the inbound group stamping and
failure disclosure in bridge_core, and the agent_client coalescing
buffer. Writing it surfaced three real defects, fixed here:

- bool is a subclass of int, so a {"index": false} marker parsed as
  index 0 instead of being rejected as malformed.
- The flush timer was re-armed on every arriving part, making the
  timeout bound the gap between parts rather than the group; a batch
  dribbling in just under the deadline could hold the buffer open
  indefinitely. It is now armed once per group.
- A coalesced payload was anchored on whichever part arrived last rather
  than on part 0, so message_id was unstable and replies could thread
  off a non-canonical event. Both the completed and timed-out paths now
  anchor on part 0.

Each fix has a regression test verified to fail against the prior code.
@github-actions

Copy link
Copy Markdown

Thank you for your contribution! Before we can merge it, please read our Contributor License Agreement and sign it by posting the following comment on this pull request:


I have read the CLA Document and I hereby sign the CLA


switch-worker-louis-remote seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@amaudruz
amaudruz merged commit bf61807 into main Jul 30, 2026
10 of 11 checks passed
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