A custom emote relayed from Discord arrived as its bare :name: shortcode, and one typed in Haven so it would show on the Discord side stayed as text here. Haven now draws <:name:id> inline, fetching the picture once through the server so nobody's browser talks to Discord's CDN, and a Haven :name: goes out as the paired server's own emote of that name. Reported by Raidenphantom.
Promotion bots (CouchBot and friends) type the ping line and put the stream link, title, and thumbnail in a rich embed. Ferry only read the embed when the Discord message had no text, so Haven got "X is live" with nothing to click or look at. Rich embeds are now read alongside the typed text, the promoted link is policy-checked on its own so an allowlist server drops the link and keeps the announcement, and Discord's own unfurls of typed links stay ignored.
Reported by Raidenphantom against Ferry. SaucyBot and the bots like it post the
picture as an embed image with the source page in embed.url, and Ferry was
relaying only the summary, so what reached Haven was a link. Haven then unfurled
it, which is the fragile path: a channel full of them ends up with dead
previews, and the picture was the point of the message.
The embed image now comes through as a URL of its own, which Haven renders as an
ordinary chat image. Where an image is present the source link is dropped, since
that is the thing that would be unfurled, and the title and description are kept
for context. Embeds with no image relay exactly as before, link included, so
sharing an ordinary link is unchanged. Every embed on the message contributes
its image, because these bots post multi-image sets as several embeds.
The URL goes in the media half rather than the authored half, matching how
attachments are already handled: Ferry builds it out of Discord's response
rather than anyone typing it, and putting it through the link filter would throw
the whole message away on an allowlist server. That loosens nothing. Whether a
viewer's browser actually fetches the image is still decided by the preview
allowlist at render time, which is the control that stops a third-party host
seeing everyone who scrolls past.
No new setting. The concern raised was disk space, and there is none to spend:
Ferry passes the URL through and re-hosts nothing, exactly as it already does
for attachments.
Three problems reported from real use:
Ferry appeared to relay only the admin's messages. It was the permission: no
default role holds use_ferry, so an admin sets Ferry up, tests it successfully
as an admin, and it silently does nothing for everyone else. In mirror mode
there is not even a toast, by design, so there was no way to tell. The panel
now has a "Choose who can send" step listing every role with a switch, writing
the same permission the Roles editor does. Nobody should have to go hunting
through another screen to make the feature they just configured work.
The channel dropdowns were flat lists. Sub-channels were indistinguishable from
channels, and Haven allows the same display name under two different parents,
so the list could show two identical entries with no way to pick the right one.
Haven channels now nest under their parent, Discord channels group by their
Discord category, and a sub-channel whose parent is not visible lands under
"Other" instead of disappearing.
@mentions did not cross. Discord only pings when the text contains <@id>, so a
relayed "@someone" was inert no matter what the pings setting said. Outgoing
names are now resolved against the destination server, and only an exact,
unambiguous match is turned into a real ping: two people answering to one name
means it stays plain text, because pinging the wrong stranger is worse than not
pinging. Incoming mentions use the message's own resolved list, so they are
exact rather than guessed. The Server Members intent is now requested when
either DMs or pings are on, not just DMs.
Security:
- The Discord DM recipient came from the client and was never validated or
authorized. Scoping the autocomplete to paired servers was not a control,
since the id travels with the send and a client could skip the lookup. The id
is now shape-checked, and the recipient is confirmed to be a member of a
server the channel is actually paired with before anything is sent. A channel
with no pairings can no longer send a DM at all.
- Member lookups had no rate limit. Each one fans out to up to five Discord
calls, and a burst of 429s is how a bot token gets temporarily banned, so
they now have their own flood bucket.
- use_ferry was checked without a channel, so a channel-scoped role grant
silently did nothing and only a server-wide grant worked. Same class of bug
as #5433.
- The composer remembered a chosen DM recipient's id but not who it belonged
to, so editing the name by hand sent a private message to whoever was picked
before. The id is now bound to the exact recipient text and dropped the
moment it stops matching.
From @Raidenphantom's independent bridge, which surfaced two real gaps:
- A Discord edit is now applied to the Haven copy instead of being ignored.
Messages we never relayed are not resurrected by an edit, which is what stops
a third-party bot's delayed image embed from arriving after its original was
filtered out, and embed-only updates are ignored so a link preview cannot
replace the author's text.
- Custom emotes arrived as raw <:name:id> mid-sentence and now read as :name:,
rendering as a real emoji when the Haven server has one by that name.
Inbound Discord messages also now pass through the automod link check. A bridge
is an excellent spam vector, and relayed messages were skipping the controls
every Haven member is held to.
Each Haven server brings its own Discord bot, since Discord caps unverified
applications at 100 guilds. Reading uses a gateway WebSocket, because Discord
has no outgoing webhook for new messages. Writing uses a per-pairing Discord
channel webhook rather than the bot user, which is the only way a relayed
message can carry the Haven author's own name and avatar.
Pairings live in ferry_links and act as the allowlist, not just a routing
table: a member can only reach Discord channels an admin paired with the Haven
channel they are standing in. Each pairing picks its own direction (two way, or
either one way) and its own outgoing mode (mirror everything, or only messages
addressed with the "=>" prefix). Sending needs the new use_ferry permission.
The trigger is "=>" and not ">>" because a leading ">" is Haven's blockquote
marker, so an unresolved target would render as a quote instead of visibly
failing. Resolved targets are stripped from the stored message and recorded in
messages.ferry_target so history shows a destination badge instead of raw
routing syntax.
Loop prevention drops the gateway echo of our own webhook ids and, by default,
every other bot. Mentions are blocked by default so one member cannot ping a
whole Discord server through the bridge. DMs are off by default, outbound only,
and stamped with the author's name, since a bot cannot impersonate in a DM.
ws becomes a direct dependency; it was only reaching us through socket.io.