CherryHQ/cherry-studio · error · Error

Bot is not connected

Error message

Bot is not connected

What it means

Thrown by TelegramAdapter.sendMessage() when this.bot is null. The bot instance is set in startBot() (TelegramAdapter.ts:70) and cleared to null in performDisconnect() (TelegramAdapter.ts:256). So this throw means sendMessage was called before connect completed or after disconnect ran. This is a lifecycle violation: the caller sent a message while the adapter was not in the connected state.

Source

Thrown at src/main/ai/channels/adapters/telegram/TelegramAdapter.ts:305

      const url = `https://api.telegram.org/file/bot${this.botToken}/${file.file_path}`
      const attachment = await downloadFileAsBase64(url, filename)
      if (!attachment) return []
      // Override media_type with Telegram's reported mime_type if available
      if (mimeType) attachment.media_type = mimeType
      return [attachment]
    } catch (error) {
      this.log.warn('Failed to download Telegram document', {
        fileId,
        filename,
        error: error instanceof Error ? error.message : String(error)
      })
      return []
    }
  }

  async sendMessage(chatId: string, text: string, opts?: SendMessageOptions): Promise<void> {
    if (!this.bot) {
      throw new Error('Bot is not connected')
    }

    const parseMode = opts?.parseMode ?? 'MarkdownV2'
    const isMarkdown = parseMode === 'MarkdownV2'
    // Split the PLAIN text first and escape each chunk, so the MarkdownV2 send and
    // its plain-text fallback share one chunk boundary. (Splitting the *formatted*
    // text and then re-splitting the *raw* text by the same index misaligns — escaping
    // changes lengths/boundaries — dropping, duplicating, or passing `undefined` chunks.)
    const plainChunks = splitMessage(text, isMarkdown ? TELEGRAM_MARKDOWN_CHUNK_BUDGET : TELEGRAM_MAX_LENGTH)

    for (let i = 0; i < plainChunks.length; i++) {
      const plain = plainChunks[i]
      const formatted = isMarkdown ? toMarkdownV2(plain).trimEnd() : plain
      // Telegram message ids are numeric; a string replyToMessageId (QQ's msg_id) isn't ours.
      const replyParams =
        typeof opts?.replyToMessageId === 'number' && i === 0
          ? { reply_parameters: { message_id: opts.replyToMessageId } }
          : {}

View on GitHub (pinned to 726446b54c)

Solutions

  1. Gate outbound sends on the adapter's connected state (isConnected()/markConnected lifecycle) before calling sendMessage, and queue or drop messages while disconnected.
  2. Have callers subscribe to disconnect events so they stop sending when the bot is null.
  3. Ensure performDisconnect() awaits in-flight sends or signals them to abort before nulling the bot.
  4. If reconnect is in progress, buffer the message and flush after markConnected.

Example fix

// before — caller throws when adapter is mid-reconnect
await telegramAdapter.sendMessage(chatId, text)

// after — guard on the adapter's connected state, queue otherwise
if (!telegramAdapter.isConnected()) {
  await messageQueue.enqueue({ chatId, text, channel: 'telegram' })
  return
}
await telegramAdapter.sendMessage(chatId, text)
Defensive patterns

Strategy: validation

Validate before calling

// Check the adapter's connected state before sending. The adapter calls markConnected()
// in startBot() (TelegramAdapter.ts:200) and markDisconnected() on polling failure.
// Expose isConnected() from the ChannelAdapter base and use it:
if (!telegramAdapter.isConnected()) {
  // Queue or drop — do not call sendMessage
  await queueOrDrop({ channel: 'telegram', chatId, text })
  return
}
await telegramAdapter.sendMessage(chatId, text)

Type guard

// Narrow the adapter state via the base class's connection flag
function isAdapterConnected(adapter: ChannelAdapter): boolean {
  return adapter.isConnected() // exposed by ChannelAdapter base after markConnected()
}

// Guard before the call site
function assertConnected(adapter: ChannelAdapter, action: string): void {
  if (!isAdapterConnected(adapter)) {
    throw new ChannelDisconnectedError(`Cannot ${action}: adapter disconnected`)
  }
}

Try / catch

// Distinguish 'bot null' from genuine send failures so transient disconnects
// do not crash the caller (e.g. an agent tool running mid-stream)
try {
  await telegramAdapter.sendMessage(chatId, text)
} catch (e) {
  if (e instanceof Error && e.message === 'Bot is not connected') {
    logger.warn('Telegram adapter not connected, queuing message', { chatId })
    await pendingTelegram.enqueue({ chatId, text })
    return
  }
  throw e
}

Prevention

When it happens

Trigger: sendMessage() is invoked after performDisconnect() set this.bot=null (e.g. a notify/scheduled-send racing with a disconnect, or a reconnect failure left the bot down); or sendMessage() is called before performConnect() finished starting the bot (e.g. immediately after connect() returned but markConnected() not yet reached). The polling bot.start() is fire-and-forget (line 192), so a failed poll can null the bot via disconnect while queued sends are in flight.

Common situations: A scheduled task fires while the channel is disconnected or reconnecting; the bot hit a fatal 409/401 and reconnect backoff is in progress (the adapter marks itself disconnected but downstream callers were not notified); a notify tool call races with the user disabling the channel.

Related errors


AI-assisted analysis of CherryHQ/cherry-studio@726446b54c (2026-08-12). Data as JSON: /api/errors/68d54d1f446f6b16. Report an issue: GitHub.