Problem
Adapters handle over-limit messages with three incompatible strategies, all lossy or opaque:
| Adapter |
Limit |
Behavior |
| Slack |
40,000 / 3,000 section |
No check — platform returns untyped 400 |
| Discord |
2,000 |
Silent truncate + ... (same bug class fixed in #PR) |
| Google Chat |
4,000 |
No check — platform 400 |
| Teams |
~28,000 |
No check — platform 400 |
| WhatsApp |
4,096 |
Auto-split into N posts, returns only last chunk's ID |
| Telegram |
4,096 / 1,024 caption |
Silent truncate with safe-boundary trim (just fixed) |
Each strategy is surprising in its own way. Silent truncation loses caller content. Silent splitting loses message identity (reactions / edits target only the last chunk). Platform 400s surface as generic errors the caller can't branch on.
Discord specifically has the same unclosed-entity and orphan-backslash bugs the recent Telegram fix addressed — just undetected because few messages hit 2,000 chars.
First-principles argument
post(message): Promise<Sent> claims to be total. When input exceeds the platform limit, the returned "Sent" either lost content, silently became N messages, or the promise rejected with an untyped error. The signature is lying in three different ways across adapters.
The honest contract: platform length limits are part of the adapter's interface and should be surfaced, not hidden.
Proposal
Default: throw a typed error. Consistent with how the SDK already handles ValidationError, AuthenticationError, RateLimitError. Exceptions carry stack traces and structured context; callers handle overflow at the scope that makes sense (one try/catch around a handler) without every call site branching on status.
class MessageTooLongError extends ValidationError {
readonly limit: number;
readonly actualLength: number;
readonly platform: string;
}
Expose limits as adapter metadata:
interface Adapter {
readonly messageLimits: { text: number; caption?: number };
}
Opt-in strategies at adapter construction for callers who want non-default policy:
type LongMessageStrategy = "throw" | "truncate" | "split" | "file";
Default is "throw". Existing behaviors move to opt-in:
"truncate" — current Telegram/Discord silent-trim behavior
"split" — current WhatsApp multi-message behavior
"file" — above-limit body attached as .txt, short caption inline
Scope by adapter
- Shared: add
MessageTooLongError, LongMessageStrategy config type, messageLimits metadata.
- Telegram: current truncator becomes the
"truncate" path (safe-boundary trim already in place). Default flips to "throw".
- Discord: promote current truncate to opt-in, port the safe-boundary-trim logic (Discord shares the unclosed-entity and orphan-
\ bugs).
- WhatsApp: current
splitMessage becomes the "split" path. Default flips to "throw" — docs explain why auto-split breaks reactions/edits/replies.
- Slack / GChat / Teams: add proactive length check before API call. Currently relies on platform 400. After: typed error with same timing, actionable by caller.
Alternatives considered
Discriminated return union — post(msg): Promise<{status: "sent"} | {status: "too_long"}>. Rejected: inconsistent with every other constraint in the SDK (all throw), and forces every call site to branch on status even when the message is known-safe, which is the vast majority of calls. Try/catch at a sensible scope is cheaper than type noise everywhere.
Keep current per-adapter behavior, document it — Rejected: the behaviors are not discoverable from the Adapter interface, and "silent" is the common failure mode. Documentation doesn't prevent data loss.
Global default of "truncate" — Rejected: lossy by default is what got us here. Callers should opt into any behavior that drops bytes.
Non-goals
- Changing the AST layer or format-converters.
- Changing how card / attachment length limits are enforced (separate concern).
Migration
Breaking change for callers relying on silent truncation / splitting. Gated behind a minor version bump of each adapter; release notes call out the default flip and the opt-in path to restore prior behavior. Could also ship "throw" as opt-in first for one minor release, warn when the overflow path is hit, then flip in the next minor.
Problem
Adapters handle over-limit messages with three incompatible strategies, all lossy or opaque:
...(same bug class fixed in #PR)Each strategy is surprising in its own way. Silent truncation loses caller content. Silent splitting loses message identity (reactions / edits target only the last chunk). Platform 400s surface as generic errors the caller can't branch on.
Discord specifically has the same unclosed-entity and orphan-backslash bugs the recent Telegram fix addressed — just undetected because few messages hit 2,000 chars.
First-principles argument
post(message): Promise<Sent>claims to be total. When input exceeds the platform limit, the returned "Sent" either lost content, silently became N messages, or the promise rejected with an untyped error. The signature is lying in three different ways across adapters.The honest contract: platform length limits are part of the adapter's interface and should be surfaced, not hidden.
Proposal
Default: throw a typed error. Consistent with how the SDK already handles
ValidationError,AuthenticationError,RateLimitError. Exceptions carry stack traces and structured context; callers handle overflow at the scope that makes sense (onetry/catcharound a handler) without every call site branching on status.Expose limits as adapter metadata:
Opt-in strategies at adapter construction for callers who want non-default policy:
Default is
"throw". Existing behaviors move to opt-in:"truncate"— current Telegram/Discord silent-trim behavior"split"— current WhatsApp multi-message behavior"file"— above-limit body attached as.txt, short caption inlineScope by adapter
MessageTooLongError,LongMessageStrategyconfig type,messageLimitsmetadata."truncate"path (safe-boundary trim already in place). Default flips to"throw".\bugs).splitMessagebecomes the"split"path. Default flips to"throw"— docs explain why auto-split breaks reactions/edits/replies.Alternatives considered
Discriminated return union —
post(msg): Promise<{status: "sent"} | {status: "too_long"}>. Rejected: inconsistent with every other constraint in the SDK (all throw), and forces every call site to branch on status even when the message is known-safe, which is the vast majority of calls. Try/catch at a sensible scope is cheaper than type noise everywhere.Keep current per-adapter behavior, document it — Rejected: the behaviors are not discoverable from the Adapter interface, and "silent" is the common failure mode. Documentation doesn't prevent data loss.
Global default of
"truncate"— Rejected: lossy by default is what got us here. Callers should opt into any behavior that drops bytes.Non-goals
Migration
Breaking change for callers relying on silent truncation / splitting. Gated behind a minor version bump of each adapter; release notes call out the default flip and the opt-in path to restore prior behavior. Could also ship
"throw"as opt-in first for one minor release, warn when the overflow path is hit, then flip in the next minor.