mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-14 14:12:44 +00:00
fix(telegram): redact transport error tokens
This commit is contained in:
parent
2b58febe46
commit
c3ab1424e6
2 changed files with 120 additions and 21 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue