Skip to content

feat(slack): forward inbound files and images to the agent - #7812

Merged
houko merged 3 commits into
mainfrom
feat/7087-slack-inbound-attachments
Aug 23, 2026
Merged

houko merged 3 commits into
mainfrom
feat/7087-slack-inbound-attachments

Conversation

@houko

@houko houko commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Closes #7087

The Slack sidecar was text-only on the inbound side. A message carrying an upload arrives with subtype file_share, and the subtype filter in parse_slack_event dropped every subtype other than message_changed, so the user's image or clip was discarded before any content parsing ran.

Changes

  • sdk/python/librefang/sidecar/adapters/slack.py

    • file_share now shares the bare-message arm; every other subtype is still dropped.
    • New parse_slack_files / _file_content map a message's files array onto Content.image / Content.video / Content.audio / Content.file, keyed on mimetype, carrying url_private_download and using the message text as the caption. duration_ms becomes duration_seconds for video / audio.
    • One attachment per message (the wire carries one ChannelContent), extras counted in a warning; the attachment outranks the message text including a slash command. Both match the discord sidecar's precedent.
    • An upload with no comment is now a complete message: the empty-text drop only applies when there is no eligible attachment.
    • SlackFilePolicy holds the gate: SLACK_FILE_DOWNLOADS (bool, default true), SLACK_FILE_MAX_BYTES (default 10 MiB, the same cap as the outbound upload path), SLACK_FILE_ALLOWED_EXTENSIONS (empty = allow all, matching the SLACK_ALLOWED_CHANNELS convention already in this file), and the SLACK_FILE_DOWNLOAD_CHANNELS allow-list / SLACK_FILE_DOWNLOAD_EXCLUDE_CHANNELS deny-list pair. A non-integer SLACK_FILE_MAX_BYTES exits 2, following the TWITCH_RATE_LIMIT_* precedent; a value below 1 falls back to the default with a warning.
    • Files whose mode is tombstone or hidden_by_limit are refused — their url_private no longer serves bytes, and letting the daemon try only puts a [File download failed] line in the prompt.
    • All five knobs are declared as SCHEMA fields so the dashboard renders them.
  • Credential handling. url_private_download 302s to a login page without the bot token, so the daemon needs it. The adapter declares header_rules for Slack's own file hosts (files.slack.com, slack-files.com); the daemon's fetch_headers_for (crates/librefang-channels/src/sidecar.rs) exact-matches the request host against those rules and attaches nothing for any other host. _is_slack_file_url applies the same pin adapter-side, refusing non-HTTPS URLs and URLs carrying userinfo (https://files.slack.com@evil.example/x resolves to evil.example), so a file registered through files.remote.add with a member-chosen url_private is never forwarded at all. The rules are only declared when forwarding is enabled, so SLACK_FILE_DOWNLOADS=false does not ship the token to the daemon.

  • Why the URL and not the bytes. download_media_blocks in crates/librefang-channels/src/bridge.rs is what turns an inbound Image / File / Voice / Audio / Video URL into a vision image block, an audio transcription or a saved document path. Its match has no FileData arm (_ => None at bridge.rs:5873), and the inbound text arm renders FileData as [User sent a local file: …] (bridge.rs:4412), so an adapter that inlines inbound bytes delivers a placeholder and discards the payload. Forwarding the URL with a host-pinned auth rule is the mechanism the matrix sidecar already uses for MSC3916 media, and it needs no kernel change.

  • Deliberately not followed: link-unfurl attachments[].image_url. That is preview metadata for a URL somebody pasted, not an upload, and following it would point the daemon at an arbitrary host. Noted in the module docstring.

  • Docs: architecture note on header_rules and why inbound FileData is a dead end (docs/architecture/sidecar-channels.md); a "User Uploads" section, the files:read scope and the five env vars in the en and zh channel pages.

  • Changelog fragment: changelog.d/added/7087-slack-inbound-attachments.md.

Verification

cd sdk/python && python3 -m pytest tests -q — 2044 passed (was 2016 before this branch; 28 new cases). This is the exact invocation of CI's Python SDK Tests job (.github/workflows/ci.yml, working-directory: sdk/python, python -m pytest tests -q).

cd sdk/python && python3 -m pytest tests/test_slack_adapter.py -q — 185 passed.

New tests in sdk/python/tests/test_slack_adapter.py, all against the pure parse_slack_event / parse_slack_files entry points:

  • test_parse_event_file_share_emits_image_content — a file_share event produces {"Image": {url, caption, mime_type}} with routing metadata unchanged.
  • test_parse_event_file_share_video_and_audio_and_document_variants — mimetype dispatch and duration_ms → duration_seconds.
  • test_parse_event_file_share_oversize_is_rejected, test_parse_event_file_share_oversize_keeps_the_companion_text — the size cap, and proof a rejected attachment does not swallow the text the user typed with it.
  • test_parse_event_file_share_disallowed_extension_is_rejected, ..._allowed_extension_passes, ..._extension_falls_back_to_filetype.
  • test_parse_event_file_share_download_switch_off_drops_the_file, ..._per_channel_exclude_list, ..._per_channel_allow_list.
  • test_inbound_attachment_parsing_performs_no_http — replaces _public_http_request and _http_request with a function that raises, then parses with the switch on and off: the adapter never fetches an inbound file itself.
  • test_parse_event_file_share_rejects_non_slack_host — an attacker host, a suffix lookalike (files.slack.com.evil.example), the userinfo trick and plain HTTP are all refused.
  • test_header_rules_pin_the_bot_token_to_slack_file_hosts, test_header_rules_absent_when_downloads_are_disabled (asserts the token string appears nowhere in the ready event), test_header_rules_surface_in_the_ready_event, test_is_slack_file_url_host_pinning — the token is declared only for Slack file hosts.
  • test_parse_event_file_share_dropped_without_a_policy, test_parse_event_other_subtypes_are_still_dropped, test_parse_event_file_share_still_honours_self_skip_and_channel_filter — no behaviour change for callers that pass no policy, and the existing filters still apply.
  • test_file_policy_defaults, test_file_policy_env_parsing, test_file_max_bytes_non_integer_exits_2, test_file_max_bytes_below_one_falls_back_to_default.

No cargo command was run: this branch touches no Rust source.

Left out

  • Multi-file uploads deliver one attachment. The wire protocol carries one ChannelContent per message; Content.media_group exists but the inbound bridge has no MediaGroup arm in download_media_blocks, so it would land as a [Media group: N items] placeholder. Extras are logged rather than silently dropped.
  • A non-media file loses its companion text. ChannelContent::File has no caption field, so Content.file cannot carry the message. Warned at the boundary, same as the discord sidecar. Giving File a caption is a change to crates/librefang-channels/src/types.rs and every adapter that constructs it — a different crate, so it is not folded in here.
  • Attachments with an unknown size (external files sometimes omit it) are accepted; the daemon's own channels_download_max_bytes still bounds the fetch.

houko added 2 commits August 23, 2026 22:00
A message carrying an upload arrived with subtype `file_share`, which the
subtype filter dropped outright, so the user's image or clip never reached
the agent and the only workaround was to host the file elsewhere and paste
a URL.

`file_share` now shares the bare-message arm and a message's `files` array
becomes an `Image` / `Video` / `Audio` / `File` content variant carrying
`url_private_download`. The adapter declares `header_rules` for Slack's
own file hosts so the daemon can fetch that URL with the bot token, which
Slack requires; the daemon exact-matches the request host against those
rules, and the adapter refuses any file URL that is not on one of the same
hosts, so a remote file registered by a workspace member cannot pull the
token to an address it chose.

The URL is forwarded rather than the bytes on purpose: `download_media_blocks`
is what turns an inbound URL into a vision image block, an audio
transcription or a saved document path, and its match has no `FileData`
arm, so inlining bytes would deliver a text placeholder and nothing else.

Gated by `SLACK_FILE_DOWNLOADS`, `SLACK_FILE_MAX_BYTES`,
`SLACK_FILE_ALLOWED_EXTENSIONS` and the
`SLACK_FILE_DOWNLOAD_CHANNELS` / `SLACK_FILE_DOWNLOAD_EXCLUDE_CHANNELS`
pair. With forwarding switched off the token is not declared at all.

Refs #7087
The trailing `(#N)` group is what suppresses the auto-generated release-note line, so it has to name the PR, not only the issue.
@github-actions github-actions Bot added area/docs Documentation and guides area/sdk JavaScript and Python SDKs labels Aug 23, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying librefang-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 2134610
Status: ✅  Deploy successful!
Preview URL: https://902e7ced.librefang-docs.pages.dev
Branch Preview URL: https://feat-7087-slack-inbound-atta.librefang-docs.pages.dev

View logs

@github-actions github-actions Bot added size/L 250-999 lines changed ready-for-review PR is ready for maintainer review labels Aug 23, 2026
CLAUDE.md's prose-wrapping rule bars hard-wrapping doc-comments at a
column count; the docstrings and comments this PR added for inbound
attachments were wrapped at ~78 columns across sentence boundaries
instead. No code changes, comment/docstring text only.

@houko houko left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against CLAUDE.md. Solid, well-tested change (185/185 pytest, security design checked against the actual Rust fetch_headers_for/download_media_blocks it references). Pushed one mechanical fix: the new docstrings/comments were hard-wrapped at ~78 columns across sentence boundaries, which CLAUDE.md's prose-wrapping rule forbids — reflowed to one sentence per line, no logic changes. One minor test-coverage nit left inline.


Generated by Claude Code

return False
if parsed.scheme != "https" or not host:
return False
if parsed.username is not None or parsed.password is not None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the docstring on _is_slack_file_url calls out https://files.slack.com@evil.example/x by name as the reason for this userinfo check, but test_is_slack_file_url_host_pinning doesn't exercise that exact string — it only asserts the plain-host rejection cases. Worth adding one assertion for the userinfo form so the test suite actually covers the attack the comment documents (even though, as far as I can tell, the plain host in SLACK_FILE_HOSTS check would already reject it since urlsplit(...).hostname resolves to evil.example there — so this looks like a coverage gap rather than a live bug).


Generated by Claude Code

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

Labels

area/docs Documentation and guides area/sdk JavaScript and Python SDKs ready-for-review PR is ready for maintainer review size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Slack adapter: inbound file/attachment support (user → agent)

1 participant