diff --git a/contributors/emails/dhruvkejri9@gmail.com b/contributors/emails/dhruvkejri9@gmail.com new file mode 100644 index 000000000000..11c5b03b7a3d --- /dev/null +++ b/contributors/emails/dhruvkejri9@gmail.com @@ -0,0 +1 @@ +dhruvkej9 diff --git a/gateway/config.py b/gateway/config.py index 3d385e33ed88..63d4f8f3bb0b 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1638,6 +1638,15 @@ def _merge_platform_map(source_platforms: Any) -> None: # (slack, telegram, matrix, dingtalk, whatsapp, feishu …) # instead of re-enabling them on token/SDK presence. #41112. extra["_enabled_explicit"] = True + # Preserve the platform's own nested ``extra:`` dict so + # per-platform settings (e.g. group_sessions_per_user) survive + # the shared-key loop, which only bridges known top-level keys + # and silently dropped anything the user placed under + # ``.extra``. Top-level bridged keys are applied + # after, so they keep precedence ("top-level wins"). + _nested_extra = platform_cfg.get("extra") + if isinstance(_nested_extra, dict): + extra.update(_nested_extra) extra.update(bridged) # Plugin-owned YAML→env config bridges (#24836). See diff --git a/gateway/run.py b/gateway/run.py index 83342c39591b..9add1f7c7dd2 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6723,10 +6723,25 @@ def _session_key_for_source(self, source: SessionSource) -> str: _profile = get_active_profile_name() or "default" except Exception: _profile = None + # Honor per-platform extra overrides (mirrors SessionStore._generate_session_key + # and the adapters' text-batching keys) so the fallback path never diverges + # from the primary path on group/thread session isolation. + _group_sessions_per_user = getattr(config, "group_sessions_per_user", True) + _thread_sessions_per_user = getattr(config, "thread_sessions_per_user", False) + if config is not None and getattr(source, "platform", None) is not None: + _platform_cfg = getattr(config, "platforms", {}).get(source.platform) + if _platform_cfg and isinstance(getattr(_platform_cfg, "extra", None), dict): + _extra = _platform_cfg.extra + _group_sessions_per_user = _extra.get( + "group_sessions_per_user", _group_sessions_per_user + ) + _thread_sessions_per_user = _extra.get( + "thread_sessions_per_user", _thread_sessions_per_user + ) return build_session_key( source, - group_sessions_per_user=getattr(config, "group_sessions_per_user", True), - thread_sessions_per_user=getattr(config, "thread_sessions_per_user", False), + group_sessions_per_user=_group_sessions_per_user, + thread_sessions_per_user=_thread_sessions_per_user, profile=_profile, ) @@ -15999,6 +16014,18 @@ async def _prepare_inbound_message_text( ) or "" _group_sessions_per_user = getattr(self.config, "group_sessions_per_user", True) _thread_sessions_per_user = getattr(self.config, "thread_sessions_per_user", False) + # Resolve per-platform extra overrides so shared-session attribution + # (user_name prefixing) matches the session key that build_session_key + # produced for this source (see _session_key_for_source). + _platform_cfg = getattr(self.config, "platforms", {}).get(source.platform) + if _platform_cfg and isinstance(getattr(_platform_cfg, "extra", None), dict): + _extra = _platform_cfg.extra + _group_sessions_per_user = _extra.get( + "group_sessions_per_user", _group_sessions_per_user + ) + _thread_sessions_per_user = _extra.get( + "thread_sessions_per_user", _thread_sessions_per_user + ) # Prefer the already resolved session key from the caller so this write # key matches the consume key at the run_conversation site. Fall back # to deriving it here for tests and legacy standalone callers. diff --git a/gateway/session.py b/gateway/session.py index fa019133c058..16dea6d4338e 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -1732,13 +1732,38 @@ def _recovered_row_allowed_for_active_profile( def _generate_session_key(self, source: SessionSource) -> str: """Generate a session key from a source.""" + group_sessions_per_user, thread_sessions_per_user = self._resolve_session_isolation( + source + ) return build_session_key( source, - group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True), - thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False), + group_sessions_per_user=group_sessions_per_user, + thread_sessions_per_user=thread_sessions_per_user, profile=self._resolve_profile_for_key(source), ) + def _resolve_session_isolation( + self, source: SessionSource + ) -> tuple[bool, bool]: + """Resolve group/thread session isolation for a source. + + Per-platform ``extra.group_sessions_per_user`` / + ``extra.thread_sessions_per_user`` overrides win when present, + falling back to the global gateway config. This mirrors the + resolution the adapters use for text-batching keys so the main + dispatch path and the adapter's batching key never diverge. + """ + default_group = getattr(self.config, "group_sessions_per_user", True) + default_thread = getattr(self.config, "thread_sessions_per_user", False) + platform_cfg = getattr(self.config, "platforms", {}).get(source.platform) + if platform_cfg and isinstance(getattr(platform_cfg, "extra", None), dict): + extra = platform_cfg.extra + return ( + extra.get("group_sessions_per_user", default_group), + extra.get("thread_sessions_per_user", default_thread), + ) + return default_group, default_thread + def _legacy_slack_session_key(self, source: SessionSource) -> Optional[str]: """Return the pre-workspace Slack key for an explicitly scoped source. diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 56ce666b706b..fa1759b4329c 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -22,10 +22,11 @@ import re import signal import subprocess +import time _IS_WINDOWS = platform.system() == "Windows" from pathlib import Path -from typing import Dict, Optional, Any +from typing import Dict, List, Optional, Any from hermes_cli._subprocess_compat import windows_detach_popen_kwargs from hermes_constants import ( @@ -419,6 +420,10 @@ def __init__(self, config: PlatformConfig): WhatsAppAdapter._DEFAULT_BRIDGE_DIR = resolve_whatsapp_bridge_dir() self._bridge_process: Optional[subprocess.Popen] = None self._bridge_port: int = config.extra.get("bridge_port", 3000) + # chat_id -> (fetch_ts, roster) for the group-member roster injected + # into group message text so the model knows who is in the group. + self._roster_cache: Dict[str, tuple] = {} + self._roster_ttl = 10 * 60 self._bridge_script: Optional[str] = config.extra.get( "bridge_script", str(self._DEFAULT_BRIDGE_DIR / "bridge.js"), @@ -918,12 +923,16 @@ async def send( chat_id: str, content: str, reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None + metadata: Optional[Dict[str, Any]] = None, + mentions: Optional[List[str]] = None, ) -> SendResult: """Send a message via the WhatsApp bridge. Formats markdown for WhatsApp, splits long messages into chunks that preserve code block boundaries, and sends each chunk sequentially. + ``mentions`` (list of WhatsApp JIDs) @-tags those users on the first + chunk; the bridge also auto-tags the author of the replied-to message + in a group. """ if not self._running or not self._http_session: return SendResult(success=False, error="Not connected") @@ -954,6 +963,8 @@ async def send( # Only reply-to on the first text chunk, even if the bridge # response omits a parseable message id. payload["replyTo"] = reply_to + if mentions and idx == 0: + payload["mentions"] = list(mentions) async with self._http_session.post( f"http://127.0.0.1:{self._bridge_port}/send", @@ -1439,6 +1450,32 @@ async def _flush_text_batch(self, key: str) -> None: if self._pending_text_batch_tasks.get(key) is current_task: self._pending_text_batch_tasks.pop(key, None) + async def _get_group_roster(self, chat_id: str) -> list: + """Return [{id, name}] for a group, cached for ``_roster_ttl`` seconds. + + Falls back to the cached roster on fetch failure so a transient bridge + error never blanks out the member list the model has already seen. + """ + now = time.monotonic() + cached = self._roster_cache.get(chat_id) + if cached and now - cached[0] < self._roster_ttl: + return cached[1] + try: + import aiohttp + async with aiohttp.ClientSession() as session: + async with session.get( + f"http://127.0.0.1:{self._bridge_port}/chat/{chat_id}", + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status != 200: + return cached[1] if cached else [] + data = await resp.json() + roster = data.get("participants") or [] + self._roster_cache[chat_id] = (now, roster) + return roster + except Exception: + return cached[1] if cached else [] + async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]: """Build a MessageEvent from bridge message data, downloading images to cache.""" try: @@ -1623,6 +1660,24 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv if not body.startswith(_OWNER_REPLY_PREFIX): body = f"{_OWNER_REPLY_PREFIX}{body}" + # Surface who this message @mentions (bridge extracts + # contextInfo.mentionedJid) so the agent can see who is being + # addressed in a group — e.g. "@user, what do you think?". + mentioned_ids = data.get("mentionedIds") or [] + if mentioned_ids: + metadata["whatsapp_mentioned_ids"] = list(mentioned_ids) + + # Give the model the group roster so it knows who is in the group + # and can @tag a member by display name (the bridge resolves + # "@Name" -> JID at send time). Compact, cached, group-only. + if is_group: + roster = await self._get_group_roster(str(data.get("chatId", ""))) + names = [r.get("name") for r in roster if r.get("name")] + if names: + roster_line = "[Group members: " + ", ".join(names) + "]" + if roster_line not in body: + body = f"{roster_line}\n{body}" + return MessageEvent( text=body, message_type=msg_type, diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 234cbef27931..7e79e241c916 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -39,6 +39,8 @@ import { createVersionResolver, buildLocationPayload, buildTextSendPayload, + buildGroupRoster, + resolveAtNameMentions, createBoundedMessageStore, extractBridgeEvent, inboundReadReceiptKeys, @@ -281,6 +283,26 @@ const MAX_QUEUE_SIZE = 100; const recentlySentIds = createOutboundIdTracker(512); const recentlyProcessedPollUpdates = createOutboundIdTracker(512); const messageStore = createBoundedMessageStore(512); +// jid -> WhatsApp display name (pushName), fed from inbound messages so the +// group roster can show human names instead of bare numbers. +const nameCache = new Map(); +// chatId -> { roster, ts } for @Name mention resolution; refreshed on demand. +const rosterCache = new Map(); +const ROSTER_TTL_MS = 5 * 60 * 1000; + +async function getGroupRoster(chatId) { + if (!chatId.endsWith('@g.us') || !sock) return []; + const cached = rosterCache.get(chatId); + if (cached && Date.now() - cached.ts < ROSTER_TTL_MS) return cached.roster; + try { + const metadata = await sock.groupMetadata(chatId); + const roster = buildGroupRoster(metadata.participants, nameCache); + rosterCache.set(chatId, { roster, ts: Date.now() }); + return roster; + } catch { + return cached ? cached.roster : []; + } +} function normalizePollUpdateOptions(aggregation, pollUpdateMessage, meId) { const selected = []; @@ -546,6 +568,11 @@ async function startSocket() { const senderId = msg.key.participant || chatId; const isGroup = chatId.endsWith('@g.us'); const senderNumber = senderId.replace(/@.*/, ''); + // Feed the name cache from pushName so the group roster can show + // human names. Skip our own messages (no useful display name). + if (msg.pushName && !msg.key.fromMe) { + nameCache.set(normalizeWhatsAppId(senderId), msg.pushName); + } emitDebugEvent({ stage: 'upsert', type, @@ -825,12 +852,21 @@ app.post('/send', async (req, res) => { return res.status(503).json({ error: 'Not connected to WhatsApp' }); } - const { chatId, message, replyTo } = req.body; + const { chatId, message, replyTo, mentions } = req.body; if (!chatId || !message) { return res.status(400).json({ error: 'chatId and message are required' }); } try { + // Resolve "@Name" tags in the text against the group roster when the + // caller didn't pass explicit mentions, so the model can tag a member by + // display name without knowing their JID. + let resolvedMentions = Array.isArray(mentions) ? mentions : []; + if (!resolvedMentions.length && chatId.endsWith('@g.us')) { + const roster = await getGroupRoster(chatId); + resolvedMentions = resolveAtNameMentions(message, roster); + } + const chunks = splitLongMessage(formatOutgoingMessage(message)); const messageIds = []; for (let i = 0; i < chunks.length; i += 1) { @@ -838,6 +874,7 @@ app.post('/send', async (req, res) => { chatId, replyTo: i === 0 ? replyTo : undefined, messageStore, + mentions: resolvedMentions, }); const sent = await sendWithTimeout(chatId, payload, options); trackSentMessageId(sent); @@ -1089,7 +1126,7 @@ app.get('/chat/:id', async (req, res) => { return res.json({ name: metadata.subject, isGroup: true, - participants: metadata.participants.map(p => p.id), + participants: buildGroupRoster(metadata.participants, nameCache), }); } catch { // Fall through to default diff --git a/scripts/whatsapp-bridge/bridge.native.test.mjs b/scripts/whatsapp-bridge/bridge.native.test.mjs index b4e123a265ef..98a534c7965a 100644 --- a/scripts/whatsapp-bridge/bridge.native.test.mjs +++ b/scripts/whatsapp-bridge/bridge.native.test.mjs @@ -15,6 +15,8 @@ import { getAggregateVotesInPollMessage } from '@whiskeysockets/baileys'; import { buildPollPayload, buildTextSendPayload, + buildGroupRoster, + resolveAtNameMentions, createBoundedMessageStore, appendMediaFailureNote, extractBridgeEvent, @@ -83,6 +85,62 @@ import { console.log(' ✓ unresolved replyTo falls back to plain text'); } +// -- mentions (WhatsApp @ tagging) ------------------------------------- +{ + // Explicit mentions pass through to Baileys content.mentions + const { content } = buildTextSendPayload('@user reply', { + chatId: '15551234567@g.us', + mentions: ['15550002222@s.whatsapp.net'], + }); + assert.deepEqual(content, { text: '@user reply', mentions: ['15550002222@s.whatsapp.net'] }); + console.log(' ✓ explicit mentions pass through to Baileys content'); +} + +{ + // No mentions → no mentions key in content + const { content } = buildTextSendPayload('plain reply', { + chatId: '15551234567@g.us', + }); + assert.deepEqual(content, { text: 'plain reply' }); + console.log(' ✓ no mentions key when none requested'); +} + +// -- group roster + @Name resolution ------------------------------------ +{ + const roster = buildGroupRoster( + [ + { id: '15550001111@s.whatsapp.net' }, + { id: '15550002222@s.whatsapp.net', username: 'ankit' }, + { id: '15550003333@s.whatsapp.net' }, + ], + new Map([ + ['15550001111@s.whatsapp.net', 'Dhruv'], + ['15550003333@s.whatsapp.net', 'Ankit Kumar'], + ]), + ); + assert.deepEqual(roster, [ + { id: '15550001111@s.whatsapp.net', name: 'Dhruv' }, + { id: '15550002222@s.whatsapp.net', name: 'ankit' }, + { id: '15550003333@s.whatsapp.net', name: 'Ankit Kumar' }, + ]); + console.log(' ✓ roster prefers pushName, falls back to username/number'); +} + +{ + const roster = [ + { id: '15550001111@s.whatsapp.net', name: 'Dhruv' }, + { id: '15550003333@s.whatsapp.net', name: 'Ankit Kumar' }, + ]; + // Case-insensitive substring match on either side + assert.deepEqual(resolveAtNameMentions('@ankit kya haal', roster), ['15550003333@s.whatsapp.net']); + assert.deepEqual(resolveAtNameMentions('sun @ANKIT ko bata', roster), ['15550003333@s.whatsapp.net']); + // Unknown name → no mention + assert.deepEqual(resolveAtNameMentions('@nobody hi', roster), []); + // No @tokens → no mentions + assert.deepEqual(resolveAtNameMentions('just text', roster), []); + console.log(' ✓ @Name resolves to JID case-insensitively, unknown names ignored'); +} + // -- inbound quote/media/native metadata -------------------------------- { const event = await extractBridgeEvent({ diff --git a/scripts/whatsapp-bridge/bridge_helpers.js b/scripts/whatsapp-bridge/bridge_helpers.js index 398521feee7c..f4397274519a 100644 --- a/scripts/whatsapp-bridge/bridge_helpers.js +++ b/scripts/whatsapp-bridge/bridge_helpers.js @@ -157,7 +157,7 @@ export function pollUpdateForAggregation({ return null; } -export function buildTextSendPayload(text, { replyTo, messageStore } = {}) { +export function buildTextSendPayload(text, { replyTo, messageStore, mentions = [] } = {}) { const content = { text }; const options = {}; const quoted = messageStore?.get(replyTo); @@ -167,9 +167,47 @@ export function buildTextSendPayload(text, { replyTo, messageStore } = {}) { // literal/ignored `quoted` field instead of a native WhatsApp reply. options.quoted = quoted; } + if (mentions.length) { + content.mentions = mentions; + } return { content, options }; } +// Build a human-readable group roster from Baileys participants plus the +// bridge's pushName cache. Names fall back to the participant username +// (@handle) and finally the bare number so the list is never empty. +export function buildGroupRoster(participants, nameCache = new Map()) { + const roster = []; + for (const p of participants || []) { + const id = p?.id; + if (!id) continue; + const name = nameCache.get(id) + || p.username + || id.replace(/@.*/, ''); + roster.push({ id, name }); + } + return roster; +} + +// Resolve "@Name" tokens in outgoing text against a roster to JIDs, so the +// model can tag a group member by display name without knowing their JID. +// Matching is case-insensitive and substring-based on either side. Tokens are +// single words (no spaces) so "@ankit kya haal" resolves "ankit", while +// multi-word names like "Ankit Kumar" still match via substring. +export function resolveAtNameMentions(text, roster) { + const tokens = [...String(text || '').matchAll(/@([\p{L}\p{N}_.-]+)/gu)].map(m => m[1].trim()); + const mentions = []; + for (const token of tokens) { + const t = token.toLowerCase(); + const match = (roster || []).find(r => { + const n = String(r.name || '').toLowerCase(); + return n && (n.includes(t) || t.includes(n)); + }); + if (match && !mentions.includes(match.id)) mentions.push(match.id); + } + return mentions; +} + export function buildLocationPayload({ latitude, longitude, name, address } = {}) { const lat = Number(latitude); const lon = Number(longitude); diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index aeec8fca9ae4..16a40afae9a9 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -257,6 +257,40 @@ def test_roundtrip_preserves_unauthorized_dm_behavior(self): assert restored.unauthorized_dm_behavior == "ignore" assert restored.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair" + def test_top_level_platform_nested_extra_preserved(self, tmp_path, monkeypatch): + """A per-platform setting under ``.extra`` must survive the + shared-key loop in ``load_gateway_config()``. + + Regression: the shared-key loop only bridged known top-level keys + (dm_policy, group_policy, …) and silently dropped the platform's own + nested ``extra:`` dict. So ``whatsapp.extra.group_sessions_per_user: + false`` never reached ``PlatformConfig.extra``, and the session-key + paths (which honor per-platform extra) fell back to the global + default — every group member kept a separate context even though the + config asked for a shared group session. + """ + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "whatsapp:\n" + " enabled: true\n" + " extra:\n" + " group_sessions_per_user: false\n" + " bridge_port: 3000\n" + " group_policy: allowlist\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + wa = config.platforms[Platform.WHATSAPP] + # Nested extra survives the shared-key loop + assert wa.extra.get("group_sessions_per_user") is False + assert wa.extra.get("bridge_port") == 3000 + # Bridged top-level keys still land in extra + assert wa.extra.get("group_policy") == "allowlist" + def test_email_defaults_to_ignore_for_unauthorized_dm_behavior(self): config = GatewayConfig( platforms={Platform.EMAIL: PlatformConfig(enabled=True)}, diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 9b68bbc352b1..09b7d0ae023c 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -687,6 +687,73 @@ def test_store_shares_group_sessions_when_disabled_in_config(self, store): assert second_entry.session_key == "agent:main:discord:group:guild-123" assert first_entry.session_id == second_entry.session_id + def test_store_honors_per_platform_group_sessions_override(self, store): + """Per-platform extra.group_sessions_per_user must win over the global + config in the main session-key path (mirrors the adapters' text-batching + keys). WhatsApp groups with the override set to False share one session + even though the global default is True.""" + from gateway.config import PlatformConfig + + store.config.group_sessions_per_user = True # global default: isolated + store.config.platforms[Platform.WHATSAPP] = PlatformConfig( + enabled=True, + extra={"group_sessions_per_user": False}, + ) + + alice = SessionSource( + platform=Platform.WHATSAPP, + chat_id="120363000000000000@g.us", + chat_type="group", + user_id="alice@lid", + user_name="Alice", + ) + bob = SessionSource( + platform=Platform.WHATSAPP, + chat_id="120363000000000000@g.us", + chat_type="group", + user_id="bob@lid", + user_name="Bob", + ) + + alice_entry = store.get_or_create_session(alice) + bob_entry = store.get_or_create_session(bob) + + # Shared group session — no per-user suffix, both resolve to the same key. + expected = "agent:main:whatsapp:group:120363000000000000@g.us" + assert alice_entry.session_key == expected + assert bob_entry.session_key == expected + assert alice_entry.session_id == bob_entry.session_id + + def test_store_keeps_global_isolation_when_no_platform_override(self, store): + """Without a per-platform override, the global default (isolated + per-user group sessions) must be preserved.""" + from gateway.config import PlatformConfig + + store.config.group_sessions_per_user = True + store.config.platforms[Platform.WHATSAPP] = PlatformConfig(enabled=True) + + alice = SessionSource( + platform=Platform.WHATSAPP, + chat_id="120363000000000000@g.us", + chat_type="group", + user_id="alice@lid", + user_name="Alice", + ) + bob = SessionSource( + platform=Platform.WHATSAPP, + chat_id="120363000000000000@g.us", + chat_type="group", + user_id="bob@lid", + user_name="Bob", + ) + + alice_entry = store.get_or_create_session(alice) + bob_entry = store.get_or_create_session(bob) + + # Isolated per-user sessions (global default) — distinct keys. + assert alice_entry.session_key != bob_entry.session_key + assert alice_entry.session_id != bob_entry.session_id + def test_telegram_dm_includes_chat_id(self): """Non-WhatsApp DMs should also include chat_id to separate users.""" source = SessionSource(