Skip to content

Unify length handling across adapters: throw MessageTooLongError, remove silent data loss #408

@serejke

Description

@serejke

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 unionpost(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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions