Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contributors/emails/dhruvkejri9@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dhruvkej9
9 changes: 9 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ``<platform>.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
Expand Down
31 changes: 29 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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.
Expand Down
29 changes: 27 additions & 2 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
59 changes: 57 additions & 2 deletions plugins/platforms/whatsapp/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
41 changes: 39 additions & 2 deletions scripts/whatsapp-bridge/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
createVersionResolver,
buildLocationPayload,
buildTextSendPayload,
buildGroupRoster,
resolveAtNameMentions,
createBoundedMessageStore,
extractBridgeEvent,
inboundReadReceiptKeys,
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -825,19 +852,29 @@ 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) {
const { content: payload, options } = buildTextSendPayload(chunks[i], {
chatId,
replyTo: i === 0 ? replyTo : undefined,
messageStore,
mentions: resolvedMentions,
});
const sent = await sendWithTimeout(chatId, payload, options);
trackSentMessageId(sent);
Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions scripts/whatsapp-bridge/bridge.native.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { getAggregateVotesInPollMessage } from '@whiskeysockets/baileys';
import {
buildPollPayload,
buildTextSendPayload,
buildGroupRoster,
resolveAtNameMentions,
createBoundedMessageStore,
appendMediaFailureNote,
extractBridgeEvent,
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading