fix(telegram): redact transport error tokens

This commit is contained in:
luyifan 2026-07-05 00:44:49 +08:00 committed by Teknium
parent 2b58febe46
commit c3ab1424e6
2 changed files with 120 additions and 21 deletions

View file

@ -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 "<telegram error redacted>"
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.

View file

@ -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()