From 9234bc89443103b2fbfb9d74e1c9da35be01c1a2 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 17 Mar 2026 10:40:10 -0700 Subject: [PATCH] feat(whatsapp): make reply prefix configurable via config.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- gateway/config.py | 7 + gateway/platforms/whatsapp.py | 10 ++ hermes_cli/config.py | 10 +- scripts/whatsapp-bridge/bridge.js | 18 ++- tests/gateway/test_whatsapp_connect.py | 1 + tests/gateway/test_whatsapp_reply_prefix.py | 121 ++++++++++++++++++ website/docs/user-guide/messaging/whatsapp.md | 9 +- 7 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 tests/gateway/test_whatsapp_reply_prefix.py diff --git a/gateway/config.py b/gateway/config.py index 50743486f650..c99756c35459 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -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 diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 2464a433242e..760196360b8e 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -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. diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5ebddeec7392..0e009ccab4b8 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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, } # ============================================================================= diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 1f326ba0ba6e..cbc18e248659 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -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 }); diff --git a/tests/gateway/test_whatsapp_connect.py b/tests/gateway/test_whatsapp_connect.py index 3f6c5e49708d..37a1f950944e 100644 --- a/tests/gateway/test_whatsapp_connect.py +++ b/tests/gateway/test_whatsapp_connect.py @@ -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 diff --git a/tests/gateway/test_whatsapp_reply_prefix.py b/tests/gateway/test_whatsapp_reply_prefix.py new file mode 100644 index 000000000000..bf7a45c3dacd --- /dev/null +++ b/tests/gateway/test_whatsapp_reply_prefix.py @@ -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) diff --git a/website/docs/user-guide/messaging/whatsapp.md b/website/docs/user-guide/messaging/whatsapp.md index eb741467826d..f754c9c2217c 100644 --- a/website/docs/user-guide/messaging/whatsapp.md +++ b/website/docs/user-guide/messaging/whatsapp.md @@ -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) +``` ---