Skip to content
Closed
23 changes: 23 additions & 0 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ def _create_agent(
session_id: Optional[str] = None,
stream_delta_callback=None,
tool_progress_callback=None,
user_id: Optional[str] = None,
) -> Any:
"""
Create an AIAgent instance using the gateway's runtime config.
Expand Down Expand Up @@ -486,6 +487,7 @@ def _create_agent(
enabled_toolsets=enabled_toolsets,
session_id=session_id,
platform="api_server",
user_id=user_id,
stream_delta_callback=stream_delta_callback,
tool_progress_callback=tool_progress_callback,
session_db=self._ensure_session_db(),
Expand Down Expand Up @@ -572,6 +574,13 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons
status=400,
)

# Per-user memory scoping: callers pass X-Hermes-User-Id so
# memory plugins (holographic, mem0, honcho) isolate facts per
# user. When absent, all facts are visible (CLI behaviour).
provided_user_id = request.headers.get(
"X-Hermes-User-Id", ""
).strip() or None

# Allow caller to continue an existing session by passing X-Hermes-Session-Id.
# When provided, history is loaded from state.db instead of from the request body.
#
Expand Down Expand Up @@ -662,6 +671,7 @@ def _on_tool_progress(event_type, name, preview, args, **kwargs):
stream_delta_callback=_on_delta,
tool_progress_callback=_on_tool_progress,
agent_ref=agent_ref,
user_id=provided_user_id,
))

return await self._write_sse_chat_completion(
Expand All @@ -676,6 +686,7 @@ async def _compute_completion():
conversation_history=history,
ephemeral_system_prompt=system_prompt,
session_id=session_id,
user_id=provided_user_id,
)

idempotency_key = request.headers.get("Idempotency-Key")
Expand Down Expand Up @@ -938,6 +949,11 @@ async def _handle_responses(self, request: "web.Request") -> "web.Response":
if body.get("truncation") == "auto" and len(conversation_history) > 100:
conversation_history = conversation_history[-100:]

# Per-user memory scoping (same header as chat completions)
resp_user_id = request.headers.get(
"X-Hermes-User-Id", ""
).strip() or None

# Run the agent (with Idempotency-Key support)
session_id = str(uuid.uuid4())

Expand All @@ -947,6 +963,7 @@ async def _compute_response():
conversation_history=conversation_history,
ephemeral_system_prompt=instructions,
session_id=session_id,
user_id=resp_user_id,
)

idempotency_key = request.headers.get("Idempotency-Key")
Expand Down Expand Up @@ -1368,6 +1385,7 @@ async def _run_agent(
stream_delta_callback=None,
tool_progress_callback=None,
agent_ref: Optional[list] = None,
user_id: Optional[str] = None,
) -> tuple:
"""
Create an agent and run a conversation in a thread executor.
Expand All @@ -1388,6 +1406,7 @@ def _run():
session_id=session_id,
stream_delta_callback=stream_delta_callback,
tool_progress_callback=tool_progress_callback,
user_id=user_id,
)
if agent_ref is not None:
agent_ref[0] = agent
Expand Down Expand Up @@ -1548,6 +1567,9 @@ def _text_cb(delta: Optional[str]) -> None:

session_id = body.get("session_id") or run_id
ephemeral_system_prompt = instructions
stream_user_id = request.headers.get(
"X-Hermes-User-Id", ""
).strip() or None

