zeroclaw-labs/zeroclaw · error

Telegram sendMessage failed (markdown {}: {}; plain {}: {})

Error message

Telegram sendMessage failed (markdown {}: {}; plain {}: {})

What it means

send_text_chunks first sends each chunk as Telegram HTML (markdown_to_telegram_html with parse_mode=HTML) and, if that attempt fails, retries the identical chunk as plain text; this error fires only when BOTH attempts fail, embedding both statuses and bodies. The plain attempt's body is authoritative — it names why the chat itself is undeliverable.

Source

Thrown at crates/zeroclaw-channels/src/telegram.rs:2989

                "chat_id": chat_id,
                "text": text,
            });

            // Add message_thread_id for forum topic support
            if let Some(tid) = thread_id {
                plain_body["message_thread_id"] = serde_json::Value::String(tid.to_string());
            }
            let plain_resp = self
                .http_client()
                .post(self.api_url("sendMessage"))
                .json(&plain_body)
                .send()
                .await?;

            if !plain_resp.status().is_success() {
                let plain_status = plain_resp.status();
                let plain_err = plain_resp.text().await.unwrap_or_default();
                anyhow::bail!(
                    "Telegram sendMessage failed (markdown {}: {}; plain {}: {})",
                    markdown_status,
                    markdown_err,
                    plain_status,
                    plain_err
                );
            }

            if index < chunks.len() - 1 {
                tokio::time::sleep(Duration::from_millis(100)).await;
            }
        }

        Ok(())
    }

    async fn send_media_by_url(
        &self,

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Read the plain attempt's `description` in the message — it identifies the terminal cause.
  2. 403 blocked/kicked: stop sending to that chat; the user must re-open the chat (find the chat via /start) before delivery can succeed.
  3. 429: honor retry_after, resend, and keep the existing 100ms inter-chunk spacing or increase it.
  4. 400 too long: split text into chunks of at most 4096 characters before calling send_text_chunks.
Defensive patterns

Strategy: retry

Validate before calling

const TG_LIMIT: usize = 4096;
anyhow::ensure!(chunk.chars().count() <= TG_LIMIT, "chunk exceeds 4096 chars");

Type guard

fn is_chat_undeliverable(err: &anyhow::Error) -> bool {
    let s = err.to_string();
    s.contains("bot was blocked") || s.contains("chat not found") || s.contains("kicked")
}

Try / catch

if let Err(e) = channel.send_text_chunks(text, chat, thread).await {
    if is_chat_undeliverable(&e) {
        mark_chat_disabled(chat).await; // stop future sends
        return Ok(());
    }
    if e.to_string().contains("429") {
        tokio::time::sleep(Duration::from_secs(2)).await;
        return channel.send_text_chunks(text, chat, thread).await;
    }
    return Err(e);
}

Prevention

When it happens

Trigger: 403 'bot was blocked by the user' or 'bot was kicked from the group chat' (both modes fail identically); 429 flood control while chunking long replies; 400 'message is too long' when a single chunk still exceeds 4096 characters; chat_id for a chat that never existed.

Common situations: User blocked the bot then triggered a background/cron reply; bot removed from a group but still in the paired chat list; burst replies hitting per-chat limits; upstream chunker producing oversized chunks.

Related errors


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