mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
feat(gateway): private notice delivery and Slack format_message fixes
Adds platform-level private notice delivery abstraction so operational messages (e.g. sethome prompt) can be sent ephemerally on Slack when configured with `slack.notice_delivery: private`. Changes: - gateway/config.py: _normalize_notice_delivery() + GatewayConfig.get_notice_delivery() with per-platform config bridging - gateway/platforms/base.py: send_private_notice() default implementation (falls through to send()) - gateway/platforms/slack.py: send_private_notice() via chat_postEphemeral - gateway/run.py: _deliver_platform_notice() helper replaces direct adapter.send() for the sethome notice, with private→public fallback - gateway/platforms/slack.py: app_mention handler now forwards to _handle_slack_message (safe due to ts-based dedup) instead of no-op pass, fixing edge-case Slack configs where mentions arrive only as app_mention - gateway/platforms/slack.py format_message: negative lookbehind prevents markdown images (![]()) from becoming broken Slack links; italic regex now requires non-whitespace boundaries so 'a * b * c' stays literal Based on PR #9340 by @probepark.
This commit is contained in:
parent
7cda0e5224
commit
0ab2d752ff
7 changed files with 269 additions and 25 deletions
|
|
@ -213,6 +213,26 @@ class TestGatewayConfigRoundtrip:
|
|||
restored = GatewayConfig.from_dict({"always_log_local": "false"})
|
||||
assert restored.always_log_local is False
|
||||
|
||||
def test_get_notice_delivery_defaults_to_public(self):
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.SLACK: PlatformConfig(enabled=True, token="***")}
|
||||
)
|
||||
|
||||
assert config.get_notice_delivery(Platform.SLACK) == "public"
|
||||
|
||||
def test_get_notice_delivery_honors_platform_override(self):
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.SLACK: PlatformConfig(
|
||||
enabled=True,
|
||||
token="***",
|
||||
extra={"notice_delivery": "private"},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
assert config.get_notice_delivery(Platform.SLACK) == "private"
|
||||
|
||||
|
||||
class TestLoadGatewayConfig:
|
||||
def test_bridges_quick_commands_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
|
|
@ -457,6 +477,22 @@ class TestLoadGatewayConfig:
|
|||
|
||||
assert config.platforms[Platform.TELEGRAM].extra["disable_link_previews"] is True
|
||||
|
||||
def test_bridges_notice_delivery_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"slack:\n"
|
||||
" notice_delivery: private\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.get_notice_delivery(Platform.SLACK) == "private"
|
||||
|
||||
def test_bridges_telegram_proxy_url_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
|
|
|
|||
67
tests/gateway/test_notice_delivery.py
Normal file
67
tests/gateway/test_notice_delivery.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import SendResult
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.SLACK,
|
||||
chat_id="C123",
|
||||
chat_type="channel",
|
||||
user_id="U123",
|
||||
thread_id="111.222",
|
||||
)
|
||||
|
||||
|
||||
def _make_runner(extra=None):
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.SLACK: PlatformConfig(enabled=True, token="***", extra=extra or {})
|
||||
}
|
||||
)
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="public-1"))
|
||||
adapter.send_private_notice = AsyncMock(return_value=SendResult(success=True, message_id="private-1"))
|
||||
runner.adapters = {Platform.SLACK: adapter}
|
||||
return runner, adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_platform_notice_uses_private_delivery_when_configured():
|
||||
runner, adapter = _make_runner(extra={"notice_delivery": "private"})
|
||||
|
||||
await runner._deliver_platform_notice(_make_source(), "hello")
|
||||
|
||||
adapter.send_private_notice.assert_awaited_once_with(
|
||||
"C123",
|
||||
"U123",
|
||||
"hello",
|
||||
metadata={"thread_id": "111.222"},
|
||||
)
|
||||
adapter.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_platform_notice_falls_back_to_public_when_private_fails():
|
||||
runner, adapter = _make_runner(extra={"notice_delivery": "private"})
|
||||
adapter.send_private_notice = AsyncMock(return_value=SendResult(success=False, error="nope"))
|
||||
|
||||
await runner._deliver_platform_notice(_make_source(), "hello")
|
||||
|
||||
adapter.send.assert_awaited_once_with("C123", "hello", metadata={"thread_id": "111.222"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_platform_notice_uses_public_delivery_by_default():
|
||||
runner, adapter = _make_runner()
|
||||
|
||||
await runner._deliver_platform_notice(_make_source(), "hello")
|
||||
|
||||
adapter.send.assert_awaited_once_with("C123", "hello", metadata={"thread_id": "111.222"})
|
||||
adapter.send_private_notice.assert_not_awaited()
|
||||
|
|
@ -518,6 +518,28 @@ class TestSendDocument:
|
|||
sleep_mock.assert_awaited_once()
|
||||
|
||||
|
||||
class TestSendPrivateNotice:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_private_notice_uses_ephemeral_api(self, adapter):
|
||||
adapter._app.client.chat_postEphemeral = AsyncMock(return_value={"message_ts": "123.456"})
|
||||
|
||||
result = await adapter.send_private_notice(
|
||||
chat_id="C123",
|
||||
user_id="U123",
|
||||
content="private hello",
|
||||
metadata={"thread_id": "1234567890.123456"},
|
||||
)
|
||||
|
||||
assert result.success
|
||||
adapter._app.client.chat_postEphemeral.assert_called_once_with(
|
||||
channel="C123",
|
||||
user="U123",
|
||||
text="private hello",
|
||||
mrkdwn=True,
|
||||
thread_ts="1234567890.123456",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSendVideo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1315,6 +1337,16 @@ class TestFormatMessage:
|
|||
result = adapter.format_message("[link](https://x.com?a=1&b=2)")
|
||||
assert result == "<https://x.com?a=1&b=2|link>"
|
||||
|
||||
def test_markdown_image_does_not_create_broken_slack_link(self, adapter):
|
||||
"""Markdown image syntax should not become '!<url|alt>' in Slack."""
|
||||
result = adapter.format_message("")
|
||||
assert result == ""
|
||||
|
||||
def test_literal_asterisks_with_spaces_are_not_treated_as_italic(self, adapter):
|
||||
"""Asterisks used as plain delimiters should stay literal."""
|
||||
result = adapter.format_message("a * b * c")
|
||||
assert result == "a * b * c"
|
||||
|
||||
def test_emoji_shortcodes_passthrough(self, adapter):
|
||||
"""Emoji shortcodes like :smile: pass through unchanged."""
|
||||
assert adapter.format_message(":smile: hello :wave:") == ":smile: hello :wave:"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue