mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env. The WhatsApp bridge prepends a header to every outgoing message. This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize or disable it via config.yaml: whatsapp: reply_prefix: '' # disable header reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix How it works: - load_gateway_config() reads whatsapp.reply_prefix from config.yaml and stores it in PlatformConfig.extra['reply_prefix'] - WhatsAppAdapter reads it from config.extra at init - When spawning bridge.js, the adapter passes it as WHATSAPP_REPLY_PREFIX in the subprocess environment - bridge.js handles undefined (default), empty (no header), or custom values with \\n escape support - Self-chat echo suppression uses the configured prefix Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a key 10 (TAVILY_API_KEY), so existing users at v9 would never be prompted for Tavily. Bumped to 10 to close the gap. Added a regression test to prevent this from happening again. Credit: ifrederico (PR #1764) for the bridge.js implementation and the config version gap discovery.
This commit is contained in:
parent
6108723b12
commit
9234bc8944
7 changed files with 167 additions and 9 deletions
|
|
@ -420,6 +420,13 @@ def load_gateway_config() -> GatewayConfig:
|
|||
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
||||
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
||||
|
||||
# Bridge whatsapp settings from config.yaml into platform config
|
||||
whatsapp_cfg = yaml_cfg.get("whatsapp", {})
|
||||
if isinstance(whatsapp_cfg, dict) and "reply_prefix" in whatsapp_cfg:
|
||||
if Platform.WHATSAPP not in config.platforms:
|
||||
config.platforms[Platform.WHATSAPP] = PlatformConfig()
|
||||
config.platforms[Platform.WHATSAPP].extra["reply_prefix"] = whatsapp_cfg["reply_prefix"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
|||
"session_path",
|
||||
get_hermes_home() / "whatsapp" / "session"
|
||||
))
|
||||
self._reply_prefix: Optional[str] = config.extra.get("reply_prefix")
|
||||
self._message_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._bridge_log_fh = None
|
||||
self._bridge_log: Optional[Path] = None
|
||||
|
|
@ -193,6 +194,14 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
|||
self._bridge_log = self._session_path.parent / "bridge.log"
|
||||
bridge_log_fh = open(self._bridge_log, "a")
|
||||
self._bridge_log_fh = bridge_log_fh
|
||||
|
||||
# Build bridge subprocess environment.
|
||||
# Pass WHATSAPP_REPLY_PREFIX from config.yaml so the Node bridge
|
||||
# can use it without the user needing to set a separate env var.
|
||||
bridge_env = os.environ.copy()
|
||||
if self._reply_prefix is not None:
|
||||
bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix
|
||||
|
||||
self._bridge_process = subprocess.Popen(
|
||||
[
|
||||
"node",
|
||||
|
|
@ -204,6 +213,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
|||
stdout=bridge_log_fh,
|
||||
stderr=bridge_log_fh,
|
||||
preexec_fn=None if _IS_WINDOWS else os.setsid,
|
||||
env=bridge_env,
|
||||
)
|
||||
|
||||
# Wait for the bridge to connect to WhatsApp.
|
||||
|
|
|
|||
|
|
@ -333,6 +333,14 @@ DEFAULT_CONFIG = {
|
|||
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
|
||||
},
|
||||
|
||||
# WhatsApp platform settings (gateway mode)
|
||||
"whatsapp": {
|
||||
# Reply prefix prepended to every outgoing WhatsApp message.
|
||||
# Default (None) uses the built-in "⚕ *Hermes Agent*" header.
|
||||
# Set to "" (empty string) to disable the header entirely.
|
||||
# Supports \n for newlines, e.g. "🤖 *My Bot*\n──────\n"
|
||||
},
|
||||
|
||||
# Approval mode for dangerous commands:
|
||||
# manual — always prompt the user (default)
|
||||
# smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk
|
||||
|
|
@ -365,7 +373,7 @@ DEFAULT_CONFIG = {
|
|||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 9,
|
||||
"_config_version": 10,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -44,6 +44,14 @@ const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.herme
|
|||
const PAIR_ONLY = args.includes('--pair-only');
|
||||
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat"
|
||||
const ALLOWED_USERS = (process.env.WHATSAPP_ALLOWED_USERS || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n';
|
||||
const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
|
||||
? DEFAULT_REPLY_PREFIX
|
||||
: process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n');
|
||||
|
||||
function formatOutgoingMessage(message) {
|
||||
return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message;
|
||||
}
|
||||
|
||||
mkdirSync(SESSION_DIR, { recursive: true });
|
||||
|
||||
|
|
@ -188,7 +196,7 @@ async function startSocket() {
|
|||
}
|
||||
|
||||
// Ignore Hermes' own reply messages in self-chat mode to avoid loops.
|
||||
if (msg.key.fromMe && (body.startsWith('⚕ *Hermes Agent*') || recentlySentIds.has(msg.key.id))) {
|
||||
if (msg.key.fromMe && ((REPLY_PREFIX && body.startsWith(REPLY_PREFIX)) || recentlySentIds.has(msg.key.id))) {
|
||||
if (WHATSAPP_DEBUG) {
|
||||
try { console.log(JSON.stringify({ event: 'ignored', reason: 'agent_echo', chatId, messageId: msg.key.id })); } catch {}
|
||||
}
|
||||
|
|
@ -251,10 +259,7 @@ app.post('/send', async (req, res) => {
|
|||
}
|
||||
|
||||
try {
|
||||
// Prefix responses so the user can distinguish agent replies from their
|
||||
// own messages (especially in self-chat / "Message Yourself").
|
||||
const prefixed = `⚕ *Hermes Agent*\n────────────\n${message}`;
|
||||
const sent = await sock.sendMessage(chatId, { text: prefixed });
|
||||
const sent = await sock.sendMessage(chatId, { text: formatOutgoingMessage(message) });
|
||||
|
||||
// Track sent message ID to prevent echo-back loops
|
||||
if (sent?.key?.id) {
|
||||
|
|
@ -282,9 +287,8 @@ app.post('/edit', async (req, res) => {
|
|||
}
|
||||
|
||||
try {
|
||||
const prefixed = `⚕ *Hermes Agent*\n────────────\n${message}`;
|
||||
const key = { id: messageId, fromMe: true, remoteJid: chatId };
|
||||
await sock.sendMessage(chatId, { text: prefixed, edit: key });
|
||||
await sock.sendMessage(chatId, { text: formatOutgoingMessage(message), edit: key });
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ def _make_adapter():
|
|||
adapter._bridge_log_fh = None
|
||||
adapter._bridge_log = None
|
||||
adapter._bridge_process = None
|
||||
adapter._reply_prefix = None
|
||||
adapter._running = False
|
||||
adapter._message_queue = asyncio.Queue()
|
||||
return adapter
|
||||
|
|
|
|||
121
tests/gateway/test_whatsapp_reply_prefix.py
Normal file
121
tests/gateway/test_whatsapp_reply_prefix.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Tests for WhatsApp reply_prefix config.yaml support.
|
||||
|
||||
Covers:
|
||||
- config.yaml whatsapp.reply_prefix bridging into PlatformConfig.extra
|
||||
- WhatsAppAdapter reading reply_prefix from config.extra
|
||||
- Bridge subprocess receiving WHATSAPP_REPLY_PREFIX env var
|
||||
- Config version covers all ENV_VARS_BY_VERSION keys (regression guard)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config bridging from config.yaml
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigYamlBridging:
|
||||
"""Test that whatsapp.reply_prefix in config.yaml flows into PlatformConfig."""
|
||||
|
||||
def test_reply_prefix_bridged_from_yaml(self, tmp_path):
|
||||
"""whatsapp.reply_prefix in config.yaml sets PlatformConfig.extra."""
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text('whatsapp:\n reply_prefix: "Custom Bot"\n')
|
||||
|
||||
with patch("gateway.config.get_hermes_home", return_value=tmp_path):
|
||||
from gateway.config import load_gateway_config
|
||||
# Need to also patch WHATSAPP_ENABLED so the platform exists
|
||||
with patch.dict("os.environ", {"WHATSAPP_ENABLED": "true"}, clear=False):
|
||||
config = load_gateway_config()
|
||||
|
||||
wa_config = config.platforms.get(Platform.WHATSAPP)
|
||||
assert wa_config is not None
|
||||
assert wa_config.extra.get("reply_prefix") == "Custom Bot"
|
||||
|
||||
def test_empty_reply_prefix_bridged(self, tmp_path):
|
||||
"""Empty string reply_prefix disables the header."""
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text('whatsapp:\n reply_prefix: ""\n')
|
||||
|
||||
with patch("gateway.config.get_hermes_home", return_value=tmp_path):
|
||||
from gateway.config import load_gateway_config
|
||||
with patch.dict("os.environ", {"WHATSAPP_ENABLED": "true"}, clear=False):
|
||||
config = load_gateway_config()
|
||||
|
||||
wa_config = config.platforms.get(Platform.WHATSAPP)
|
||||
assert wa_config is not None
|
||||
assert wa_config.extra.get("reply_prefix") == ""
|
||||
|
||||
def test_no_whatsapp_section_no_extra(self, tmp_path):
|
||||
"""Without whatsapp section, no reply_prefix is set."""
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text("timezone: UTC\n")
|
||||
|
||||
with patch("gateway.config.get_hermes_home", return_value=tmp_path):
|
||||
from gateway.config import load_gateway_config
|
||||
with patch.dict("os.environ", {"WHATSAPP_ENABLED": "true"}, clear=False):
|
||||
config = load_gateway_config()
|
||||
|
||||
wa_config = config.platforms.get(Platform.WHATSAPP)
|
||||
assert wa_config is not None
|
||||
assert "reply_prefix" not in wa_config.extra
|
||||
|
||||
def test_whatsapp_section_without_reply_prefix(self, tmp_path):
|
||||
"""whatsapp section present but without reply_prefix key."""
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text("whatsapp:\n other_setting: true\n")
|
||||
|
||||
with patch("gateway.config.get_hermes_home", return_value=tmp_path):
|
||||
from gateway.config import load_gateway_config
|
||||
with patch.dict("os.environ", {"WHATSAPP_ENABLED": "true"}, clear=False):
|
||||
config = load_gateway_config()
|
||||
|
||||
wa_config = config.platforms.get(Platform.WHATSAPP)
|
||||
assert "reply_prefix" not in wa_config.extra
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WhatsAppAdapter __init__
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdapterInit:
|
||||
"""Test that WhatsAppAdapter reads reply_prefix from config.extra."""
|
||||
|
||||
def test_reply_prefix_from_extra(self):
|
||||
from gateway.platforms.whatsapp import WhatsAppAdapter
|
||||
config = PlatformConfig(enabled=True, extra={"reply_prefix": "Bot\\n"})
|
||||
adapter = WhatsAppAdapter(config)
|
||||
assert adapter._reply_prefix == "Bot\\n"
|
||||
|
||||
def test_reply_prefix_default_none(self):
|
||||
from gateway.platforms.whatsapp import WhatsAppAdapter
|
||||
config = PlatformConfig(enabled=True)
|
||||
adapter = WhatsAppAdapter(config)
|
||||
assert adapter._reply_prefix is None
|
||||
|
||||
def test_reply_prefix_empty_string(self):
|
||||
from gateway.platforms.whatsapp import WhatsAppAdapter
|
||||
config = PlatformConfig(enabled=True, extra={"reply_prefix": ""})
|
||||
adapter = WhatsAppAdapter(config)
|
||||
assert adapter._reply_prefix == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config version regression guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigVersionCoverage:
|
||||
"""Ensure _config_version covers all ENV_VARS_BY_VERSION keys."""
|
||||
|
||||
def test_default_config_version_covers_env_var_versions(self):
|
||||
"""_config_version must be >= the highest ENV_VARS_BY_VERSION key."""
|
||||
from hermes_cli.config import DEFAULT_CONFIG, ENV_VARS_BY_VERSION
|
||||
assert DEFAULT_CONFIG["_config_version"] >= max(ENV_VARS_BY_VERSION)
|
||||
|
|
@ -140,7 +140,14 @@ Hermes supports voice on WhatsApp:
|
|||
|
||||
- **Incoming:** Voice messages (`.ogg` opus) are automatically transcribed using the configured STT provider: local `faster-whisper`, Groq Whisper (`GROQ_API_KEY`), or OpenAI Whisper (`VOICE_TOOLS_OPENAI_KEY`)
|
||||
- **Outgoing:** TTS responses are sent as MP3 audio file attachments
|
||||
- Agent responses are prefixed with "⚕ **Hermes Agent**" for easy identification
|
||||
- Agent responses are prefixed with "⚕ **Hermes Agent**" by default. You can customize or disable this in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
whatsapp:
|
||||
reply_prefix: "" # Empty string disables the header
|
||||
# reply_prefix: "🤖 *My Bot*\n──────\n" # Custom prefix (supports \n for newlines)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue