diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 2e723223aef..6657ed7a762 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -22,6 +22,19 @@ from typing import Dict, List, Optional, Set, Any logger = logging.getLogger(__name__) +def _redact_telegram_error_text(error: object) -> str: + """Redact secrets from Telegram transport errors before logging or returning them.""" + text = "" if error is None else str(error) + if not text: + return text + try: + from agent.redact import redact_sensitive_text + + return redact_sensitive_text(text, force=True) + except Exception: + return "" + + def _consume_abandoned_task(task: asyncio.Task) -> None: """Observe a detached task's terminal exception to avoid noisy loop logs.""" try: @@ -1609,13 +1622,14 @@ class TelegramAdapter(BasePlatformAdapter): _m = _re.search(r"retry\s+(?:in\s+)?(\d+)", err_str, _re.IGNORECASE) if _m: _retry_after = float(_m.group(1)) + safe_error = _redact_telegram_error_text(exc) logger.warning( "[%s] sendRichMessage transient failure (no legacy resend): %s", - self.name, exc, + self.name, safe_error, ) return SendResult( success=False, - error=str(exc), + error=safe_error, retryable=(is_connect_timeout or not is_timeout), retry_after=_retry_after, ) @@ -1704,13 +1718,14 @@ class TelegramAdapter(BasePlatformAdapter): _TimedOut = None is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str is_connect_timeout = self._looks_like_connect_timeout(exc) + safe_error = _redact_telegram_error_text(exc) logger.warning( "[%s] rich editMessageText transient failure (no legacy resend): %s", - self.name, exc, + self.name, safe_error, ) return SendResult( success=False, - error=str(exc), + error=safe_error, retryable=(is_connect_timeout or not is_timeout), ) # Telegram won't echo rich content for messages that predate the bot's @@ -1978,15 +1993,16 @@ class TelegramAdapter(BasePlatformAdapter): "Telegram polling could not reconnect after %d network error retries. " "Restarting gateway." % MAX_NETWORK_RETRIES ) - logger.error("[%s] %s Last error: %s", self.name, message, error) + logger.error("[%s] %s Last error: %s", self.name, message, _redact_telegram_error_text(error)) self._set_fatal_error("telegram_network_error", message, retryable=True) await self._notify_fatal_error() return delay = min(BASE_DELAY * (2 ** (attempt - 1)), MAX_DELAY) + safe_error = _redact_telegram_error_text(error) logger.warning( "[%s] Telegram network error (attempt %d/%d), reconnecting in %ds. Error: %s", - self.name, attempt, MAX_NETWORK_RETRIES, delay, error, + self.name, attempt, MAX_NETWORK_RETRIES, delay, safe_error, ) await asyncio.sleep(delay) @@ -2058,7 +2074,8 @@ class TelegramAdapter(BasePlatformAdapter): self._background_tasks.add(probe) probe.add_done_callback(self._background_tasks.discard) except Exception as retry_err: - logger.warning("[%s] Telegram polling reconnect failed: %s", self.name, retry_err) + safe_retry_error = _redact_telegram_error_text(retry_err) + logger.warning("[%s] Telegram polling reconnect failed: %s", self.name, safe_retry_error) # start_polling failed — polling is dead and no further error # callbacks will fire, so schedule the next retry ourselves. if not self.has_fatal_error: @@ -3629,18 +3646,20 @@ class TelegramAdapter(BasePlatformAdapter): err_lower = str(send_err).lower() if "message to be replied not found" in err_lower and reply_to_id is not None: if private_dm_topic_send: + safe_send_error = _redact_telegram_error_text(send_err) return SendResult( success=False, - error=str(send_err), + error=safe_send_error, retryable=False, ) # Original message was deleted before we # could reply. For private-topic fallback # sends, message_thread_id is only valid with # the reply anchor, so drop both together. + safe_send_error = _redact_telegram_error_text(send_err) logger.warning( "[%s] Reply target deleted, retrying without reply_to: %s", - self.name, send_err, + self.name, safe_send_error, ) reply_to_id = None if metadata and metadata.get("telegram_dm_topic_reply_fallback"): @@ -3677,8 +3696,9 @@ class TelegramAdapter(BasePlatformAdapter): await self._drain_general_connections_after_pool_timeout() if _send_attempt < 2: wait = 2 ** _send_attempt + safe_send_error = _redact_telegram_error_text(send_err) logger.warning("[%s] Network error on send (attempt %d/3), retrying in %ds: %s", - self.name, _send_attempt + 1, wait, send_err) + self.name, _send_attempt + 1, wait, safe_send_error) await asyncio.sleep(wait) else: raise @@ -3687,12 +3707,13 @@ class TelegramAdapter(BasePlatformAdapter): if retry_after is not None or "retry after" in str(send_err).lower(): if _send_attempt < 2: wait = float(retry_after) if retry_after is not None else 1.0 + safe_send_error = _redact_telegram_error_text(send_err) logger.warning( "[%s] Telegram flood control on send (attempt %d/3), retrying in %.1fs: %s", self.name, _send_attempt + 1, wait, - send_err, + safe_send_error, ) await asyncio.sleep(wait) continue @@ -3726,7 +3747,8 @@ class TelegramAdapter(BasePlatformAdapter): ) except Exception as e: - logger.error("[%s] Failed to send Telegram message: %s", self.name, e, exc_info=True) + safe_error = _redact_telegram_error_text(e) + logger.error("[%s] Failed to send Telegram message: %s", self.name, safe_error) err_str = str(e).lower() error_kind = classify_send_error(e) # Message too long — content exceeded 4096 chars. Return failure so @@ -3748,7 +3770,7 @@ class TelegramAdapter(BasePlatformAdapter): is_pool_timeout = self._looks_like_pool_timeout(e) return SendResult( success=False, - error=str(e), + error=safe_error, retryable=(is_connect_timeout or is_pool_timeout or not is_timeout), error_kind=error_kind, ) @@ -3862,10 +3884,11 @@ class TelegramAdapter(BasePlatformAdapter): if "not modified" in str(fmt_err).lower(): return SendResult(success=True, message_id=message_id) # Fallback: strip MarkdownV2 escapes and retry as clean plain text + safe_format_error = _redact_telegram_error_text(fmt_err) logger.warning( "[%s] MarkdownV2 edit failed, falling back to plain text: %s", self.name, - fmt_err, + safe_format_error, ) _plain = _strip_mdv2(content) if content else content await self._bot.edit_message_text( @@ -3920,11 +3943,12 @@ class TelegramAdapter(BasePlatformAdapter): ) return SendResult(success=True, message_id=message_id) except Exception as retry_err: + safe_retry_error = _redact_telegram_error_text(retry_err) logger.error( "[%s] Edit retry failed after flood wait: %s", - self.name, retry_err, + self.name, safe_retry_error, ) - return SendResult(success=False, error=str(retry_err)) + return SendResult(success=False, error=safe_retry_error) # Transient network errors (ConnectError, timeouts, server # disconnects) should not permanently disable progress-message # editing. Mark the result retryable so the caller knows it @@ -3945,21 +3969,22 @@ class TelegramAdapter(BasePlatformAdapter): ) _is_transient = any(m in err_str for m in _transient_markers) if _is_transient: + safe_error = _redact_telegram_error_text(e) logger.warning( "[%s] Transient network error editing message %s (will retry): %s", self.name, message_id, - e, + safe_error, ) - return SendResult(success=False, error=str(e), retryable=True) + return SendResult(success=False, error=safe_error, retryable=True) + safe_error = _redact_telegram_error_text(e) logger.error( "[%s] Failed to edit Telegram message %s: %s", self.name, message_id, - e, - exc_info=True, + safe_error, ) - return SendResult(success=False, error=str(e)) + return SendResult(success=False, error=safe_error) def _truncate_stream_overflow_preview(self, content: str) -> str: """Return a one-message preview for oversized streaming edits. diff --git a/tests/gateway/test_telegram_rich_messages.py b/tests/gateway/test_telegram_rich_messages.py index 98427a68b52..eb4a5b3802d 100644 --- a/tests/gateway/test_telegram_rich_messages.py +++ b/tests/gateway/test_telegram_rich_messages.py @@ -10,6 +10,7 @@ The ``telegram`` package is mocked by ``tests/gateway/conftest.py`` ``TelegramAdapter`` and wire a mock bot. """ +import logging from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -475,6 +476,53 @@ async def test_transient_timeout_is_not_retryable(): assert result.retryable is False +@pytest.mark.asyncio +async def test_rich_transport_error_redacts_bot_token_even_when_redaction_disabled(monkeypatch): + import agent.redact as redact + + monkeypatch.setattr(redact, "_REDACT_ENABLED", False) + token = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef" + adapter = _make_adapter() + adapter._bot.do_api_request = AsyncMock( + side_effect=NetworkError( + f"Timed out requesting https://api.telegram.org/bot{token}/sendRichMessage" + ) + ) + + result = await adapter.send("12345", RICH_CONTENT) + + assert result.success is False + assert result.error is not None + assert token not in result.error + assert "bot123456789:***/sendRichMessage" in result.error + adapter._bot.send_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_legacy_send_error_redacts_bot_token_without_traceback(monkeypatch, caplog): + import agent.redact as redact + + monkeypatch.setattr(redact, "_REDACT_ENABLED", False) + token = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef" + adapter = _make_adapter({"rich_messages": False}) + adapter._bot.send_message = AsyncMock( + side_effect=BadRequest( + f"Bad Request: https://api.telegram.org/bot{token}/sendMessage" + ) + ) + + with caplog.at_level(logging.ERROR): + result = await adapter.send("12345", "Plain legacy content.") + + assert result.success is False + assert result.error is not None + assert token not in result.error + assert "bot123456789:***/sendMessage" in result.error + assert token not in caplog.text + assert "bot123456789:***/sendMessage" in caplog.text + adapter._bot.do_api_request.assert_not_called() + + @pytest.mark.asyncio async def test_routing_thread_id_maps_to_message_thread_id(): adapter = _make_adapter() @@ -823,6 +871,32 @@ async def test_finalize_edit_plain_content_stays_legacy(): adapter._bot.edit_message_text.assert_awaited() +@pytest.mark.asyncio +async def test_legacy_edit_error_logs_redacted_bot_token_without_traceback(monkeypatch, caplog): + import agent.redact as redact + + monkeypatch.setattr(redact, "_REDACT_ENABLED", False) + token = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef" + adapter = _make_adapter() + adapter._bot.edit_message_text = AsyncMock( + side_effect=BadRequest( + f"Bad Request: https://api.telegram.org/bot{token}/editMessageText" + ) + ) + + with caplog.at_level(logging.WARNING): + result = await adapter.edit_message( + "12345", "555", "Just a normal answer.", finalize=True, + ) + + assert result.success is False + assert result.error is not None + assert token not in result.error + assert "bot123456789:***/editMessageText" in result.error + assert token not in caplog.text + assert "bot123456789:***/editMessageText" in caplog.text + + @pytest.mark.asyncio async def test_finalize_edit_cjk_rich_content_stays_legacy_to_avoid_tdesktop_garble(): adapter = _make_adapter()