zeroclaw-labs/zeroclaw · error · anyhow::Error

modal custom_id exceeds Discord's 100-char limit; cannot ope

Error message

modal custom_id exceeds Discord's 100-char limit; cannot open

What it means

discord_open_modal serializes the DiscordModal via to_api(), which returns None when the modal's routing custom_id (the zc1 token) cannot be encoded within Discord's hard 100-character custom_id limit. This bail fires before any HTTP call: the payload is unrepresentable, so the library refuses locally. Unlike label (45 chars) and value (4000 chars), which to_api clamps, the custom_id cannot be truncated without breaking submit routing — hence the hard failure.

Source

Thrown at crates/zeroclaw-channels/src/discord/interaction.rs:64

        let status = resp.status();
        let err = resp.text().await.unwrap_or_default();
        anyhow::bail!("interaction defer failed ({status}): {err}");
    }
    Ok(())
}

/// Open a modal in response to a button/slash interaction (callback type 9).
/// The caller registers the modal's `custom_id` in the pending registry as a
/// resolve-into-turn so the eventual type-5 submit resolves it. Driven by the
/// `OpenModal` dispatch arm (a `[COMPONENTS:…]` modal button click).
pub(crate) async fn discord_open_modal(
    client: &reqwest::Client,
    interaction_id: &str,
    interaction_token: &str,
    modal: &super::components::DiscordModal,
) -> anyhow::Result<()> {
    let Some(data) = modal.to_api() else {
        anyhow::bail!("modal custom_id exceeds Discord's 100-char limit; cannot open");
    };
    let url = format!(
        "https://discord.com/api/v10/interactions/{interaction_id}/{interaction_token}/callback"
    );
    // type 9 = MODAL
    let body = json!({ "type": 9, "data": data });
    // without_url: transport errors embed the token-bearing URL.
    let resp = client
        .post(&url)
        .json(&body)
        .send()
        .await
        .map_err(reqwest::Error::without_url)?;
    if !resp.status().is_success() {
        let status = resp.status();
        let err = resp.text().await.unwrap_or_default();
        anyhow::bail!("modal open failed ({status}): {err}");
    }

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Move the payload out-of-band: store it under a short key and embed only the key in custom_id
  2. Shorten the routing token deterministically (hash or truncate the argument)
  3. Validate at construction time so oversized ids never reach open_modal (see validationCode)
  4. Pin the max encoded id length with a unit test over your longest realistic input

Example fix

// before: full payload embedded in the custom_id
let modal = DiscordModal { custom_id: CustomId::new(format!("zc1:edit:{draft}")), .. };

// after: short key, payload stored out-of-band
let key = store.insert(draft); // e.g. "k3f9"
let modal = DiscordModal { custom_id: CustomId::new(format!("zc1:edit:{key}")), .. };
Defensive patterns

Strategy: validation

Validate before calling

// to_api() is the authority: if it cannot encode, neither can Discord.
if modal.to_api().is_none() {
    // rebuild the custom_id with a shorter argument before attempting to open
}

Prevention

When it happens

Trigger: A modal custom_id embedding a long argument (draft body, file path, serialized payload) that pushes the encoded id over 100 chars; OpenModal dispatch driven by an oversized [COMPONENTS:...] marker; ids built from unbounded user-supplied strings.

Common situations: Edit-modals that stuff the current draft into the custom_id; tool outputs or long paths used as routing args; payloads that grow over time and cross 100 chars only for some inputs.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/efabb4ccde9dc14f. Report an issue: GitHub.