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

Twitter DM send failed ({status}): {err}

Error message

Twitter DM send failed ({status}): {err}

What it means

Raised by TwitterChannel::send_dm when POST /2/dm_conversations/with/{recipient_id}/messages answers non-2xx. The recipient travels in the URL as a numeric user id (the channel routes recipients prefixed "dm:"), and the body carries only {text}; DMs allow up to 10000 chars per the channel's own routing note.

Source

Thrown at crates/zeroclaw-channels/src/twitter.rs:170

    async fn send_dm(&self, recipient_id: &str, text: &str) -> anyhow::Result<()> {
        let body = json!({
            "text": text,
        });

        let resp = self
            .http_client()
            .post(format!(
                "{TWITTER_API_BASE}/dm_conversations/with/{recipient_id}/messages"
            ))
            .bearer_auth(&self.bearer_token)
            .json(&body)
            .send()
            .await?;

        if !resp.status().is_success() {
            let status = resp.status();
            let err = resp.text().await.unwrap_or_default();
            anyhow::bail!("Twitter DM send failed ({status}): {err}");
        }

        Ok(())
    }
}

impl ::zeroclaw_api::attribution::Attributable for TwitterChannel {
    fn role(&self) -> ::zeroclaw_api::attribution::Role {
        ::zeroclaw_api::attribution::Role::Channel(
            ::zeroclaw_api::attribution::ChannelKind::Twitter,
        )
    }
    fn alias(&self) -> &str {
        &self.alias
    }
}

#[async_trait]

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Regenerate the token with dm.write (and dm.read) scopes.
  2. Have the recipient follow the account or open DMs — X blocks DMs to closed recipients with 403.
  3. Verify the recipient value after "dm:" is the numeric user id, not a handle.
  4. For 429, respect the DM rate window before retrying.

Example fix

// before — dm: recipient holds a handle, not the numeric id
channel.send(&SendMessage { recipient: "dm:@alice".into(), content: text.into() }).await?;

// after — numeric user id resolved beforehand
channel.send(&SendMessage { recipient: "dm:2244994945".into(), content: text.into() }).await?;
Defensive patterns

Strategy: retry

Validate before calling

// The dm: recipient must be a numeric user id.
fn is_twitter_user_id(recipient: &str) -> bool {
    !recipient.is_empty() && recipient.chars().all(|c| c.is_ascii_digit())
}

if let Some(id) = msg.recipient.strip_prefix("dm:") {
    assert!(is_twitter_user_id(id), "dm recipient must be the numeric user id");
}

Type guard

fn is_numeric_user_id(v: &str) -> bool {
    !v.is_empty() && v.chars().all(|c| c.is_ascii_digit())
}

Try / catch

match twitter.send(&msg).await {
    Ok(()) => Ok(()),
    Err(err) => {
        let t = err.to_string();
        if t.contains("Twitter DM send failed (403)") {
            // closed DMs or missing dm.write scope — retrying cannot help
            Err(err.context("recipient must follow the account / open DMs; token needs dm.write"))
        } else if t.contains("(429)") {
            tokio::time::sleep(Duration::from_secs(60)).await;
            twitter.send(&msg).await
        } else {
            Err(err)
        }
    }
}

Prevention

When it happens

Trigger: 403 when the app lacks dm.write/dm.read scopes, 403 when the recipient does not follow the account or has DMs closed, 404 for a malformed or nonexistent recipient id, and 429 on DM rate limits.

Common situations: The token covers tweeting but not DMs. End users who never enabled open DMs cannot be messaged, so the bot fails exactly on the DM path while tweets work. Passing an @handle instead of the numeric user id as the dm: recipient.

Related errors


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