Message gateway adapters
Add a compiled-in chat adapter without putting platform-specific routing in the gateway.
A chat adapter connects an external messaging platform to the account’s message gateway. Discord is the only adapter shipped today.
Adapters are compiled into the server through CrumblePlugin.chatAdapters.
Installing or loading third-party adapters without rebuilding is deferred.
There is no adapter marketplace or dynamic loading API.
Contribution boundary
The source contract is
message-gateway/adapter.ts.
The plugin contract is
plugins/plugins.ts.
Both paths are relative to apps/server/src.
| Adapter member | Responsibility |
|---|---|
id |
Stable, open string identifier. Keep it unique across contributions. |
label |
Display name for settings. |
secretKey |
Fully qualified plugin secret key, such as discord:token. |
maxMessageLength |
Maximum text length accepted by one outbound message. |
connect(host) |
Open the platform connection using host.token and return a GatewayConnection. |
Declare the secret with CrumblePlugin.settings; use a secret setting.
Contribute the adapter through chatAdapters, then register the plugin in
apps/server/src/app/builtin-plugins.ts.
The shared gateway owns account configuration, owner and destination
authorization, bot routing, durable receipts, bot submission, answer selection,
text splitting, and delivery progress. Pending receipts retain their original
owner and destination; authorization is checked again before processing.
Only the configured ownerId can submit messages, with the same owner authority
as bot chat input. Keep platform branches out of that shared logic. Adapter IDs
don’t require a closed union of supported platforms.
Receive messages
Call host.onMessage with a GatewayMessage for each supported platform event:
| Field | Meaning |
|---|---|
id |
Stable platform message ID, reused when an event is replayed. |
channelId |
Stable destination ID for both inbound messages and outbound answers. |
senderId |
Stable user ID used for authorization. |
text |
Original message text, preserved in the bot transcript. |
routingText |
Optional text used only to resolve a leading recipient. |
isDirectMessage |
Whether the destination is a direct message. |
replyTo |
Optional platform message ID to which this message replies. |
hasAttachments |
Whether unsupported attachments accompany the message. |
Ignore the platform’s bot and webhook messages before calling onMessage.
Report attachments even when the event also contains text. Don’t silently
drop attachments or resolve a bot inside the adapter. If the platform adds a
native application mention, strip only that leading mention in routingText.
Preserve text, including every mention and routing prefix.
Routing uses a leading literal @handle, then a recorded gateway reply,
then the configured default bot. Explicit routing lasts for one message.
Unknown recipients don’t fall back. Reply associations and selected
recipients must survive restarts. Reply routing expires 30 days after
completion; later replies use the default bot unless an explicit recipient
overrides it. Duplicate receipts are removed by hourly cleanup after 30 days.
The gateway submits input to the existing bot transcript. It reads the answer by submission ID, rather than selecting the transcript’s newest answer. Unrelated app messages, automations, and background activity never become an external response merely because they arrived later.
Send answers
GatewayConnection.send receives channelId, optional replyTo, text,
and nonce. Return the platform’s sent message ID. Preserve the supplied
nonce across attempts, and use platform deduplication when available.
Disable automatic user, role, and everyone mentions. Use a platform reply
reference when replyTo is present. Let failures reject instead of reporting
success before the platform accepts the message. Throw GatewayTransportError
with permanent, optional status, and optional retryAfterMs. Include
rateLimit metadata when a platform limit applies, so the gateway respects the
adapter or channel cooldown. Retryable failures use persisted exponential
backoff from 5 seconds to 15 minutes, honoring longer platform waits. Receipts
stop after 10 failures or 24 hours. Permanent errors stop immediately.
When a receipt finishes, its gateway input and answer are cleared. Compact duplicate receipts and indexed reply associations remain for 30 days and are pruned hourly. The bot transcript keeps its original message and answer.
A successful send can still be followed by a crash before its message ID is persisted. Stable nonces and saved progress reduce duplicates; they don’t provide an unconditional exactly-once guarantee. Don’t claim that the adapter recovers events the platform never delivered.
Connection lifecycle
Report connecting, connected, disconnected, or error through
host.onStatus. Include an actionable error without exposing the token.
Use the platform SDK for heartbeat, transient reconnects, and rate-limit behavior.
onStatus("error", error) reports a diagnostic without replacing a live client.
Call host.onInvalidated(error) only when the client cannot recover its session.
Failed connection attempts and invalidated sessions use exponential backoff
from 60 seconds to 15 minutes. Five minutes continuously connected resets the backoff.
GatewayConnection.close must release the client, listeners, and network
connection. Disabling an adapter, replacing its token, and stopping the
account runtime must close the previous connection.
Verification
Use a fake platform client without real credentials or network requests. Cover normalization, bot and webhook rejection, attachment detection, reply references, disabled mentions, stable nonces, send errors, and cleanup. Shared gateway tests cover authorization, routing, replayed events, concurrent submissions, persisted reply associations, and partial delivery after restart.
For owner setup and platform permissions, see Discord.