async def _run_and_close():
try:
Expand All @@ -1556,6 +1578,7 @@ async def _run_and_close():
session_id=session_id,
stream_delta_callback=_text_cb,
tool_progress_callback=event_cb,
user_id=stream_user_id,
)
def _run_sync():
r = agent.run_conversation(
Expand Down
61 changes: 56 additions & 5 deletions gateway/platforms/mattermost.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,9 +271,14 @@ async def send(
"channel_id": chat_id,
"message": chunk,
}
# Thread support: reply_to is the root post ID.
if reply_to and self._reply_mode == "thread":
payload["root_id"] = reply_to
# Thread support: prefer thread_id from metadata (existing
# thread) over reply_to (creates a new thread).
if self._reply_mode == "thread":
thread_root = (
(metadata or {}).get("thread_id") or reply_to
)
if thread_root:
payload["root_id"] = thread_root

data = await self._api_post("posts", payload)
if not data or "id" not in data:
Expand Down Expand Up @@ -454,8 +459,12 @@ async def _send_url_as_file(
"message": caption or "",
"file_ids": [file_id],
}
if reply_to and self._reply_mode == "thread":
payload["root_id"] = reply_to
if self._reply_mode == "thread":
thread_root = (
(metadata or {}).get("thread_id") or reply_to
)
if thread_root:
payload["root_id"] = thread_root

data = await self._api_post("posts", payload)
if not data or "id" not in data:
Expand Down Expand Up @@ -617,6 +626,16 @@ async def _handle_ws_event(self, event: Dict[str, Any]) -> None:
# For DMs, user_id is sufficient. For channels, check for @mention.
message_text = post.get("message", "")

# Waiki: ignorar DMs si configurado
_ignore_dms = os.getenv(
"MATTERMOST_IGNORE_DMS", ""
).lower() == "true"
if channel_type_raw == "D" and _ignore_dms:
logger.debug(
"Mattermost: ignoring DM (MATTERMOST_IGNORE_DMS=true)"
)
return

# Mention-gating for non-DM channels.
# Config (env vars):
# MATTERMOST_REQUIRE_MENTION: Require @mention in channels (default: true)
Expand All @@ -630,6 +649,38 @@ async def _handle_ws_event(self, event: Dict[str, Any]) -> None:
free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
is_free_channel = channel_id in free_channels

# Waiki: match por prefijo de nombre de canal
if not is_free_channel:
_prefix_raw = os.getenv(
"MATTERMOST_FREE_RESPONSE_PREFIX", ""
)
_prefixes = [
p.strip()
for p in _prefix_raw.split(",")
if p.strip()
]
if _prefixes:
if not hasattr(self, "_channel_name_cache"):
self._channel_name_cache = {}
_ch_name = self._channel_name_cache.get(
channel_id
)
if _ch_name is None:
try:
_ch = await self._api_get(
f"channels/{channel_id}"
)
_ch_name = _ch.get("name", "")
except Exception:
_ch_name = ""
self._channel_name_cache[
channel_id
] = _ch_name
is_free_channel = any(
_ch_name.startswith(p)
for p in _prefixes
)

mention_patterns = [
f"@{self._bot_username}",
f"@{self._bot_user_id}",
Expand Down
157 changes: 156 additions & 1 deletion gateway/platforms/whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,27 @@ def __init__(self, config: PlatformConfig):
self._poll_task: Optional[asyncio.Task] = None
self._http_session: Optional["aiohttp.ClientSession"] = None
self._session_lock_identity: Optional[str] = None
# Waiki: group debounce — equipo tiene N seg para responder
self._group_debounce_sec: int = int(
os.getenv("WHATSAPP_GROUP_DEBOUNCE_SECONDS", "0")
)
self._team_jids: set = set()
_raw_team = os.getenv("WHATSAPP_TEAM_NUMBERS", "")
for num in _raw_team.split(","):
num = num.strip().replace("+", "")
if num:
self._team_jids.add(num)
# {chat_id: timestamp} del ultimo mensaje del equipo
self._team_last_seen: dict = {}
# {chat_id: asyncio.Event} para cancelar delays
self._debounce_cancel: dict = {}
if self._group_debounce_sec and self._team_jids:
logger.info(
"[%s] Group debounce: %ds, team JIDs: %s",
self.name,
self._group_debounce_sec,
self._team_jids,
)

def _whatsapp_require_mention(self) -> bool:
configured = self.config.extra.get("require_mention")
Expand Down Expand Up @@ -559,6 +580,32 @@ async def disconnect(self) -> None:
self._session_lock_identity = None
print(f"[{self.name}] Disconnected")

# Waiki: patrones que nunca deben llegar a WhatsApp
_SUPPRESS_PATTERNS = [
r"(?i)silencio\s*absoluto",
r"(?i)cero\s*output",
r"(?i)alguien\s*(del\s*equipo\s*)?ya\s*(contest|respond)",
r"(?i)no\s*responder",
r"(?i)regla\s*cr[ií]tica",
r"(?i)\(empty\)",
r"📬\s*No home channel",
r"💾\s*Memory updated",
r"🔊\s*Audio:\s*/",
r"Type\s*/sethome\s+to\s+make",
r"(?i)skill.+created",
r"📚\s*skill_view",
r"🧠\s*memory:",
]
_SUPPRESS_RE = None

@classmethod
def _get_suppress_re(cls):
if cls._SUPPRESS_RE is None:
cls._SUPPRESS_RE = re.compile(
"|".join(cls._SUPPRESS_PATTERNS)
)
return cls._SUPPRESS_RE

async def send(
self,
chat_id: str,
Expand All @@ -567,6 +614,32 @@ async def send(
metadata: Optional[Dict[str, Any]] = None
) -> SendResult:
"""Send a message via the WhatsApp bridge."""
# Waiki: suprimir mensajes internos
if content and self._get_suppress_re().search(content):
logger.debug(
"WA suppressed internal message: %s",
content[:80],
)
return SendResult(success=True, message_id="suppressed")
# Waiki: bloquear envio a grupos no autorizados
if chat_id and "@g.us" in chat_id:
_allowed_raw = os.getenv(
"WHATSAPP_ALLOWED_GROUPS", ""
)
_allowed_jids = {
j.strip()
for j in _allowed_raw.split(",")
if j.strip()
}
if not _allowed_jids or chat_id not in _allowed_jids:
logger.warning(
"WA blocked send to unauthorized group: %s",
chat_id,
)
return SendResult(
success=False,
error="Envio a grupos de clientes bloqueado",
)
if not self._running or not self._http_session:
return SendResult(success=False, error="Not connected")
bridge_exit = await self._check_managed_bridge_exit()
Expand Down Expand Up @@ -776,6 +849,81 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:

return {"name": chat_id, "type": "dm"}

def _is_team_member(self, sender_id: str) -> bool:
"""Waiki: verifica si el sender es del equipo."""
if not self._team_jids or not sender_id:
return False
# Normalizar: quitar +, @s.whatsapp.net, :lid
bare = (
str(sender_id)
.strip()
.replace("+", "")
.split(":", 1)[0]
.split("@", 1)[0]
)
return bare in self._team_jids

async def _waiki_debounce(self, msg_data: dict) -> bool:
"""Waiki: debounce en grupos WhatsApp.

Retorna True si el mensaje debe ignorarse (equipo
respondio o hay que esperar). False si debe procesarse.
"""
if not self._group_debounce_sec or not self._team_jids:
return False
if not msg_data.get("isGroup"):
return False

chat_id = msg_data.get("chatId", "")
sender_id = msg_data.get("senderId", "")

# Si es del equipo: registrar y no procesar
if self._is_team_member(sender_id):
import time
self._team_last_seen[chat_id] = time.time()
# Cancelar delay pendiente en este chat
cancel_evt = self._debounce_cancel.get(chat_id)
if cancel_evt:
cancel_evt.set()
logger.debug(
"WA debounce: team member %s in %s",
sender_id, chat_id,
)
return True

# Si es de un cliente: esperar antes de procesar
import time
cancel_evt = asyncio.Event()
self._debounce_cancel[chat_id] = cancel_evt

try:
await asyncio.wait_for(
cancel_evt.wait(),
timeout=self._group_debounce_sec,
)
# Si llegamos aca, el evento se disparo (equipo
# respondio durante el delay)
logger.info(
"WA debounce: cancelled in %s (team responded)",
chat_id,
)
return True
except asyncio.TimeoutError:
pass
finally:
self._debounce_cancel.pop(chat_id, None)

# Verificar si el equipo respondio justo despues
last = self._team_last_seen.get(chat_id, 0)
if time.time() - last < self._group_debounce_sec + 10:
logger.info(
"WA debounce: skip in %s (team seen recently)",
chat_id,
)
return True

return False

async def _poll_messages(self) -> None:
"""Poll the bridge for incoming messages."""
import aiohttp
Expand All @@ -795,7 +943,14 @@ async def _poll_messages(self) -> None:
if resp.status == 200:
messages = await resp.json()
for msg_data in messages:
event = await self._build_message_event(msg_data)
# Waiki: group debounce
if await self._waiki_debounce(
msg_data
):
continue
event = await self._build_message_event(
msg_data
)
if event:
await self.handle_message(event)
except asyncio.CancelledError:
Expand Down
Loading
Loading