mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 23:41:35 +08:00
Compare commits
1 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f1fe5aee |
@@ -62,6 +62,42 @@ def _resolve_origin(job: dict) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_explicit_delivery_target(platform_name: str, target_ref: str) -> dict:
|
||||
"""Resolve an explicit platform target, including display labels from the channel directory."""
|
||||
from gateway.channel_directory import resolve_channel_name
|
||||
from gateway.delivery import parse_platform_target_ref
|
||||
|
||||
platform_name = platform_name.strip().lower()
|
||||
target_ref = target_ref.strip()
|
||||
|
||||
chat_id, thread_id, is_explicit = parse_platform_target_ref(platform_name, target_ref)
|
||||
if is_explicit:
|
||||
return {
|
||||
"platform": platform_name,
|
||||
"chat_id": chat_id,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
try:
|
||||
resolved = resolve_channel_name(platform_name, target_ref)
|
||||
except Exception:
|
||||
resolved = None
|
||||
|
||||
if resolved:
|
||||
chat_id, thread_id, is_explicit = parse_platform_target_ref(platform_name, resolved)
|
||||
return {
|
||||
"platform": platform_name,
|
||||
"chat_id": chat_id if is_explicit else resolved,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
return {
|
||||
"platform": platform_name,
|
||||
"chat_id": target_ref,
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_delivery_target(job: dict) -> Optional[dict]:
|
||||
"""Resolve the concrete auto-delivery target for a cron job, if any."""
|
||||
deliver = job.get("deliver", "local")
|
||||
@@ -80,17 +116,8 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:
|
||||
}
|
||||
|
||||
if ":" in deliver:
|
||||
platform_name, rest = deliver.split(":", 1)
|
||||
# Check for thread_id suffix (e.g. "telegram:-1003724596514:17")
|
||||
if ":" in rest:
|
||||
chat_id, thread_id = rest.split(":", 1)
|
||||
else:
|
||||
chat_id, thread_id = rest, None
|
||||
return {
|
||||
"platform": platform_name,
|
||||
"chat_id": chat_id,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
platform_name, target_ref = deliver.split(":", 1)
|
||||
return _resolve_explicit_delivery_target(platform_name, target_ref)
|
||||
|
||||
platform_name = deliver
|
||||
if origin and origin.get("platform") == platform_name:
|
||||
|
||||
@@ -176,6 +176,19 @@ def load_directory() -> Dict[str, Any]:
|
||||
return {"updated_at": None, "platforms": {}}
|
||||
|
||||
|
||||
def _channel_match_names(channel: Dict[str, Any]) -> List[str]:
|
||||
"""Return accepted lookup labels for a channel entry."""
|
||||
name = str(channel.get("name") or "").strip()
|
||||
if not name:
|
||||
return []
|
||||
|
||||
names = [name.lower()]
|
||||
channel_type = str(channel.get("type") or "").strip().lower()
|
||||
if channel_type:
|
||||
names.append(f"{name} ({channel_type})".lower())
|
||||
return names
|
||||
|
||||
|
||||
def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve a human-friendly channel name to a numeric ID.
|
||||
@@ -194,7 +207,7 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
|
||||
|
||||
# 1. Exact name match
|
||||
for ch in channels:
|
||||
if ch["name"].lower() == query:
|
||||
if query in _channel_match_names(ch):
|
||||
return ch["id"]
|
||||
|
||||
# 2. Guild-qualified match for Discord ("GuildName/channel")
|
||||
@@ -206,7 +219,10 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
|
||||
return ch["id"]
|
||||
|
||||
# 3. Partial prefix match (only if unambiguous)
|
||||
matches = [ch for ch in channels if ch["name"].lower().startswith(query)]
|
||||
matches = [
|
||||
ch for ch in channels
|
||||
if any(candidate.startswith(query) for candidate in _channel_match_names(ch))
|
||||
]
|
||||
if len(matches) == 1:
|
||||
return matches[0]["id"]
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Routes messages to the appropriate destination based on:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
@@ -21,11 +22,36 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_PLATFORM_OUTPUT = 4000
|
||||
TRUNCATED_VISIBLE = 3800
|
||||
_TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$")
|
||||
_PHONE_TARGET_RE = re.compile(r"^\+?\d+$")
|
||||
_EMAIL_TARGET_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
from .config import Platform, GatewayConfig
|
||||
from .session import SessionSource
|
||||
|
||||
|
||||
def parse_platform_target_ref(platform_name: str, target_ref: str):
|
||||
"""Parse a platform target into chat_id/thread_id and whether it is explicit."""
|
||||
platform_name = str(platform_name or "").strip().lower()
|
||||
target_ref = str(target_ref or "").strip()
|
||||
|
||||
if platform_name == "telegram":
|
||||
match = _TELEGRAM_TOPIC_TARGET_RE.fullmatch(target_ref)
|
||||
if match:
|
||||
return match.group(1), match.group(2), True
|
||||
if target_ref.lstrip("-").isdigit():
|
||||
return target_ref, None, True
|
||||
if platform_name in {"signal", "sms"} and _PHONE_TARGET_RE.fullmatch(target_ref):
|
||||
return target_ref, None, True
|
||||
if platform_name == "signal" and target_ref.startswith("group:"):
|
||||
return target_ref, None, True
|
||||
if platform_name == "email" and _EMAIL_TARGET_RE.fullmatch(target_ref):
|
||||
return target_ref, None, True
|
||||
if platform_name == "whatsapp" and "@" in target_ref and not any(ch.isspace() for ch in target_ref):
|
||||
return target_ref, None, True
|
||||
return None, None, False
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeliveryTarget:
|
||||
"""
|
||||
|
||||
@@ -116,6 +116,36 @@ class TestResolveDeliveryTarget:
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
def test_explicit_whatsapp_display_label_resolves_via_channel_directory(self):
|
||||
job = {
|
||||
"deliver": "whatsapp:Alice (dm)",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"gateway.channel_directory.resolve_channel_name",
|
||||
return_value="12345678901234@lid",
|
||||
):
|
||||
assert _resolve_delivery_target(job) == {
|
||||
"platform": "whatsapp",
|
||||
"chat_id": "12345678901234@lid",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
def test_explicit_whatsapp_jid_preserved_when_no_directory_match_exists(self):
|
||||
job = {
|
||||
"deliver": "whatsapp:12345678901234@lid",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"gateway.channel_directory.resolve_channel_name",
|
||||
return_value=None,
|
||||
):
|
||||
assert _resolve_delivery_target(job) == {
|
||||
"platform": "whatsapp",
|
||||
"chat_id": "12345678901234@lid",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
|
||||
class TestDeliverResultWrapping:
|
||||
"""Verify that cron deliveries are wrapped with header/footer and no longer mirrored."""
|
||||
|
||||
@@ -119,6 +119,13 @@ class TestResolveChannelName:
|
||||
with self._setup(tmp_path, platforms):
|
||||
assert resolve_channel_name("telegram", "Coaching Chat / topic 17585") == "-1001:17585"
|
||||
|
||||
def test_display_label_resolves_to_channel_id(self, tmp_path):
|
||||
platforms = {
|
||||
"whatsapp": [{"id": "12345678901234@lid", "name": "Alice", "type": "dm"}]
|
||||
}
|
||||
with self._setup(tmp_path, platforms):
|
||||
assert resolve_channel_name("whatsapp", "Alice (dm)") == "12345678901234@lid"
|
||||
|
||||
|
||||
class TestBuildFromSessions:
|
||||
def _write_sessions(self, tmp_path, sessions_data):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for the delivery routing module."""
|
||||
|
||||
from gateway.config import Platform, GatewayConfig, PlatformConfig, HomeChannel
|
||||
from gateway.delivery import DeliveryRouter, DeliveryTarget, parse_deliver_spec
|
||||
from gateway.delivery import DeliveryRouter, DeliveryTarget, parse_deliver_spec, parse_platform_target_ref
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
@@ -41,6 +41,32 @@ class TestParseTargetPlatformChat:
|
||||
assert target.platform == Platform.LOCAL
|
||||
|
||||
|
||||
class TestParsePlatformTargetRef:
|
||||
def test_whatsapp_jid_is_explicit(self):
|
||||
assert parse_platform_target_ref("whatsapp", "12345678901234@lid") == (
|
||||
"12345678901234@lid",
|
||||
None,
|
||||
True,
|
||||
)
|
||||
|
||||
def test_signal_phone_is_explicit(self):
|
||||
assert parse_platform_target_ref("signal", "+15551234567") == (
|
||||
"+15551234567",
|
||||
None,
|
||||
True,
|
||||
)
|
||||
|
||||
def test_email_address_is_explicit(self):
|
||||
assert parse_platform_target_ref("email", "alice@example.com") == (
|
||||
"alice@example.com",
|
||||
None,
|
||||
True,
|
||||
)
|
||||
|
||||
def test_human_label_is_not_explicit(self):
|
||||
assert parse_platform_target_ref("whatsapp", "Alice (dm)") == (None, None, False)
|
||||
|
||||
|
||||
class TestParseDeliverSpec:
|
||||
def test_none_returns_default(self):
|
||||
result = parse_deliver_spec(None)
|
||||
|
||||
@@ -16,12 +16,12 @@ def _run_async_immediately(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _make_config():
|
||||
telegram_cfg = SimpleNamespace(enabled=True, token="***", extra={})
|
||||
def _make_config(platform=Platform.TELEGRAM, *, token="***", extra=None):
|
||||
platform_cfg = SimpleNamespace(enabled=True, token=token, extra=extra or {})
|
||||
return SimpleNamespace(
|
||||
platforms={Platform.TELEGRAM: telegram_cfg},
|
||||
platforms={platform: platform_cfg},
|
||||
get_home_channel=lambda _platform: None,
|
||||
), telegram_cfg
|
||||
), platform_cfg
|
||||
|
||||
|
||||
def _install_telegram_mock(monkeypatch, bot):
|
||||
@@ -203,6 +203,65 @@ class TestSendMessageTool:
|
||||
media_files=[],
|
||||
)
|
||||
|
||||
def test_resolved_whatsapp_display_label_uses_resolved_jid(self):
|
||||
config, whatsapp_cfg = _make_config(Platform.WHATSAPP, token=None, extra={"bridge_port": 3000})
|
||||
|
||||
with patch("gateway.config.load_gateway_config", return_value=config), \
|
||||
patch("tools.interrupt.is_interrupted", return_value=False), \
|
||||
patch("gateway.channel_directory.resolve_channel_name", return_value="12345678901234@lid"), \
|
||||
patch("model_tools._run_async", side_effect=_run_async_immediately), \
|
||||
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
|
||||
patch("gateway.mirror.mirror_to_session", return_value=True):
|
||||
result = json.loads(
|
||||
send_message_tool(
|
||||
{
|
||||
"action": "send",
|
||||
"target": "whatsapp:Alice (dm)",
|
||||
"message": "hello",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
send_mock.assert_awaited_once_with(
|
||||
Platform.WHATSAPP,
|
||||
whatsapp_cfg,
|
||||
"12345678901234@lid",
|
||||
"hello",
|
||||
thread_id=None,
|
||||
media_files=[],
|
||||
)
|
||||
|
||||
def test_explicit_whatsapp_jid_bypasses_channel_name_resolution(self):
|
||||
config, whatsapp_cfg = _make_config(Platform.WHATSAPP, token=None, extra={"bridge_port": 3000})
|
||||
|
||||
with patch("gateway.config.load_gateway_config", return_value=config), \
|
||||
patch("tools.interrupt.is_interrupted", return_value=False), \
|
||||
patch("gateway.channel_directory.resolve_channel_name") as resolve_mock, \
|
||||
patch("model_tools._run_async", side_effect=_run_async_immediately), \
|
||||
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
|
||||
patch("gateway.mirror.mirror_to_session", return_value=True):
|
||||
result = json.loads(
|
||||
send_message_tool(
|
||||
{
|
||||
"action": "send",
|
||||
"target": "whatsapp:12345678901234@lid",
|
||||
"message": "hello",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
resolve_mock.assert_not_called()
|
||||
send_mock.assert_awaited_once_with(
|
||||
Platform.WHATSAPP,
|
||||
whatsapp_cfg,
|
||||
"12345678901234@lid",
|
||||
"hello",
|
||||
thread_id=None,
|
||||
media_files=[],
|
||||
)
|
||||
|
||||
def test_media_only_message_uses_placeholder_for_mirroring(self):
|
||||
config, telegram_cfg = _make_config()
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ import re
|
||||
import ssl
|
||||
import time
|
||||
|
||||
from gateway.delivery import parse_platform_target_ref
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$")
|
||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
|
||||
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"}
|
||||
@@ -86,7 +87,7 @@ def _handle_send(args):
|
||||
thread_id = None
|
||||
|
||||
if target_ref:
|
||||
chat_id, thread_id, is_explicit = _parse_target_ref(platform_name, target_ref)
|
||||
chat_id, thread_id, is_explicit = parse_platform_target_ref(platform_name, target_ref)
|
||||
else:
|
||||
is_explicit = False
|
||||
|
||||
@@ -96,7 +97,7 @@ def _handle_send(args):
|
||||
from gateway.channel_directory import resolve_channel_name
|
||||
resolved = resolve_channel_name(platform_name, target_ref)
|
||||
if resolved:
|
||||
chat_id, thread_id, _ = _parse_target_ref(platform_name, resolved)
|
||||
chat_id, thread_id, _ = parse_platform_target_ref(platform_name, resolved)
|
||||
else:
|
||||
return json.dumps({
|
||||
"error": f"Could not resolve '{target_ref}' on {platform_name}. "
|
||||
@@ -191,18 +192,6 @@ def _handle_send(args):
|
||||
except Exception as e:
|
||||
return json.dumps({"error": f"Send failed: {e}"})
|
||||
|
||||
|
||||
def _parse_target_ref(platform_name: str, target_ref: str):
|
||||
"""Parse a tool target into chat_id/thread_id and whether it is explicit."""
|
||||
if platform_name == "telegram":
|
||||
match = _TELEGRAM_TOPIC_TARGET_RE.fullmatch(target_ref)
|
||||
if match:
|
||||
return match.group(1), match.group(2), True
|
||||
if target_ref.lstrip("-").isdigit():
|
||||
return target_ref, None, True
|
||||
return None, None, False
|
||||
|
||||
|
||||
def _describe_media_for_mirror(media_files):
|
||||
"""Return a human-readable mirror summary when a message only contains media."""
|
||||
if not media_files:
|
||||
|
||||
Reference in New Issue
Block a user