mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-04 17:57:28 +08:00
Compare commits
1 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6aed7debf |
@@ -712,6 +712,13 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
|||||||
name=os.getenv("DISCORD_HOME_CHANNEL_NAME", "Home"),
|
name=os.getenv("DISCORD_HOME_CHANNEL_NAME", "Home"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Reply threading mode for Discord (off/first/all)
|
||||||
|
discord_reply_mode = os.getenv("DISCORD_REPLY_TO_MODE", "").lower()
|
||||||
|
if discord_reply_mode in ("off", "first", "all"):
|
||||||
|
if Platform.DISCORD not in config.platforms:
|
||||||
|
config.platforms[Platform.DISCORD] = PlatformConfig()
|
||||||
|
config.platforms[Platform.DISCORD].reply_to_mode = discord_reply_mode
|
||||||
|
|
||||||
# WhatsApp (typically uses different auth mechanism)
|
# WhatsApp (typically uses different auth mechanism)
|
||||||
whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes")
|
whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes")
|
||||||
if whatsapp_enabled:
|
if whatsapp_enabled:
|
||||||
|
|||||||
@@ -455,6 +455,9 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
self._seen_messages: Dict[str, float] = {}
|
self._seen_messages: Dict[str, float] = {}
|
||||||
self._SEEN_TTL = 300 # 5 minutes
|
self._SEEN_TTL = 300 # 5 minutes
|
||||||
self._SEEN_MAX = 2000 # prune threshold
|
self._SEEN_MAX = 2000 # prune threshold
|
||||||
|
# Reply threading mode: "off" (no replies), "first" (reply on first
|
||||||
|
# chunk only, default), "all" (reply-reference on every chunk).
|
||||||
|
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
"""Connect to Discord and start receiving events."""
|
"""Connect to Discord and start receiving events."""
|
||||||
@@ -774,7 +777,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
message_ids = []
|
message_ids = []
|
||||||
reference = None
|
reference = None
|
||||||
|
|
||||||
if reply_to:
|
if reply_to and self._reply_to_mode != "off":
|
||||||
try:
|
try:
|
||||||
ref_msg = await channel.fetch_message(int(reply_to))
|
ref_msg = await channel.fetch_message(int(reply_to))
|
||||||
reference = ref_msg
|
reference = ref_msg
|
||||||
@@ -782,7 +785,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
logger.debug("Could not fetch reply-to message: %s", e)
|
logger.debug("Could not fetch reply-to message: %s", e)
|
||||||
|
|
||||||
for i, chunk in enumerate(chunks):
|
for i, chunk in enumerate(chunks):
|
||||||
chunk_reference = reference if i == 0 else None
|
if self._reply_to_mode == "all":
|
||||||
|
chunk_reference = reference
|
||||||
|
else: # "first" (default) or "off"
|
||||||
|
chunk_reference = reference if i == 0 else None
|
||||||
try:
|
try:
|
||||||
msg = await channel.send(
|
msg = await channel.send(
|
||||||
content=chunk,
|
content=chunk,
|
||||||
|
|||||||
@@ -999,6 +999,13 @@ OPTIONAL_ENV_VARS = {
|
|||||||
"password": False,
|
"password": False,
|
||||||
"category": "messaging",
|
"category": "messaging",
|
||||||
},
|
},
|
||||||
|
"DISCORD_REPLY_TO_MODE": {
|
||||||
|
"description": "Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)",
|
||||||
|
"prompt": "Discord reply mode (off/first/all)",
|
||||||
|
"url": None,
|
||||||
|
"password": False,
|
||||||
|
"category": "messaging",
|
||||||
|
},
|
||||||
"SLACK_BOT_TOKEN": {
|
"SLACK_BOT_TOKEN": {
|
||||||
"description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. "
|
"description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. "
|
||||||
"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
|
"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
|
||||||
|
|||||||
277
tests/gateway/test_discord_reply_mode.py
Normal file
277
tests/gateway/test_discord_reply_mode.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""Tests for Discord reply_to_mode functionality.
|
||||||
|
|
||||||
|
Covers the threading behavior control for multi-chunk replies:
|
||||||
|
- "off": Never reply-reference to original message
|
||||||
|
- "first": Only first chunk uses reply reference (default)
|
||||||
|
- "all": All chunks reply-reference the original message
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_discord_mock():
|
||||||
|
"""Install a mock discord module when discord.py isn't available."""
|
||||||
|
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||||
|
return
|
||||||
|
|
||||||
|
discord_mod = MagicMock()
|
||||||
|
discord_mod.Intents.default.return_value = MagicMock()
|
||||||
|
discord_mod.Client = MagicMock
|
||||||
|
discord_mod.File = MagicMock
|
||||||
|
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||||
|
discord_mod.Thread = type("Thread", (), {})
|
||||||
|
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||||
|
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
|
||||||
|
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
|
||||||
|
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
|
||||||
|
discord_mod.Interaction = object
|
||||||
|
discord_mod.Embed = MagicMock
|
||||||
|
discord_mod.app_commands = SimpleNamespace(
|
||||||
|
describe=lambda **kwargs: (lambda fn: fn),
|
||||||
|
choices=lambda **kwargs: (lambda fn: fn),
|
||||||
|
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
ext_mod = MagicMock()
|
||||||
|
commands_mod = MagicMock()
|
||||||
|
commands_mod.Bot = MagicMock
|
||||||
|
ext_mod.commands = commands_mod
|
||||||
|
|
||||||
|
sys.modules.setdefault("discord", discord_mod)
|
||||||
|
sys.modules.setdefault("discord.ext", ext_mod)
|
||||||
|
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||||
|
|
||||||
|
|
||||||
|
_ensure_discord_mock()
|
||||||
|
|
||||||
|
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def adapter_factory():
|
||||||
|
"""Factory to create DiscordAdapter with custom reply_to_mode."""
|
||||||
|
def create(reply_to_mode: str = "first"):
|
||||||
|
config = PlatformConfig(enabled=True, token="test-token", reply_to_mode=reply_to_mode)
|
||||||
|
return DiscordAdapter(config)
|
||||||
|
return create
|
||||||
|
|
||||||
|
|
||||||
|
class TestReplyToModeConfig:
|
||||||
|
"""Tests for reply_to_mode configuration loading."""
|
||||||
|
|
||||||
|
def test_default_mode_is_first(self, adapter_factory):
|
||||||
|
adapter = adapter_factory()
|
||||||
|
assert adapter._reply_to_mode == "first"
|
||||||
|
|
||||||
|
def test_off_mode(self, adapter_factory):
|
||||||
|
adapter = adapter_factory(reply_to_mode="off")
|
||||||
|
assert adapter._reply_to_mode == "off"
|
||||||
|
|
||||||
|
def test_first_mode(self, adapter_factory):
|
||||||
|
adapter = adapter_factory(reply_to_mode="first")
|
||||||
|
assert adapter._reply_to_mode == "first"
|
||||||
|
|
||||||
|
def test_all_mode(self, adapter_factory):
|
||||||
|
adapter = adapter_factory(reply_to_mode="all")
|
||||||
|
assert adapter._reply_to_mode == "all"
|
||||||
|
|
||||||
|
def test_invalid_mode_stored_as_is(self, adapter_factory):
|
||||||
|
"""Invalid modes are stored but send() handles them gracefully."""
|
||||||
|
adapter = adapter_factory(reply_to_mode="invalid")
|
||||||
|
assert adapter._reply_to_mode == "invalid"
|
||||||
|
|
||||||
|
def test_none_mode_defaults_to_first(self):
|
||||||
|
config = PlatformConfig(enabled=True, token="test-token")
|
||||||
|
adapter = DiscordAdapter(config)
|
||||||
|
assert adapter._reply_to_mode == "first"
|
||||||
|
|
||||||
|
def test_empty_string_mode_defaults_to_first(self):
|
||||||
|
config = PlatformConfig(enabled=True, token="test-token", reply_to_mode="")
|
||||||
|
adapter = DiscordAdapter(config)
|
||||||
|
assert adapter._reply_to_mode == "first"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_discord_adapter(reply_to_mode: str = "first"):
|
||||||
|
"""Create a DiscordAdapter with mocked client and channel for send() tests."""
|
||||||
|
config = PlatformConfig(enabled=True, token="test-token", reply_to_mode=reply_to_mode)
|
||||||
|
adapter = DiscordAdapter(config)
|
||||||
|
|
||||||
|
# Mock the Discord client and channel
|
||||||
|
mock_channel = AsyncMock()
|
||||||
|
ref_message = MagicMock()
|
||||||
|
mock_channel.fetch_message = AsyncMock(return_value=ref_message)
|
||||||
|
|
||||||
|
sent_msg = MagicMock()
|
||||||
|
sent_msg.id = 42
|
||||||
|
mock_channel.send = AsyncMock(return_value=sent_msg)
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.get_channel = MagicMock(return_value=mock_channel)
|
||||||
|
|
||||||
|
adapter._client = mock_client
|
||||||
|
return adapter, mock_channel, ref_message
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendWithReplyToMode:
|
||||||
|
"""Tests for send() method respecting reply_to_mode."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_off_mode_no_reply_reference(self):
|
||||||
|
adapter, channel, ref_msg = _make_discord_adapter("off")
|
||||||
|
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
|
||||||
|
|
||||||
|
await adapter.send("12345", "test content", reply_to="999")
|
||||||
|
|
||||||
|
# Should never try to fetch the reference message
|
||||||
|
channel.fetch_message.assert_not_called()
|
||||||
|
# All chunks sent without reference
|
||||||
|
for call in channel.send.call_args_list:
|
||||||
|
assert call.kwargs.get("reference") is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_first_mode_only_first_chunk_references(self):
|
||||||
|
adapter, channel, ref_msg = _make_discord_adapter("first")
|
||||||
|
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
|
||||||
|
|
||||||
|
await adapter.send("12345", "test content", reply_to="999")
|
||||||
|
|
||||||
|
# Should fetch the reference message
|
||||||
|
channel.fetch_message.assert_called_once_with(999)
|
||||||
|
calls = channel.send.call_args_list
|
||||||
|
assert len(calls) == 3
|
||||||
|
assert calls[0].kwargs.get("reference") is ref_msg
|
||||||
|
assert calls[1].kwargs.get("reference") is None
|
||||||
|
assert calls[2].kwargs.get("reference") is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_mode_all_chunks_reference(self):
|
||||||
|
adapter, channel, ref_msg = _make_discord_adapter("all")
|
||||||
|
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
|
||||||
|
|
||||||
|
await adapter.send("12345", "test content", reply_to="999")
|
||||||
|
|
||||||
|
channel.fetch_message.assert_called_once_with(999)
|
||||||
|
calls = channel.send.call_args_list
|
||||||
|
assert len(calls) == 3
|
||||||
|
for call in calls:
|
||||||
|
assert call.kwargs.get("reference") is ref_msg
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_reply_to_param_no_reference(self):
|
||||||
|
adapter, channel, ref_msg = _make_discord_adapter("all")
|
||||||
|
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"]
|
||||||
|
|
||||||
|
await adapter.send("12345", "test content", reply_to=None)
|
||||||
|
|
||||||
|
channel.fetch_message.assert_not_called()
|
||||||
|
for call in channel.send.call_args_list:
|
||||||
|
assert call.kwargs.get("reference") is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_single_chunk_respects_first_mode(self):
|
||||||
|
adapter, channel, ref_msg = _make_discord_adapter("first")
|
||||||
|
adapter.truncate_message = lambda content, max_len: ["single chunk"]
|
||||||
|
|
||||||
|
await adapter.send("12345", "test", reply_to="999")
|
||||||
|
|
||||||
|
calls = channel.send.call_args_list
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert calls[0].kwargs.get("reference") is ref_msg
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_single_chunk_off_mode(self):
|
||||||
|
adapter, channel, ref_msg = _make_discord_adapter("off")
|
||||||
|
adapter.truncate_message = lambda content, max_len: ["single chunk"]
|
||||||
|
|
||||||
|
await adapter.send("12345", "test", reply_to="999")
|
||||||
|
|
||||||
|
channel.fetch_message.assert_not_called()
|
||||||
|
calls = channel.send.call_args_list
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert calls[0].kwargs.get("reference") is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_mode_falls_back_to_first_behavior(self):
|
||||||
|
"""Invalid mode behaves like 'first' — only first chunk gets reference."""
|
||||||
|
adapter, channel, ref_msg = _make_discord_adapter("banana")
|
||||||
|
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"]
|
||||||
|
|
||||||
|
await adapter.send("12345", "test", reply_to="999")
|
||||||
|
|
||||||
|
calls = channel.send.call_args_list
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert calls[0].kwargs.get("reference") is ref_msg
|
||||||
|
assert calls[1].kwargs.get("reference") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigSerialization:
|
||||||
|
"""Tests for reply_to_mode serialization (shared with Telegram)."""
|
||||||
|
|
||||||
|
def test_to_dict_includes_reply_to_mode(self):
|
||||||
|
config = PlatformConfig(enabled=True, token="test", reply_to_mode="all")
|
||||||
|
result = config.to_dict()
|
||||||
|
assert result["reply_to_mode"] == "all"
|
||||||
|
|
||||||
|
def test_from_dict_loads_reply_to_mode(self):
|
||||||
|
data = {"enabled": True, "token": "***", "reply_to_mode": "off"}
|
||||||
|
config = PlatformConfig.from_dict(data)
|
||||||
|
assert config.reply_to_mode == "off"
|
||||||
|
|
||||||
|
def test_from_dict_defaults_to_first(self):
|
||||||
|
data = {"enabled": True, "token": "***"}
|
||||||
|
config = PlatformConfig.from_dict(data)
|
||||||
|
assert config.reply_to_mode == "first"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnvVarOverride:
|
||||||
|
"""Tests for DISCORD_REPLY_TO_MODE environment variable override."""
|
||||||
|
|
||||||
|
def _make_config(self):
|
||||||
|
config = GatewayConfig()
|
||||||
|
config.platforms[Platform.DISCORD] = PlatformConfig(enabled=True, token="test")
|
||||||
|
return config
|
||||||
|
|
||||||
|
def test_env_var_sets_off_mode(self):
|
||||||
|
config = self._make_config()
|
||||||
|
with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "off"}, clear=False):
|
||||||
|
_apply_env_overrides(config)
|
||||||
|
assert config.platforms[Platform.DISCORD].reply_to_mode == "off"
|
||||||
|
|
||||||
|
def test_env_var_sets_all_mode(self):
|
||||||
|
config = self._make_config()
|
||||||
|
with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "all"}, clear=False):
|
||||||
|
_apply_env_overrides(config)
|
||||||
|
assert config.platforms[Platform.DISCORD].reply_to_mode == "all"
|
||||||
|
|
||||||
|
def test_env_var_case_insensitive(self):
|
||||||
|
config = self._make_config()
|
||||||
|
with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "ALL"}, clear=False):
|
||||||
|
_apply_env_overrides(config)
|
||||||
|
assert config.platforms[Platform.DISCORD].reply_to_mode == "all"
|
||||||
|
|
||||||
|
def test_env_var_invalid_value_ignored(self):
|
||||||
|
config = self._make_config()
|
||||||
|
with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "banana"}, clear=False):
|
||||||
|
_apply_env_overrides(config)
|
||||||
|
assert config.platforms[Platform.DISCORD].reply_to_mode == "first"
|
||||||
|
|
||||||
|
def test_env_var_empty_value_ignored(self):
|
||||||
|
config = self._make_config()
|
||||||
|
with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": ""}, clear=False):
|
||||||
|
_apply_env_overrides(config)
|
||||||
|
assert config.platforms[Platform.DISCORD].reply_to_mode == "first"
|
||||||
|
|
||||||
|
def test_env_var_creates_platform_config_if_missing(self):
|
||||||
|
"""DISCORD_REPLY_TO_MODE creates PlatformConfig even without DISCORD_BOT_TOKEN."""
|
||||||
|
config = GatewayConfig()
|
||||||
|
assert Platform.DISCORD not in config.platforms
|
||||||
|
with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "off"}, clear=False):
|
||||||
|
_apply_env_overrides(config)
|
||||||
|
assert Platform.DISCORD in config.platforms
|
||||||
|
assert config.platforms[Platform.DISCORD].reply_to_mode == "off"
|
||||||
@@ -175,6 +175,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
|
|||||||
| `DISCORD_REACTIONS` | Enable emoji reactions on messages during processing (default: `true`) |
|
| `DISCORD_REACTIONS` | Enable emoji reactions on messages during processing (default: `true`) |
|
||||||
| `DISCORD_IGNORED_CHANNELS` | Comma-separated channel IDs where the bot never responds |
|
| `DISCORD_IGNORED_CHANNELS` | Comma-separated channel IDs where the bot never responds |
|
||||||
| `DISCORD_NO_THREAD_CHANNELS` | Comma-separated channel IDs where bot responds without auto-threading |
|
| `DISCORD_NO_THREAD_CHANNELS` | Comma-separated channel IDs where bot responds without auto-threading |
|
||||||
|
| `DISCORD_REPLY_TO_MODE` | Reply-reference behavior: `off`, `first` (default), or `all` |
|
||||||
| `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-...`) |
|
| `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-...`) |
|
||||||
| `SLACK_APP_TOKEN` | Slack app-level token (`xapp-...`, required for Socket Mode) |
|
| `SLACK_APP_TOKEN` | Slack app-level token (`xapp-...`, required for Socket Mode) |
|
||||||
| `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs |
|
| `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs |
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede
|
|||||||
| `DISCORD_REACTIONS` | No | `true` | When `true`, the bot adds emoji reactions to messages during processing (👀 when starting, ✅ on success, ❌ on error). Set to `false` to disable reactions entirely. |
|
| `DISCORD_REACTIONS` | No | `true` | When `true`, the bot adds emoji reactions to messages during processing (👀 when starting, ✅ on success, ❌ on error). Set to `false` to disable reactions entirely. |
|
||||||
| `DISCORD_IGNORED_CHANNELS` | No | — | Comma-separated channel IDs where the bot **never** responds, even when `@mentioned`. Takes priority over all other channel settings. |
|
| `DISCORD_IGNORED_CHANNELS` | No | — | Comma-separated channel IDs where the bot **never** responds, even when `@mentioned`. Takes priority over all other channel settings. |
|
||||||
| `DISCORD_NO_THREAD_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds directly in the channel instead of creating a thread. Only relevant when `DISCORD_AUTO_THREAD` is `true`. |
|
| `DISCORD_NO_THREAD_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds directly in the channel instead of creating a thread. Only relevant when `DISCORD_AUTO_THREAD` is `true`. |
|
||||||
|
| `DISCORD_REPLY_TO_MODE` | No | `"first"` | Controls reply-reference behavior: `"off"` — never reply to the original message, `"first"` — reply-reference on the first message chunk only (default), `"all"` — reply-reference on every chunk. |
|
||||||
|
|
||||||
### Config File (`config.yaml`)
|
### Config File (`config.yaml`)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user