2026-03-11 09:15:34 +01:00
|
|
|
"""Tests for tools/send_message_tool.py."""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import json
|
2026-03-14 19:07:50 -07:00
|
|
|
import os
|
2026-03-14 04:01:46 -07:00
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
2026-03-11 09:15:34 +01:00
|
|
|
from types import SimpleNamespace
|
2026-03-14 04:01:46 -07:00
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
2026-03-11 09:15:34 +01:00
|
|
|
|
|
|
|
|
from gateway.config import Platform
|
fix(tools): chunk long messages in send_message_tool before dispatch (#1552)
* fix: prevent infinite 400 failure loop on context overflow (#1630)
When a gateway session exceeds the model's context window, Anthropic may
return a generic 400 invalid_request_error with just 'Error' as the
message. This bypassed the phrase-based context-length detection,
causing the agent to treat it as a non-retryable client error. Worse,
the failed user message was still persisted to the transcript, making
the session even larger on each attempt — creating an infinite loop.
Three-layer fix:
1. run_agent.py — Fallback heuristic: when a 400 error has a very short
generic message AND the session is large (>40% of context or >80
messages), treat it as a probable context overflow and trigger
compression instead of aborting.
2. run_agent.py + gateway/run.py — Don't persist failed messages:
when the agent returns failed=True before generating any response,
skip writing the user's message to the transcript/DB. This prevents
the session from growing on each failure.
3. gateway/run.py — Smarter error messages: detect context-overflow
failures and suggest /compact or /reset specifically, instead of a
generic 'try again' that will fail identically.
* fix(skills): detect prompt injection patterns and block cache file reads
Adds two security layers to prevent prompt injection via skills hub
cache files (#1558):
1. read_file: blocks direct reads of ~/.hermes/skills/.hub/ directory
(index-cache, catalog files). The 3.5MB clawhub_catalog_v1.json
was the original injection vector — untrusted skill descriptions
in the catalog contained adversarial text that the model executed.
2. skill_view: warns when skills are loaded from outside the trusted
~/.hermes/skills/ directory, and detects common injection patterns
in skill content ("ignore previous instructions", "<system>", etc.).
Cherry-picked from PR #1562 by ygd58.
* fix(tools): chunk long messages in send_message_tool before dispatch (#1552)
Long messages sent via send_message tool or cron delivery silently
failed when exceeding platform limits. Gateway adapters handle this
via truncate_message(), but the standalone senders in send_message_tool
bypassed that entirely.
- Apply truncate_message() chunking in _send_to_platform() before
dispatching to individual platform senders
- Remove naive message[i:i+2000] character split in _send_discord()
in favor of centralized smart splitting
- Attach media files to last chunk only for Telegram
- Add regression tests for chunking and media placement
Cherry-picked from PR #1557 by llbn.
---------
Co-authored-by: buray <ygd58@users.noreply.github.com>
Co-authored-by: lbn <llbn@users.noreply.github.com>
2026-03-17 01:52:43 -07:00
|
|
|
from tools.send_message_tool import _send_telegram, _send_to_platform, send_message_tool
|
2026-03-11 09:15:34 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_async_immediately(coro):
|
|
|
|
|
return asyncio.run(coro)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_config():
|
2026-03-14 04:01:46 -07:00
|
|
|
telegram_cfg = SimpleNamespace(enabled=True, token="***", extra={})
|
2026-03-11 09:15:34 +01:00
|
|
|
return SimpleNamespace(
|
|
|
|
|
platforms={Platform.TELEGRAM: telegram_cfg},
|
|
|
|
|
get_home_channel=lambda _platform: None,
|
|
|
|
|
), telegram_cfg
|
|
|
|
|
|
|
|
|
|
|
2026-03-14 04:01:46 -07:00
|
|
|
def _install_telegram_mock(monkeypatch, bot):
|
feat(telegram): auto-detect HTML tags and use parse_mode=HTML in send_message (#1709)
* feat: interactive MCP tool configuration in hermes tools
Add the ability to selectively enable/disable individual MCP server
tools through the interactive 'hermes tools' TUI.
Changes:
- tools/mcp_tool.py: Add probe_mcp_server_tools() — lightweight function
that temporarily connects to configured MCP servers, discovers their
tools (names + descriptions), and disconnects. No registry side effects.
- hermes_cli/tools_config.py: Add 'Configure MCP tools' option to the
interactive menu. When selected:
1. Probes all enabled MCP servers for their available tools
2. Shows a per-server curses checklist with tool descriptions
3. Pre-selects tools based on existing include/exclude config
4. Writes changes back as tools.exclude entries in config.yaml
5. Reports which servers failed to connect
The existing CLI commands (hermes tools enable/disable server:tool)
continue to work unchanged. This adds the interactive TUI counterpart
so users can browse and toggle MCP tools visually.
Tests: 22 new tests covering probe function edge cases and interactive
flow (pre-selection, exclude/include modes, description truncation,
multi-server handling, error paths).
* feat(telegram): auto-detect HTML tags and use parse_mode=HTML in send_message
When _send_telegram detects HTML tags in the message body, it now sends
with parse_mode='HTML' instead of converting to MarkdownV2. This allows
cron jobs and agents to send rich HTML-formatted Telegram messages with
bold, italic, code blocks, etc. that render correctly.
Detection uses the same regex from PR #1568 by @ashaney:
re.search(r'<[a-zA-Z/][^>]*>', message)
Plain-text and markdown messages continue through the existing
MarkdownV2 pipeline. The HTML fallback path also catches HTML parse
errors and falls back to plain text, matching the existing MarkdownV2
error handling.
Inspired by: github.com/ashaney — PR #1568
2026-03-17 03:56:06 -07:00
|
|
|
parse_mode = SimpleNamespace(MARKDOWN_V2="MarkdownV2", HTML="HTML")
|
2026-03-17 01:44:04 -07:00
|
|
|
constants_mod = SimpleNamespace(ParseMode=parse_mode)
|
|
|
|
|
telegram_mod = SimpleNamespace(Bot=lambda token: bot, constants=constants_mod)
|
2026-03-14 04:01:46 -07:00
|
|
|
monkeypatch.setitem(sys.modules, "telegram", telegram_mod)
|
2026-03-17 01:44:04 -07:00
|
|
|
monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod)
|
2026-03-14 04:01:46 -07:00
|
|
|
|
|
|
|
|
|
fix(slack): comprehensive mrkdwn formatting — 6 bug fixes + 52 tests
Fixes blockquote > escaping, edit_message raw markdown, ***bold italic***
handling, HTML entity double-escaping (&amp;), Wikipedia URL parens
truncation, and step numbering format. Also adds format_message to the
tool-layer _send_to_platform for consistent formatting across all
delivery paths.
Changes:
- Protect Slack entities (<@user>, <https://...|label>, <!here>) from
escaping passes
- Protect blockquote > markers before HTML entity escaping
- Unescape-before-escape for idempotent HTML entity handling
- ***bold italic*** → *_text_* conversion (before **bold** pass)
- URL regex upgraded to handle balanced parentheses
- mrkdwn:True flag on chat_postMessage payloads
- format_message applied in edit_message and send_message_tool
- 52 new tests (format, edit, streaming, splitting, tool chunking)
- Use reversed(dict) idiom for placeholder restoration
Based on PR #3715 by dashed, cherry-picked onto current main.
2026-04-09 13:33:05 -07:00
|
|
|
def _ensure_slack_mock(monkeypatch):
|
|
|
|
|
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
slack_bolt = MagicMock()
|
|
|
|
|
slack_bolt.async_app.AsyncApp = MagicMock
|
|
|
|
|
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
|
|
|
|
|
|
|
|
|
|
slack_sdk = MagicMock()
|
|
|
|
|
slack_sdk.web.async_client.AsyncWebClient = MagicMock
|
|
|
|
|
|
|
|
|
|
for name, mod in [
|
|
|
|
|
("slack_bolt", slack_bolt),
|
|
|
|
|
("slack_bolt.async_app", slack_bolt.async_app),
|
|
|
|
|
("slack_bolt.adapter", slack_bolt.adapter),
|
|
|
|
|
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
|
|
|
|
|
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
|
|
|
|
|
("slack_sdk", slack_sdk),
|
|
|
|
|
("slack_sdk.web", slack_sdk.web),
|
|
|
|
|
("slack_sdk.web.async_client", slack_sdk.web.async_client),
|
|
|
|
|
]:
|
|
|
|
|
monkeypatch.setitem(sys.modules, name, mod)
|
|
|
|
|
|
|
|
|
|
|
2026-03-11 09:15:34 +01:00
|
|
|
class TestSendMessageTool:
|
2026-03-14 19:07:50 -07:00
|
|
|
def test_cron_duplicate_target_is_skipped_and_explained(self):
|
|
|
|
|
home = SimpleNamespace(chat_id="-1001")
|
|
|
|
|
config, _telegram_cfg = _make_config()
|
|
|
|
|
config.get_home_channel = lambda _platform: home
|
|
|
|
|
|
|
|
|
|
with patch.dict(
|
|
|
|
|
os.environ,
|
|
|
|
|
{
|
|
|
|
|
"HERMES_CRON_AUTO_DELIVER_PLATFORM": "telegram",
|
|
|
|
|
"HERMES_CRON_AUTO_DELIVER_CHAT_ID": "-1001",
|
|
|
|
|
},
|
|
|
|
|
clear=False,
|
|
|
|
|
), \
|
|
|
|
|
patch("gateway.config.load_gateway_config", return_value=config), \
|
|
|
|
|
patch("tools.interrupt.is_interrupted", return_value=False), \
|
|
|
|
|
patch("model_tools._run_async", side_effect=_run_async_immediately), \
|
|
|
|
|
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
|
|
|
|
|
patch("gateway.mirror.mirror_to_session", return_value=True) as mirror_mock:
|
|
|
|
|
result = json.loads(
|
|
|
|
|
send_message_tool(
|
|
|
|
|
{
|
|
|
|
|
"action": "send",
|
|
|
|
|
"target": "telegram",
|
|
|
|
|
"message": "hello",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
assert result["skipped"] is True
|
|
|
|
|
assert result["reason"] == "cron_auto_delivery_duplicate_target"
|
|
|
|
|
assert "final response" in result["note"]
|
|
|
|
|
send_mock.assert_not_awaited()
|
|
|
|
|
mirror_mock.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_cron_different_target_still_sends(self):
|
|
|
|
|
config, telegram_cfg = _make_config()
|
|
|
|
|
|
|
|
|
|
with patch.dict(
|
|
|
|
|
os.environ,
|
|
|
|
|
{
|
|
|
|
|
"HERMES_CRON_AUTO_DELIVER_PLATFORM": "telegram",
|
|
|
|
|
"HERMES_CRON_AUTO_DELIVER_CHAT_ID": "-1001",
|
|
|
|
|
},
|
|
|
|
|
clear=False,
|
|
|
|
|
), \
|
|
|
|
|
patch("gateway.config.load_gateway_config", return_value=config), \
|
|
|
|
|
patch("tools.interrupt.is_interrupted", return_value=False), \
|
|
|
|
|
patch("model_tools._run_async", side_effect=_run_async_immediately), \
|
|
|
|
|
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
|
|
|
|
|
patch("gateway.mirror.mirror_to_session", return_value=True) as mirror_mock:
|
|
|
|
|
result = json.loads(
|
|
|
|
|
send_message_tool(
|
|
|
|
|
{
|
|
|
|
|
"action": "send",
|
|
|
|
|
"target": "telegram:-1002",
|
|
|
|
|
"message": "hello",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
assert result.get("skipped") is not True
|
|
|
|
|
send_mock.assert_awaited_once_with(
|
|
|
|
|
Platform.TELEGRAM,
|
|
|
|
|
telegram_cfg,
|
|
|
|
|
"-1002",
|
|
|
|
|
"hello",
|
|
|
|
|
thread_id=None,
|
|
|
|
|
media_files=[],
|
|
|
|
|
)
|
|
|
|
|
mirror_mock.assert_called_once_with("telegram", "-1002", "hello", source_label="cli", thread_id=None)
|
|
|
|
|
|
|
|
|
|
def test_cron_same_chat_different_thread_still_sends(self):
|
|
|
|
|
config, telegram_cfg = _make_config()
|
|
|
|
|
|
|
|
|
|
with patch.dict(
|
|
|
|
|
os.environ,
|
|
|
|
|
{
|
|
|
|
|
"HERMES_CRON_AUTO_DELIVER_PLATFORM": "telegram",
|
|
|
|
|
"HERMES_CRON_AUTO_DELIVER_CHAT_ID": "-1001",
|
|
|
|
|
"HERMES_CRON_AUTO_DELIVER_THREAD_ID": "17585",
|
|
|
|
|
},
|
|
|
|
|
clear=False,
|
|
|
|
|
), \
|
|
|
|
|
patch("gateway.config.load_gateway_config", return_value=config), \
|
|
|
|
|
patch("tools.interrupt.is_interrupted", return_value=False), \
|
|
|
|
|
patch("model_tools._run_async", side_effect=_run_async_immediately), \
|
|
|
|
|
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
|
|
|
|
|
patch("gateway.mirror.mirror_to_session", return_value=True) as mirror_mock:
|
|
|
|
|
result = json.loads(
|
|
|
|
|
send_message_tool(
|
|
|
|
|
{
|
|
|
|
|
"action": "send",
|
|
|
|
|
"target": "telegram:-1001:99999",
|
|
|
|
|
"message": "hello",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
assert result.get("skipped") is not True
|
|
|
|
|
send_mock.assert_awaited_once_with(
|
|
|
|
|
Platform.TELEGRAM,
|
|
|
|
|
telegram_cfg,
|
|
|
|
|
"-1001",
|
|
|
|
|
"hello",
|
|
|
|
|
thread_id="99999",
|
|
|
|
|
media_files=[],
|
|
|
|
|
)
|
|
|
|
|
mirror_mock.assert_called_once_with("telegram", "-1001", "hello", source_label="cli", thread_id="99999")
|
|
|
|
|
|
2026-03-11 09:15:34 +01:00
|
|
|
def test_sends_to_explicit_telegram_topic_target(self):
|
|
|
|
|
config, telegram_cfg = _make_config()
|
|
|
|
|
|
|
|
|
|
with patch("gateway.config.load_gateway_config", return_value=config), \
|
|
|
|
|
patch("tools.interrupt.is_interrupted", return_value=False), \
|
|
|
|
|
patch("model_tools._run_async", side_effect=_run_async_immediately), \
|
|
|
|
|
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
|
|
|
|
|
patch("gateway.mirror.mirror_to_session", return_value=True) as mirror_mock:
|
|
|
|
|
result = json.loads(
|
|
|
|
|
send_message_tool(
|
|
|
|
|
{
|
|
|
|
|
"action": "send",
|
|
|
|
|
"target": "telegram:-1001:17585",
|
|
|
|
|
"message": "hello",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["success"] is True
|
2026-03-14 04:01:46 -07:00
|
|
|
send_mock.assert_awaited_once_with(
|
|
|
|
|
Platform.TELEGRAM,
|
|
|
|
|
telegram_cfg,
|
|
|
|
|
"-1001",
|
|
|
|
|
"hello",
|
|
|
|
|
thread_id="17585",
|
|
|
|
|
media_files=[],
|
|
|
|
|
)
|
2026-03-11 09:15:34 +01:00
|
|
|
mirror_mock.assert_called_once_with("telegram", "-1001", "hello", source_label="cli", thread_id="17585")
|
|
|
|
|
|
|
|
|
|
def test_resolved_telegram_topic_name_preserves_thread_id(self):
|
|
|
|
|
config, telegram_cfg = _make_config()
|
|
|
|
|
|
|
|
|
|
with patch("gateway.config.load_gateway_config", return_value=config), \
|
|
|
|
|
patch("tools.interrupt.is_interrupted", return_value=False), \
|
|
|
|
|
patch("gateway.channel_directory.resolve_channel_name", return_value="-1001:17585"), \
|
|
|
|
|
patch("model_tools._run_async", side_effect=_run_async_immediately), \
|
|
|
|
|
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
|
|
|
|
|
patch("gateway.mirror.mirror_to_session", return_value=True):
|
|
|
|
|
result = json.loads(
|
|
|
|
|
send_message_tool(
|
|
|
|
|
{
|
|
|
|
|
"action": "send",
|
|
|
|
|
"target": "telegram:Coaching Chat / topic 17585",
|
|
|
|
|
"message": "hello",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["success"] is True
|
2026-03-14 04:01:46 -07:00
|
|
|
send_mock.assert_awaited_once_with(
|
|
|
|
|
Platform.TELEGRAM,
|
|
|
|
|
telegram_cfg,
|
|
|
|
|
"-1001",
|
|
|
|
|
"hello",
|
|
|
|
|
thread_id="17585",
|
|
|
|
|
media_files=[],
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-05 02:45:24 +02:00
|
|
|
def test_display_label_target_resolves_via_channel_directory(self, tmp_path):
|
|
|
|
|
config, telegram_cfg = _make_config()
|
|
|
|
|
cache_file = tmp_path / "channel_directory.json"
|
|
|
|
|
cache_file.write_text(json.dumps({
|
|
|
|
|
"updated_at": "2026-01-01T00:00:00",
|
|
|
|
|
"platforms": {
|
|
|
|
|
"telegram": [
|
|
|
|
|
{"id": "-1001:17585", "name": "Coaching Chat / topic 17585", "type": "group"}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file), \
|
|
|
|
|
patch("gateway.config.load_gateway_config", return_value=config), \
|
|
|
|
|
patch("tools.interrupt.is_interrupted", return_value=False), \
|
|
|
|
|
patch("model_tools._run_async", side_effect=_run_async_immediately), \
|
|
|
|
|
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
|
|
|
|
|
patch("gateway.mirror.mirror_to_session", return_value=True):
|
|
|
|
|
result = json.loads(
|
|
|
|
|
send_message_tool(
|
|
|
|
|
{
|
|
|
|
|
"action": "send",
|
|
|
|
|
"target": "telegram:Coaching Chat / topic 17585 (group)",
|
|
|
|
|
"message": "hello",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
send_mock.assert_awaited_once_with(
|
|
|
|
|
Platform.TELEGRAM,
|
|
|
|
|
telegram_cfg,
|
|
|
|
|
"-1001",
|
|
|
|
|
"hello",
|
|
|
|
|
thread_id="17585",
|
|
|
|
|
media_files=[],
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-14 04:01:46 -07:00
|
|
|
def test_media_only_message_uses_placeholder_for_mirroring(self):
|
|
|
|
|
config, telegram_cfg = _make_config()
|
|
|
|
|
|
|
|
|
|
with patch("gateway.config.load_gateway_config", return_value=config), \
|
|
|
|
|
patch("tools.interrupt.is_interrupted", return_value=False), \
|
|
|
|
|
patch("model_tools._run_async", side_effect=_run_async_immediately), \
|
|
|
|
|
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
|
|
|
|
|
patch("gateway.mirror.mirror_to_session", return_value=True) as mirror_mock:
|
|
|
|
|
result = json.loads(
|
|
|
|
|
send_message_tool(
|
|
|
|
|
{
|
|
|
|
|
"action": "send",
|
|
|
|
|
"target": "telegram:-1001",
|
|
|
|
|
"message": "MEDIA:/tmp/example.ogg",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
send_mock.assert_awaited_once_with(
|
|
|
|
|
Platform.TELEGRAM,
|
|
|
|
|
telegram_cfg,
|
|
|
|
|
"-1001",
|
|
|
|
|
"",
|
|
|
|
|
thread_id=None,
|
|
|
|
|
media_files=[("/tmp/example.ogg", False)],
|
|
|
|
|
)
|
|
|
|
|
mirror_mock.assert_called_once_with(
|
|
|
|
|
"telegram",
|
|
|
|
|
"-1001",
|
|
|
|
|
"[Sent audio attachment]",
|
|
|
|
|
source_label="cli",
|
|
|
|
|
thread_id=None,
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-07 01:02:56 +03:00
|
|
|
def test_top_level_send_failure_redacts_query_token(self):
|
|
|
|
|
config, _telegram_cfg = _make_config()
|
|
|
|
|
leaked = "very-secret-query-token-123456"
|
|
|
|
|
|
|
|
|
|
def _raise_and_close(coro):
|
|
|
|
|
coro.close()
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"transport error: https://api.example.com/send?access_token={leaked}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with patch("gateway.config.load_gateway_config", return_value=config), \
|
|
|
|
|
patch("tools.interrupt.is_interrupted", return_value=False), \
|
|
|
|
|
patch("model_tools._run_async", side_effect=_raise_and_close):
|
|
|
|
|
result = json.loads(
|
|
|
|
|
send_message_tool(
|
|
|
|
|
{
|
|
|
|
|
"action": "send",
|
|
|
|
|
"target": "telegram:-1001",
|
|
|
|
|
"message": "hello",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert "error" in result
|
|
|
|
|
assert leaked not in result["error"]
|
|
|
|
|
assert "access_token=***" in result["error"]
|
|
|
|
|
|
2026-03-14 04:01:46 -07:00
|
|
|
|
|
|
|
|
class TestSendTelegramMediaDelivery:
|
|
|
|
|
def test_sends_text_then_photo_for_media_tag(self, tmp_path, monkeypatch):
|
|
|
|
|
image_path = tmp_path / "photo.png"
|
|
|
|
|
image_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32)
|
|
|
|
|
|
|
|
|
|
bot = MagicMock()
|
|
|
|
|
bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1))
|
|
|
|
|
bot.send_photo = AsyncMock(return_value=SimpleNamespace(message_id=2))
|
|
|
|
|
bot.send_video = AsyncMock()
|
|
|
|
|
bot.send_voice = AsyncMock()
|
|
|
|
|
bot.send_audio = AsyncMock()
|
|
|
|
|
bot.send_document = AsyncMock()
|
|
|
|
|
_install_telegram_mock(monkeypatch, bot)
|
|
|
|
|
|
|
|
|
|
result = asyncio.run(
|
|
|
|
|
_send_telegram(
|
|
|
|
|
"token",
|
|
|
|
|
"12345",
|
|
|
|
|
"Hello there",
|
|
|
|
|
media_files=[(str(image_path), False)],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
assert result["message_id"] == "2"
|
|
|
|
|
bot.send_message.assert_awaited_once()
|
|
|
|
|
bot.send_photo.assert_awaited_once()
|
|
|
|
|
sent_text = bot.send_message.await_args.kwargs["text"]
|
|
|
|
|
assert "MEDIA:" not in sent_text
|
|
|
|
|
assert sent_text == "Hello there"
|
|
|
|
|
|
|
|
|
|
def test_sends_voice_for_ogg_with_voice_directive(self, tmp_path, monkeypatch):
|
|
|
|
|
voice_path = tmp_path / "voice.ogg"
|
|
|
|
|
voice_path.write_bytes(b"OggS" + b"\x00" * 32)
|
|
|
|
|
|
|
|
|
|
bot = MagicMock()
|
|
|
|
|
bot.send_message = AsyncMock()
|
|
|
|
|
bot.send_photo = AsyncMock()
|
|
|
|
|
bot.send_video = AsyncMock()
|
|
|
|
|
bot.send_voice = AsyncMock(return_value=SimpleNamespace(message_id=7))
|
|
|
|
|
bot.send_audio = AsyncMock()
|
|
|
|
|
bot.send_document = AsyncMock()
|
|
|
|
|
_install_telegram_mock(monkeypatch, bot)
|
|
|
|
|
|
|
|
|
|
result = asyncio.run(
|
|
|
|
|
_send_telegram(
|
|
|
|
|
"token",
|
|
|
|
|
"12345",
|
|
|
|
|
"",
|
|
|
|
|
media_files=[(str(voice_path), True)],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
bot.send_voice.assert_awaited_once()
|
|
|
|
|
bot.send_audio.assert_not_awaited()
|
|
|
|
|
bot.send_message.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
def test_sends_audio_for_mp3(self, tmp_path, monkeypatch):
|
|
|
|
|
audio_path = tmp_path / "clip.mp3"
|
|
|
|
|
audio_path.write_bytes(b"ID3" + b"\x00" * 32)
|
|
|
|
|
|
|
|
|
|
bot = MagicMock()
|
|
|
|
|
bot.send_message = AsyncMock()
|
|
|
|
|
bot.send_photo = AsyncMock()
|
|
|
|
|
bot.send_video = AsyncMock()
|
|
|
|
|
bot.send_voice = AsyncMock()
|
|
|
|
|
bot.send_audio = AsyncMock(return_value=SimpleNamespace(message_id=8))
|
|
|
|
|
bot.send_document = AsyncMock()
|
|
|
|
|
_install_telegram_mock(monkeypatch, bot)
|
|
|
|
|
|
|
|
|
|
result = asyncio.run(
|
|
|
|
|
_send_telegram(
|
|
|
|
|
"token",
|
|
|
|
|
"12345",
|
|
|
|
|
"",
|
|
|
|
|
media_files=[(str(audio_path), False)],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
bot.send_audio.assert_awaited_once()
|
|
|
|
|
bot.send_voice.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
def test_missing_media_returns_error_without_leaking_raw_tag(self, monkeypatch):
|
|
|
|
|
bot = MagicMock()
|
|
|
|
|
bot.send_message = AsyncMock()
|
|
|
|
|
bot.send_photo = AsyncMock()
|
|
|
|
|
bot.send_video = AsyncMock()
|
|
|
|
|
bot.send_voice = AsyncMock()
|
|
|
|
|
bot.send_audio = AsyncMock()
|
|
|
|
|
bot.send_document = AsyncMock()
|
|
|
|
|
_install_telegram_mock(monkeypatch, bot)
|
|
|
|
|
|
|
|
|
|
result = asyncio.run(
|
|
|
|
|
_send_telegram(
|
|
|
|
|
"token",
|
|
|
|
|
"12345",
|
|
|
|
|
"",
|
|
|
|
|
media_files=[("/tmp/does-not-exist.png", False)],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert "error" in result
|
|
|
|
|
assert "No deliverable text or media remained" in result["error"]
|
|
|
|
|
bot.send_message.assert_not_awaited()
|
fix(tools): chunk long messages in send_message_tool before dispatch (#1552)
* fix: prevent infinite 400 failure loop on context overflow (#1630)
When a gateway session exceeds the model's context window, Anthropic may
return a generic 400 invalid_request_error with just 'Error' as the
message. This bypassed the phrase-based context-length detection,
causing the agent to treat it as a non-retryable client error. Worse,
the failed user message was still persisted to the transcript, making
the session even larger on each attempt — creating an infinite loop.
Three-layer fix:
1. run_agent.py — Fallback heuristic: when a 400 error has a very short
generic message AND the session is large (>40% of context or >80
messages), treat it as a probable context overflow and trigger
compression instead of aborting.
2. run_agent.py + gateway/run.py — Don't persist failed messages:
when the agent returns failed=True before generating any response,
skip writing the user's message to the transcript/DB. This prevents
the session from growing on each failure.
3. gateway/run.py — Smarter error messages: detect context-overflow
failures and suggest /compact or /reset specifically, instead of a
generic 'try again' that will fail identically.
* fix(skills): detect prompt injection patterns and block cache file reads
Adds two security layers to prevent prompt injection via skills hub
cache files (#1558):
1. read_file: blocks direct reads of ~/.hermes/skills/.hub/ directory
(index-cache, catalog files). The 3.5MB clawhub_catalog_v1.json
was the original injection vector — untrusted skill descriptions
in the catalog contained adversarial text that the model executed.
2. skill_view: warns when skills are loaded from outside the trusted
~/.hermes/skills/ directory, and detects common injection patterns
in skill content ("ignore previous instructions", "<system>", etc.).
Cherry-picked from PR #1562 by ygd58.
* fix(tools): chunk long messages in send_message_tool before dispatch (#1552)
Long messages sent via send_message tool or cron delivery silently
failed when exceeding platform limits. Gateway adapters handle this
via truncate_message(), but the standalone senders in send_message_tool
bypassed that entirely.
- Apply truncate_message() chunking in _send_to_platform() before
dispatching to individual platform senders
- Remove naive message[i:i+2000] character split in _send_discord()
in favor of centralized smart splitting
- Attach media files to last chunk only for Telegram
- Add regression tests for chunking and media placement
Cherry-picked from PR #1557 by llbn.
---------
Co-authored-by: buray <ygd58@users.noreply.github.com>
Co-authored-by: lbn <llbn@users.noreply.github.com>
2026-03-17 01:52:43 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Regression: long messages are chunked before platform dispatch
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSendToPlatformChunking:
|
|
|
|
|
def test_long_message_is_chunked(self):
|
|
|
|
|
"""Messages exceeding the platform limit are split into multiple sends."""
|
|
|
|
|
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
|
|
|
|
long_msg = "word " * 1000 # ~5000 chars, well over Discord's 2000 limit
|
|
|
|
|
with patch("tools.send_message_tool._send_discord", send):
|
|
|
|
|
result = asyncio.run(
|
|
|
|
|
_send_to_platform(
|
|
|
|
|
Platform.DISCORD,
|
fix(slack): comprehensive mrkdwn formatting — 6 bug fixes + 52 tests
Fixes blockquote > escaping, edit_message raw markdown, ***bold italic***
handling, HTML entity double-escaping (&amp;), Wikipedia URL parens
truncation, and step numbering format. Also adds format_message to the
tool-layer _send_to_platform for consistent formatting across all
delivery paths.
Changes:
- Protect Slack entities (<@user>, <https://...|label>, <!here>) from
escaping passes
- Protect blockquote > markers before HTML entity escaping
- Unescape-before-escape for idempotent HTML entity handling
- ***bold italic*** → *_text_* conversion (before **bold** pass)
- URL regex upgraded to handle balanced parentheses
- mrkdwn:True flag on chat_postMessage payloads
- format_message applied in edit_message and send_message_tool
- 52 new tests (format, edit, streaming, splitting, tool chunking)
- Use reversed(dict) idiom for placeholder restoration
Based on PR #3715 by dashed, cherry-picked onto current main.
2026-04-09 13:33:05 -07:00
|
|
|
SimpleNamespace(enabled=True, token="***", extra={}),
|
fix(tools): chunk long messages in send_message_tool before dispatch (#1552)
* fix: prevent infinite 400 failure loop on context overflow (#1630)
When a gateway session exceeds the model's context window, Anthropic may
return a generic 400 invalid_request_error with just 'Error' as the
message. This bypassed the phrase-based context-length detection,
causing the agent to treat it as a non-retryable client error. Worse,
the failed user message was still persisted to the transcript, making
the session even larger on each attempt — creating an infinite loop.
Three-layer fix:
1. run_agent.py — Fallback heuristic: when a 400 error has a very short
generic message AND the session is large (>40% of context or >80
messages), treat it as a probable context overflow and trigger
compression instead of aborting.
2. run_agent.py + gateway/run.py — Don't persist failed messages:
when the agent returns failed=True before generating any response,
skip writing the user's message to the transcript/DB. This prevents
the session from growing on each failure.
3. gateway/run.py — Smarter error messages: detect context-overflow
failures and suggest /compact or /reset specifically, instead of a
generic 'try again' that will fail identically.
* fix(skills): detect prompt injection patterns and block cache file reads
Adds two security layers to prevent prompt injection via skills hub
cache files (#1558):
1. read_file: blocks direct reads of ~/.hermes/skills/.hub/ directory
(index-cache, catalog files). The 3.5MB clawhub_catalog_v1.json
was the original injection vector — untrusted skill descriptions
in the catalog contained adversarial text that the model executed.
2. skill_view: warns when skills are loaded from outside the trusted
~/.hermes/skills/ directory, and detects common injection patterns
in skill content ("ignore previous instructions", "<system>", etc.).
Cherry-picked from PR #1562 by ygd58.
* fix(tools): chunk long messages in send_message_tool before dispatch (#1552)
Long messages sent via send_message tool or cron delivery silently
failed when exceeding platform limits. Gateway adapters handle this
via truncate_message(), but the standalone senders in send_message_tool
bypassed that entirely.
- Apply truncate_message() chunking in _send_to_platform() before
dispatching to individual platform senders
- Remove naive message[i:i+2000] character split in _send_discord()
in favor of centralized smart splitting
- Attach media files to last chunk only for Telegram
- Add regression tests for chunking and media placement
Cherry-picked from PR #1557 by llbn.
---------
Co-authored-by: buray <ygd58@users.noreply.github.com>
Co-authored-by: lbn <llbn@users.noreply.github.com>
2026-03-17 01:52:43 -07:00
|
|
|
"ch", long_msg,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
assert send.await_count >= 3
|
|
|
|
|
for call in send.await_args_list:
|
|
|
|
|
assert len(call.args[2]) <= 2020 # each chunk fits the limit
|
|
|
|
|
|
fix(slack): comprehensive mrkdwn formatting — 6 bug fixes + 52 tests
Fixes blockquote > escaping, edit_message raw markdown, ***bold italic***
handling, HTML entity double-escaping (&amp;), Wikipedia URL parens
truncation, and step numbering format. Also adds format_message to the
tool-layer _send_to_platform for consistent formatting across all
delivery paths.
Changes:
- Protect Slack entities (<@user>, <https://...|label>, <!here>) from
escaping passes
- Protect blockquote > markers before HTML entity escaping
- Unescape-before-escape for idempotent HTML entity handling
- ***bold italic*** → *_text_* conversion (before **bold** pass)
- URL regex upgraded to handle balanced parentheses
- mrkdwn:True flag on chat_postMessage payloads
- format_message applied in edit_message and send_message_tool
- 52 new tests (format, edit, streaming, splitting, tool chunking)
- Use reversed(dict) idiom for placeholder restoration
Based on PR #3715 by dashed, cherry-picked onto current main.
2026-04-09 13:33:05 -07:00
|
|
|
def test_slack_messages_are_formatted_before_send(self, monkeypatch):
|
|
|
|
|
_ensure_slack_mock(monkeypatch)
|
|
|
|
|
|
|
|
|
|
import gateway.platforms.slack as slack_mod
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
|
|
|
|
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
|
|
|
|
|
|
|
|
|
with patch("tools.send_message_tool._send_slack", send):
|
|
|
|
|
result = asyncio.run(
|
|
|
|
|
_send_to_platform(
|
|
|
|
|
Platform.SLACK,
|
|
|
|
|
SimpleNamespace(enabled=True, token="***", extra={}),
|
|
|
|
|
"C123",
|
|
|
|
|
"**hello** from [Hermes](<https://example.com>)",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
send.assert_awaited_once_with(
|
|
|
|
|
"***",
|
|
|
|
|
"C123",
|
|
|
|
|
"*hello* from <https://example.com|Hermes>",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_slack_bold_italic_formatted_before_send(self, monkeypatch):
|
|
|
|
|
"""Bold+italic ***text*** survives tool-layer formatting."""
|
|
|
|
|
_ensure_slack_mock(monkeypatch)
|
|
|
|
|
import gateway.platforms.slack as slack_mod
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
|
|
|
|
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
|
|
|
|
with patch("tools.send_message_tool._send_slack", send):
|
|
|
|
|
result = asyncio.run(
|
|
|
|
|
_send_to_platform(
|
|
|
|
|
Platform.SLACK,
|
|
|
|
|
SimpleNamespace(enabled=True, token="***", extra={}),
|
|
|
|
|
"C123",
|
|
|
|
|
"***important*** update",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
sent_text = send.await_args.args[2]
|
|
|
|
|
assert "*_important_*" in sent_text
|
|
|
|
|
|
|
|
|
|
def test_slack_blockquote_formatted_before_send(self, monkeypatch):
|
|
|
|
|
"""Blockquote '>' markers must survive formatting (not escaped to '>')."""
|
|
|
|
|
_ensure_slack_mock(monkeypatch)
|
|
|
|
|
import gateway.platforms.slack as slack_mod
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
|
|
|
|
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
|
|
|
|
with patch("tools.send_message_tool._send_slack", send):
|
|
|
|
|
result = asyncio.run(
|
|
|
|
|
_send_to_platform(
|
|
|
|
|
Platform.SLACK,
|
|
|
|
|
SimpleNamespace(enabled=True, token="***", extra={}),
|
|
|
|
|
"C123",
|
|
|
|
|
"> important quote\n\nnormal text & stuff",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
sent_text = send.await_args.args[2]
|
|
|
|
|
assert sent_text.startswith("> important quote")
|
|
|
|
|
assert "&" in sent_text # & is escaped
|
|
|
|
|
assert ">" not in sent_text.split("\n")[0] # > in blockquote is NOT escaped
|
|
|
|
|
|
|
|
|
|
def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch):
|
|
|
|
|
"""Pre-escaped HTML entities survive tool-layer formatting without double-escaping."""
|
|
|
|
|
_ensure_slack_mock(monkeypatch)
|
|
|
|
|
import gateway.platforms.slack as slack_mod
|
|
|
|
|
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
|
|
|
|
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
|
|
|
|
with patch("tools.send_message_tool._send_slack", send):
|
|
|
|
|
result = asyncio.run(
|
|
|
|
|
_send_to_platform(
|
|
|
|
|
Platform.SLACK,
|
|
|
|
|
SimpleNamespace(enabled=True, token="***", extra={}),
|
|
|
|
|
"C123",
|
|
|
|
|
"AT&T <tag> test",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
sent_text = send.await_args.args[2]
|
|
|
|
|
assert "&amp;" not in sent_text
|
|
|
|
|
assert "&lt;" not in sent_text
|
|
|
|
|
assert "AT&T" in sent_text
|
|
|
|
|
|
|
|
|
|
def test_slack_url_with_parens_formatted_before_send(self, monkeypatch):
|
|
|
|
|
"""Wikipedia-style URL with parens survives tool-layer formatting."""
|
|
|
|
|
_ensure_slack_mock(monkeypatch)
|
|
|
|
|
import gateway.platforms.slack as slack_mod
|
|
|
|
|
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
|
|
|
|
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
|
|
|
|
with patch("tools.send_message_tool._send_slack", send):
|
|
|
|
|
result = asyncio.run(
|
|
|
|
|
_send_to_platform(
|
|
|
|
|
Platform.SLACK,
|
|
|
|
|
SimpleNamespace(enabled=True, token="***", extra={}),
|
|
|
|
|
"C123",
|
|
|
|
|
"See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
sent_text = send.await_args.args[2]
|
|
|
|
|
assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in sent_text
|
|
|
|
|
|
fix(tools): chunk long messages in send_message_tool before dispatch (#1552)
* fix: prevent infinite 400 failure loop on context overflow (#1630)
When a gateway session exceeds the model's context window, Anthropic may
return a generic 400 invalid_request_error with just 'Error' as the
message. This bypassed the phrase-based context-length detection,
causing the agent to treat it as a non-retryable client error. Worse,
the failed user message was still persisted to the transcript, making
the session even larger on each attempt — creating an infinite loop.
Three-layer fix:
1. run_agent.py — Fallback heuristic: when a 400 error has a very short
generic message AND the session is large (>40% of context or >80
messages), treat it as a probable context overflow and trigger
compression instead of aborting.
2. run_agent.py + gateway/run.py — Don't persist failed messages:
when the agent returns failed=True before generating any response,
skip writing the user's message to the transcript/DB. This prevents
the session from growing on each failure.
3. gateway/run.py — Smarter error messages: detect context-overflow
failures and suggest /compact or /reset specifically, instead of a
generic 'try again' that will fail identically.
* fix(skills): detect prompt injection patterns and block cache file reads
Adds two security layers to prevent prompt injection via skills hub
cache files (#1558):
1. read_file: blocks direct reads of ~/.hermes/skills/.hub/ directory
(index-cache, catalog files). The 3.5MB clawhub_catalog_v1.json
was the original injection vector — untrusted skill descriptions
in the catalog contained adversarial text that the model executed.
2. skill_view: warns when skills are loaded from outside the trusted
~/.hermes/skills/ directory, and detects common injection patterns
in skill content ("ignore previous instructions", "<system>", etc.).
Cherry-picked from PR #1562 by ygd58.
* fix(tools): chunk long messages in send_message_tool before dispatch (#1552)
Long messages sent via send_message tool or cron delivery silently
failed when exceeding platform limits. Gateway adapters handle this
via truncate_message(), but the standalone senders in send_message_tool
bypassed that entirely.
- Apply truncate_message() chunking in _send_to_platform() before
dispatching to individual platform senders
- Remove naive message[i:i+2000] character split in _send_discord()
in favor of centralized smart splitting
- Attach media files to last chunk only for Telegram
- Add regression tests for chunking and media placement
Cherry-picked from PR #1557 by llbn.
---------
Co-authored-by: buray <ygd58@users.noreply.github.com>
Co-authored-by: lbn <llbn@users.noreply.github.com>
2026-03-17 01:52:43 -07:00
|
|
|
def test_telegram_media_attaches_to_last_chunk(self):
|
fix(slack): comprehensive mrkdwn formatting — 6 bug fixes + 52 tests
Fixes blockquote > escaping, edit_message raw markdown, ***bold italic***
handling, HTML entity double-escaping (&amp;), Wikipedia URL parens
truncation, and step numbering format. Also adds format_message to the
tool-layer _send_to_platform for consistent formatting across all
delivery paths.
Changes:
- Protect Slack entities (<@user>, <https://...|label>, <!here>) from
escaping passes
- Protect blockquote > markers before HTML entity escaping
- Unescape-before-escape for idempotent HTML entity handling
- ***bold italic*** → *_text_* conversion (before **bold** pass)
- URL regex upgraded to handle balanced parentheses
- mrkdwn:True flag on chat_postMessage payloads
- format_message applied in edit_message and send_message_tool
- 52 new tests (format, edit, streaming, splitting, tool chunking)
- Use reversed(dict) idiom for placeholder restoration
Based on PR #3715 by dashed, cherry-picked onto current main.
2026-04-09 13:33:05 -07:00
|
|
|
|
fix(tools): chunk long messages in send_message_tool before dispatch (#1552)
* fix: prevent infinite 400 failure loop on context overflow (#1630)
When a gateway session exceeds the model's context window, Anthropic may
return a generic 400 invalid_request_error with just 'Error' as the
message. This bypassed the phrase-based context-length detection,
causing the agent to treat it as a non-retryable client error. Worse,
the failed user message was still persisted to the transcript, making
the session even larger on each attempt — creating an infinite loop.
Three-layer fix:
1. run_agent.py — Fallback heuristic: when a 400 error has a very short
generic message AND the session is large (>40% of context or >80
messages), treat it as a probable context overflow and trigger
compression instead of aborting.
2. run_agent.py + gateway/run.py — Don't persist failed messages:
when the agent returns failed=True before generating any response,
skip writing the user's message to the transcript/DB. This prevents
the session from growing on each failure.
3. gateway/run.py — Smarter error messages: detect context-overflow
failures and suggest /compact or /reset specifically, instead of a
generic 'try again' that will fail identically.
* fix(skills): detect prompt injection patterns and block cache file reads
Adds two security layers to prevent prompt injection via skills hub
cache files (#1558):
1. read_file: blocks direct reads of ~/.hermes/skills/.hub/ directory
(index-cache, catalog files). The 3.5MB clawhub_catalog_v1.json
was the original injection vector — untrusted skill descriptions
in the catalog contained adversarial text that the model executed.
2. skill_view: warns when skills are loaded from outside the trusted
~/.hermes/skills/ directory, and detects common injection patterns
in skill content ("ignore previous instructions", "<system>", etc.).
Cherry-picked from PR #1562 by ygd58.
* fix(tools): chunk long messages in send_message_tool before dispatch (#1552)
Long messages sent via send_message tool or cron delivery silently
failed when exceeding platform limits. Gateway adapters handle this
via truncate_message(), but the standalone senders in send_message_tool
bypassed that entirely.
- Apply truncate_message() chunking in _send_to_platform() before
dispatching to individual platform senders
- Remove naive message[i:i+2000] character split in _send_discord()
in favor of centralized smart splitting
- Attach media files to last chunk only for Telegram
- Add regression tests for chunking and media placement
Cherry-picked from PR #1557 by llbn.
---------
Co-authored-by: buray <ygd58@users.noreply.github.com>
Co-authored-by: lbn <llbn@users.noreply.github.com>
2026-03-17 01:52:43 -07:00
|
|
|
sent_calls = []
|
|
|
|
|
|
|
|
|
|
async def fake_send(token, chat_id, message, media_files=None, thread_id=None):
|
|
|
|
|
sent_calls.append(media_files or [])
|
|
|
|
|
return {"success": True, "platform": "telegram", "chat_id": chat_id, "message_id": str(len(sent_calls))}
|
|
|
|
|
|
|
|
|
|
long_msg = "word " * 2000 # ~10000 chars, well over 4096
|
|
|
|
|
media = [("/tmp/photo.png", False)]
|
|
|
|
|
with patch("tools.send_message_tool._send_telegram", fake_send):
|
|
|
|
|
asyncio.run(
|
|
|
|
|
_send_to_platform(
|
|
|
|
|
Platform.TELEGRAM,
|
|
|
|
|
SimpleNamespace(enabled=True, token="tok", extra={}),
|
|
|
|
|
"123", long_msg, media_files=media,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert len(sent_calls) >= 3
|
|
|
|
|
assert all(call == [] for call in sent_calls[:-1])
|
|
|
|
|
assert sent_calls[-1] == media
|
feat(telegram): auto-detect HTML tags and use parse_mode=HTML in send_message (#1709)
* feat: interactive MCP tool configuration in hermes tools
Add the ability to selectively enable/disable individual MCP server
tools through the interactive 'hermes tools' TUI.
Changes:
- tools/mcp_tool.py: Add probe_mcp_server_tools() — lightweight function
that temporarily connects to configured MCP servers, discovers their
tools (names + descriptions), and disconnects. No registry side effects.
- hermes_cli/tools_config.py: Add 'Configure MCP tools' option to the
interactive menu. When selected:
1. Probes all enabled MCP servers for their available tools
2. Shows a per-server curses checklist with tool descriptions
3. Pre-selects tools based on existing include/exclude config
4. Writes changes back as tools.exclude entries in config.yaml
5. Reports which servers failed to connect
The existing CLI commands (hermes tools enable/disable server:tool)
continue to work unchanged. This adds the interactive TUI counterpart
so users can browse and toggle MCP tools visually.
Tests: 22 new tests covering probe function edge cases and interactive
flow (pre-selection, exclude/include modes, description truncation,
multi-server handling, error paths).
* feat(telegram): auto-detect HTML tags and use parse_mode=HTML in send_message
When _send_telegram detects HTML tags in the message body, it now sends
with parse_mode='HTML' instead of converting to MarkdownV2. This allows
cron jobs and agents to send rich HTML-formatted Telegram messages with
bold, italic, code blocks, etc. that render correctly.
Detection uses the same regex from PR #1568 by @ashaney:
re.search(r'<[a-zA-Z/][^>]*>', message)
Plain-text and markdown messages continue through the existing
MarkdownV2 pipeline. The HTML fallback path also catches HTML parse
errors and falls back to plain text, matching the existing MarkdownV2
error handling.
Inspired by: github.com/ashaney — PR #1568
2026-03-17 03:56:06 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# HTML auto-detection in Telegram send
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
2026-03-17 15:31:13 +00:00
|
|
|
class TestSendToPlatformWhatsapp:
|
|
|
|
|
def test_whatsapp_routes_via_local_bridge_sender(self):
|
2026-03-17 15:38:37 +00:00
|
|
|
chat_id = "test-user@lid"
|
|
|
|
|
async_mock = AsyncMock(return_value={"success": True, "platform": "whatsapp", "chat_id": chat_id, "message_id": "abc123"})
|
2026-03-17 15:31:13 +00:00
|
|
|
|
|
|
|
|
with patch("tools.send_message_tool._send_whatsapp", async_mock):
|
|
|
|
|
result = asyncio.run(
|
|
|
|
|
_send_to_platform(
|
|
|
|
|
Platform.WHATSAPP,
|
|
|
|
|
SimpleNamespace(enabled=True, token=None, extra={"bridge_port": 3000}),
|
2026-03-17 15:38:37 +00:00
|
|
|
chat_id,
|
2026-03-17 15:31:13 +00:00
|
|
|
"hello from hermes",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["success"] is True
|
2026-03-17 15:38:37 +00:00
|
|
|
async_mock.assert_awaited_once_with({"bridge_port": 3000}, chat_id, "hello from hermes")
|
2026-03-17 15:31:13 +00:00
|
|
|
|
|
|
|
|
|
feat(telegram): auto-detect HTML tags and use parse_mode=HTML in send_message (#1709)
* feat: interactive MCP tool configuration in hermes tools
Add the ability to selectively enable/disable individual MCP server
tools through the interactive 'hermes tools' TUI.
Changes:
- tools/mcp_tool.py: Add probe_mcp_server_tools() — lightweight function
that temporarily connects to configured MCP servers, discovers their
tools (names + descriptions), and disconnects. No registry side effects.
- hermes_cli/tools_config.py: Add 'Configure MCP tools' option to the
interactive menu. When selected:
1. Probes all enabled MCP servers for their available tools
2. Shows a per-server curses checklist with tool descriptions
3. Pre-selects tools based on existing include/exclude config
4. Writes changes back as tools.exclude entries in config.yaml
5. Reports which servers failed to connect
The existing CLI commands (hermes tools enable/disable server:tool)
continue to work unchanged. This adds the interactive TUI counterpart
so users can browse and toggle MCP tools visually.
Tests: 22 new tests covering probe function edge cases and interactive
flow (pre-selection, exclude/include modes, description truncation,
multi-server handling, error paths).
* feat(telegram): auto-detect HTML tags and use parse_mode=HTML in send_message
When _send_telegram detects HTML tags in the message body, it now sends
with parse_mode='HTML' instead of converting to MarkdownV2. This allows
cron jobs and agents to send rich HTML-formatted Telegram messages with
bold, italic, code blocks, etc. that render correctly.
Detection uses the same regex from PR #1568 by @ashaney:
re.search(r'<[a-zA-Z/][^>]*>', message)
Plain-text and markdown messages continue through the existing
MarkdownV2 pipeline. The HTML fallback path also catches HTML parse
errors and falls back to plain text, matching the existing MarkdownV2
error handling.
Inspired by: github.com/ashaney — PR #1568
2026-03-17 03:56:06 -07:00
|
|
|
class TestSendTelegramHtmlDetection:
|
|
|
|
|
"""Verify that messages containing HTML tags are sent with parse_mode=HTML
|
|
|
|
|
and that plain / markdown messages use MarkdownV2."""
|
|
|
|
|
|
|
|
|
|
def _make_bot(self):
|
|
|
|
|
bot = MagicMock()
|
|
|
|
|
bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1))
|
|
|
|
|
bot.send_photo = AsyncMock()
|
|
|
|
|
bot.send_video = AsyncMock()
|
|
|
|
|
bot.send_voice = AsyncMock()
|
|
|
|
|
bot.send_audio = AsyncMock()
|
|
|
|
|
bot.send_document = AsyncMock()
|
|
|
|
|
return bot
|
|
|
|
|
|
|
|
|
|
def test_html_message_uses_html_parse_mode(self, monkeypatch):
|
|
|
|
|
bot = self._make_bot()
|
|
|
|
|
_install_telegram_mock(monkeypatch, bot)
|
|
|
|
|
|
|
|
|
|
asyncio.run(
|
|
|
|
|
_send_telegram("tok", "123", "<b>Hello</b> world")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
bot.send_message.assert_awaited_once()
|
|
|
|
|
kwargs = bot.send_message.await_args.kwargs
|
|
|
|
|
assert kwargs["parse_mode"] == "HTML"
|
|
|
|
|
assert kwargs["text"] == "<b>Hello</b> world"
|
|
|
|
|
|
|
|
|
|
def test_plain_text_uses_markdown_v2(self, monkeypatch):
|
|
|
|
|
bot = self._make_bot()
|
|
|
|
|
_install_telegram_mock(monkeypatch, bot)
|
|
|
|
|
|
|
|
|
|
asyncio.run(
|
|
|
|
|
_send_telegram("tok", "123", "Just plain text, no tags")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
bot.send_message.assert_awaited_once()
|
|
|
|
|
kwargs = bot.send_message.await_args.kwargs
|
|
|
|
|
assert kwargs["parse_mode"] == "MarkdownV2"
|
|
|
|
|
|
|
|
|
|
def test_html_with_code_and_pre_tags(self, monkeypatch):
|
|
|
|
|
bot = self._make_bot()
|
|
|
|
|
_install_telegram_mock(monkeypatch, bot)
|
|
|
|
|
|
|
|
|
|
html = "<pre>code block</pre> and <code>inline</code>"
|
|
|
|
|
asyncio.run(_send_telegram("tok", "123", html))
|
|
|
|
|
|
|
|
|
|
kwargs = bot.send_message.await_args.kwargs
|
|
|
|
|
assert kwargs["parse_mode"] == "HTML"
|
|
|
|
|
|
|
|
|
|
def test_closing_tag_detected(self, monkeypatch):
|
|
|
|
|
bot = self._make_bot()
|
|
|
|
|
_install_telegram_mock(monkeypatch, bot)
|
|
|
|
|
|
|
|
|
|
asyncio.run(_send_telegram("tok", "123", "text </div> more"))
|
|
|
|
|
|
|
|
|
|
kwargs = bot.send_message.await_args.kwargs
|
|
|
|
|
assert kwargs["parse_mode"] == "HTML"
|
|
|
|
|
|
|
|
|
|
def test_angle_brackets_in_math_not_detected(self, monkeypatch):
|
|
|
|
|
"""Expressions like 'x < 5' or '3 > 2' should not trigger HTML mode."""
|
|
|
|
|
bot = self._make_bot()
|
|
|
|
|
_install_telegram_mock(monkeypatch, bot)
|
|
|
|
|
|
|
|
|
|
asyncio.run(_send_telegram("tok", "123", "if x < 5 then y > 2"))
|
|
|
|
|
|
|
|
|
|
kwargs = bot.send_message.await_args.kwargs
|
|
|
|
|
assert kwargs["parse_mode"] == "MarkdownV2"
|
|
|
|
|
|
|
|
|
|
def test_html_parse_failure_falls_back_to_plain(self, monkeypatch):
|
|
|
|
|
"""If Telegram rejects the HTML, fall back to plain text."""
|
|
|
|
|
bot = self._make_bot()
|
|
|
|
|
bot.send_message = AsyncMock(
|
|
|
|
|
side_effect=[
|
|
|
|
|
Exception("Bad Request: can't parse entities: unsupported html tag"),
|
|
|
|
|
SimpleNamespace(message_id=2), # plain fallback succeeds
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
_install_telegram_mock(monkeypatch, bot)
|
|
|
|
|
|
|
|
|
|
result = asyncio.run(
|
|
|
|
|
_send_telegram("tok", "123", "<invalid>broken html</invalid>")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
assert bot.send_message.await_count == 2
|
|
|
|
|
second_call = bot.send_message.await_args_list[1].kwargs
|
|
|
|
|
assert second_call["parse_mode"] is None
|