diff --git a/gateway/config.py b/gateway/config.py index 50743486f65..c99756c3545 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 2464a433242..760196360b8 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 5ebddeec739..0e009ccab4b 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 1f326ba0ba6..cbc18e24865 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 3f6c5e49708..37a1f950944 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 00000000000..bf7a45c3dac --- /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 eb741467826..f754c9c2217 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) +``` ---