Discord's CDN edges occasionally advertise `content-encoding: gzip` (or
deflate/brotli) on PNG/JPEG passthroughs while the body is raw,
uncompressed image bytes. With the default `reqwest::Client::new()` and
the workspace's gzip/deflate/brotli features all enabled, reqwest's
transparent-decompression layer chokes on the PNG/JPEG header and
returns "error decoding response body" only on `bytes().await` (not on
`send()`), causing `download_image_to_blocks` to silently fall back to a
text-only block — the user's image never reaches the model.
Build the client explicitly with no_gzip/no_deflate/no_brotli so the
request advertises identity encoding and the body is read raw. Also set
a User-Agent (some CDN edges 403 clients without one) and a 30s timeout
aligned with the upstream 5 MB cap.
Repro: send an image attachment via Discord; the daemon logs
`Failed to read image bytes: error decoding response body` and the turn
appends as text-only with `appended_has_image=false`. After this fix the
PNG bytes are read and emitted as an Image content block as intended.
Discord MESSAGE_CREATE payloads with attachments were previously parsed
in a way that either dropped the attachment (when text was present, only
the text was kept) or dropped the whole message (when text was empty,
the early `content.is_empty()` return killed bare-image posts). The
result on text-only providers like claude-code: silent drops, then
hallucinated acknowledgements of content the model never saw.
This rewires the inbound path end-to-end:
* types: add ChannelContent::Multipart(Vec<ChannelContent>) so a single
inbound message can carry a caption + one or more attachments as
sibling blocks. Doc forbids nesting; consumers debug_assert.
* discord: classify attachments by MIME (with extension fallback for
bot-relayed payloads that omit content_type) and a 5 MB vision-size
cap matching Anthropic's image block limit. Vision-eligible images
become ChannelContent::Image; everything else becomes File. Emit
Multipart whenever text and attachments coexist, or when there are
multiple attachments.
* bridge: flat-map Multipart in both dispatch paths — into Vec<ContentBlock>
for multimodal-capable providers, and into a newline-joined text
descriptor for text-flatten providers.
* telegram: add the Multipart arm to send_to_user for exhaustive-match
parity; flattens defensively.
* claude_code driver: render Image blocks as
"[attachment: <mime> image, ~N KB — not viewable on this provider]"
instead of dropping them. The model still cannot see the image, but
it can acknowledge it coherently rather than confabulating.
Adds 9 discord parser tests covering all (text, attachment-count) shapes
plus MIME edge cases, and 2 claude_code driver tests covering captioned
and bare-image rendering.
Adds a single tracing::debug! at the top of parse_discord_message that
dumps the full payload JSON. Silent at default `info` level; enable with
`RUST_LOG=openfang_channels::discord=debug` to capture real attachment
JSON when developing the file-passing parse code.
Logs before any filters (bot, allowed_users, allowed_guilds, empty
content) so attachment-only messages are visible too.
Pick 3a-bis of the Discord file-passing plan: teach the multimodal
image fetcher to handle file:// URLs by reading from local disk
instead of going through reqwest. PR-A (Discord inbound) will
materialize attachments to a shared inbox dir and emit
ChannelContent::Image { url: "file://..." }, so this branch is what
unblocks vision on inbox-materialized images after the Discord CDN
URL has expired.
Implementation:
- Branch on url.strip_prefix("file://"); local read uses tokio::fs::read.
- HTTP path unchanged. Both paths converge on (Vec<u8>, Option<String>)
before the existing 5MB cap, magic-byte sniffing, and base64 path.
- No content-type header on file:// — magic-byte detection and URL
extension fallback do all the media-type work, which is fine since
detect_image_magic and media_type_from_url already exist.
- No new deps. Vec<u8> instead of bytes::Bytes to avoid pulling in
the bytes crate as a direct dep.
- No URL percent-decoding: the inbox writer (PR-A) controls filenames
and avoids characters that would need encoding.
Refs: projects/openfang-fork/discord-file-passing-plan.md (step 2)
Pick 3a of the Discord file-passing plan: extend the URL-flavored File
variant with optional mime and size metadata so adapters can pass
attachment context through to bridges. FileData (bytes-flavored) is
unchanged; size is implicit in data.len() and mime_type already exists.
Match-arm sites in bridge.rs, telegram.rs, whatsapp.rs use `..` to stay
forward-compatible. Construction sites in telegram.rs and kernel.rs
pass `mime: None, size: None` for now; Discord inbound (PR-A) will
populate them.
Refs: projects/openfang-fork/discord-file-passing-plan.md
Replaces "Spec §5.5 scoped strict-field validation to bindings" with
self-contained wording. The §5.5 reference points to an internal-fork
spec document that means nothing to upstream readers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A typo in any binding's match_rule no longer drops the entire bindings
table. Each entry is parsed independently; malformed entries log an
ERROR with index, agent name, and the underlying serde error, then are
skipped. A single WARN summarizes total dropped vs. surviving bindings.
Per-entry deny_unknown_fields is preserved so silent typos still fail
loudly — just no longer catastrophically.
Before this change, a single misspelled field anywhere in [[bindings]]
caused the whole table to fail parsing, silently unbinding every
agent — the worst possible failure mode for a routing config.
- New `lenient_extract_bindings` runs after include-merge / [api]
migration, before `try_into::<KernelConfig>()`.
- 7 new config tests cover the reproducer, happy path, all-malformed,
no-bindings, missing-agent, survivor-order preservation, and
top-level field typos:
* test_lenient_bindings_drops_typo_keeps_rest
* test_lenient_bindings_all_valid_unchanged
* test_lenient_bindings_all_malformed_yields_empty_but_keeps_rest_of_config
* test_lenient_bindings_no_bindings_section_is_noop
* test_lenient_bindings_missing_agent_field_dropped
* test_lenient_bindings_preserves_survivor_order — locks in that
first-match-wins routing semantics cannot silently regress when
a middle entry is dropped
* test_lenient_bindings_top_level_field_typo_dropped — locks in
that deny_unknown_fields catches operator typos at the binding
top level (e.g. \`agnt = ...\`), not just inside match_rule
- TODO marker added on the remaining \`warn!\` fallback in \`load_config\`
for the non-binding silent-default path (follow-up work).
Tested live: typo'd \`hannel\` field on binding #2 logged as expected;
remaining 5 bindings loaded and routed correctly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Layers richer config validation, an explicit adapter allowlist, and a
stricter bridge routing path on top of upstream `d336314` ("binding
rule"), which shipped the same `channel_id` field as our PR #1127 in a
parallel implementation. Replaces upstream's `sender_user_id`/
`platform_id` heuristic with a single source of truth shared between
config validation and routing.
## What changes vs upstream `d336314`
**Data model** (`openfang-types/src/config.rs`)
- `#[serde(deny_unknown_fields)]` on `AgentBinding` so a typo at the
binding level (e.g. `match_rules` plural) fails loudly instead of
silently leaving the rule defaulted to "match everything". Upstream
has it on `BindingMatchRule` only.
- New `pub const CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL` (19 adapters:
discord, slack, telegram, matrix, mattermost, teams, webex,
rocketchat, nextcloud, pumble, revolt, guilded, feishu, lark,
keybase, google_chat, line, twist, flock, twitch). Single source of
truth shared with the bridge — no drift between routing and
validation paths possible. Hybrid adapters (IRC, Zulip) are
excluded; see source comment.
- Startup validation: warn when a binding sets `channel_id` for a
non-supporting adapter, or when `channel_id` is set without
`channel`. Documents the metadata escape hatch in the warning.
- Top-level `KernelConfig` keeps no `deny_unknown_fields` — comment
explains the §5.5 scoping decision so a future reader doesn't
"tighten" it without realizing it would break forward-compat keys.
**Bridge** (`openfang-channels/src/bridge.rs`)
- Replaces upstream's `sender_channel_id()` heuristic ("if metadata has
`sender_user_id` and it differs from `platform_id`, assume
`platform_id` IS the channel") with `binding_context_for(message)`,
which delegates to `ChannelMessage::channel_id()`. The heuristic
worked for Discord/Slack but would fail silently on Matrix, Teams,
Mattermost, Telegram, etc. — adapters whose `platform_id` IS the
channel ID but whose metadata does not happen to set
`sender_user_id` differently.
- Routes both dispatch paths (text + blocks) through
`resolve_with_context` so `guild_id` and `channel_id` bindings can
match. (Upstream's `resolve_with_channel_id` only handled
channel_id.)
**Channels types** (`openfang-channels/src/types.rs`)
- New `ChannelMessage::channel_id()` accessor: reads `platform_id` for
allowlisted adapters, falls back to `metadata["channel_id"]` for
opt-in adapters, else `None`. Case-folds `Custom(...)` variants so
a stray `Custom("Twitch")` cannot silently slip past the allowlist
(the validation path lowercases user input — accessor must match).
**Tests** (+8 in `bridge.rs`, +5 in `config.rs`, +1 in `types.rs`)
- Bridge: Discord/Telegram/Matrix/custom-supported/user-id-only-adapter
/metadata-fallback/guild-id-from-metadata/Email-returns-None
coverage of `binding_context_for` and `channel_id()`.
- Config: typo rejection on both `BindingMatchRule` and `AgentBinding`;
channel_id-without-channel warning; unsupported-adapter warning;
no-warning for discord/slack/telegram.
- Types: `channel_id()` case-insensitivity for Custom variants
including the Lark/Feishu Intl spelling.
**Docs** (`docs/channel-adapters.md`)
- Routing section rewritten: bindings are step 1 in the resolution
order. New "Bindings" subsection documents the rule shape, the
`peer_id` vs `channel_id` distinction (the easy confusion), full
specificity table, the adapter allowlist with the metadata escape
hatch, and the strict-field parsing rule.
## Why this layering instead of replacing d336314
Upstream's commit and our PR #1127 are functionally equivalent on
Discord and Slack. Shipping a richer extension on top is less churn
than ripping out the upstream commit and substituting ours, and keeps
the API surface upstream just added (`resolve_with_channel_id`)
intact for any third-party consumers.
`cargo check --workspace` and `cargo test -p openfang-types -p
openfang-channels --lib` (850 tests) pass.