mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 16:01:49 +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
|
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]:
|
def _resolve_delivery_target(job: dict) -> Optional[dict]:
|
||||||
"""Resolve the concrete auto-delivery target for a cron job, if any."""
|
"""Resolve the concrete auto-delivery target for a cron job, if any."""
|
||||||
deliver = job.get("deliver", "local")
|
deliver = job.get("deliver", "local")
|
||||||
@@ -80,17 +116,8 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ":" in deliver:
|
if ":" in deliver:
|
||||||
platform_name, rest = deliver.split(":", 1)
|
platform_name, target_ref = deliver.split(":", 1)
|
||||||
# Check for thread_id suffix (e.g. "telegram:-1003724596514:17")
|
return _resolve_explicit_delivery_target(platform_name, target_ref)
|
||||||
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 = deliver
|
platform_name = deliver
|
||||||
if origin and origin.get("platform") == platform_name:
|
if origin and origin.get("platform") == platform_name:
|
||||||
|
|||||||
@@ -176,6 +176,19 @@ def load_directory() -> Dict[str, Any]:
|
|||||||
return {"updated_at": None, "platforms": {}}
|
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]:
|
def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Resolve a human-friendly channel name to a numeric ID.
|
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
|
# 1. Exact name match
|
||||||
for ch in channels:
|
for ch in channels:
|
||||||
if ch["name"].lower() == query:
|
if query in _channel_match_names(ch):
|
||||||
return ch["id"]
|
return ch["id"]
|
||||||
|
|
||||||
# 2. Guild-qualified match for Discord ("GuildName/channel")
|
# 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"]
|
return ch["id"]
|
||||||
|
|
||||||
# 3. Partial prefix match (only if unambiguous)
|
# 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:
|
if len(matches) == 1:
|
||||||
return matches[0]["id"]
|
return matches[0]["id"]
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Routes messages to the appropriate destination based on:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -21,11 +22,36 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
MAX_PLATFORM_OUTPUT = 4000
|
MAX_PLATFORM_OUTPUT = 4000
|
||||||
TRUNCATED_VISIBLE = 3800
|
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 .config import Platform, GatewayConfig
|
||||||
from .session import SessionSource
|
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
|
@dataclass
|
||||||
class DeliveryTarget:
|
class DeliveryTarget:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -116,6 +116,36 @@ class TestResolveDeliveryTarget:
|
|||||||
"thread_id": None,
|
"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:
|
class TestDeliverResultWrapping:
|
||||||
"""Verify that cron deliveries are wrapped with header/footer and no longer mirrored."""
|
"""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):
|
with self._setup(tmp_path, platforms):
|
||||||
assert resolve_channel_name("telegram", "Coaching Chat / topic 17585") == "-1001:17585"
|
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:
|
class TestBuildFromSessions:
|
||||||
def _write_sessions(self, tmp_path, sessions_data):
|
def _write_sessions(self, tmp_path, sessions_data):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Tests for the delivery routing module."""
|
"""Tests for the delivery routing module."""
|
||||||
|
|
||||||
from gateway.config import Platform, GatewayConfig, PlatformConfig, HomeChannel
|
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
|
from gateway.session import SessionSource
|
||||||
|
|
||||||
|
|
||||||
@@ -41,6 +41,32 @@ class TestParseTargetPlatformChat:
|
|||||||
assert target.platform == Platform.LOCAL
|
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:
|
class TestParseDeliverSpec:
|
||||||
def test_none_returns_default(self):
|
def test_none_returns_default(self):
|
||||||
result = parse_deliver_spec(None)
|
result = parse_deliver_spec(None)
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ def _run_async_immediately(coro):
|
|||||||
return asyncio.run(coro)
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
def _make_config():
|
def _make_config(platform=Platform.TELEGRAM, *, token="***", extra=None):
|
||||||
telegram_cfg = SimpleNamespace(enabled=True, token="***", extra={})
|
platform_cfg = SimpleNamespace(enabled=True, token=token, extra=extra or {})
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
platforms={Platform.TELEGRAM: telegram_cfg},
|
platforms={platform: platform_cfg},
|
||||||
get_home_channel=lambda _platform: None,
|
get_home_channel=lambda _platform: None,
|
||||||
), telegram_cfg
|
), platform_cfg
|
||||||
|
|
||||||
|
|
||||||
def _install_telegram_mock(monkeypatch, bot):
|
def _install_telegram_mock(monkeypatch, bot):
|
||||||
@@ -203,6 +203,65 @@ class TestSendMessageTool:
|
|||||||
media_files=[],
|
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):
|
def test_media_only_message_uses_placeholder_for_mirroring(self):
|
||||||
config, telegram_cfg = _make_config()
|
config, telegram_cfg = _make_config()
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import re
|
|||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from gateway.delivery import parse_platform_target_ref
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$")
|
|
||||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
|
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
|
||||||
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"}
|
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"}
|
||||||
@@ -86,7 +87,7 @@ def _handle_send(args):
|
|||||||
thread_id = None
|
thread_id = None
|
||||||
|
|
||||||
if target_ref:
|
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:
|
else:
|
||||||
is_explicit = False
|
is_explicit = False
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ def _handle_send(args):
|
|||||||
from gateway.channel_directory import resolve_channel_name
|
from gateway.channel_directory import resolve_channel_name
|
||||||
resolved = resolve_channel_name(platform_name, target_ref)
|
resolved = resolve_channel_name(platform_name, target_ref)
|
||||||
if resolved:
|
if resolved:
|
||||||
chat_id, thread_id, _ = _parse_target_ref(platform_name, resolved)
|
chat_id, thread_id, _ = parse_platform_target_ref(platform_name, resolved)
|
||||||
else:
|
else:
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"error": f"Could not resolve '{target_ref}' on {platform_name}. "
|
"error": f"Could not resolve '{target_ref}' on {platform_name}. "
|
||||||
@@ -191,18 +192,6 @@ def _handle_send(args):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": f"Send failed: {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):
|
def _describe_media_for_mirror(media_files):
|
||||||
"""Return a human-readable mirror summary when a message only contains media."""
|
"""Return a human-readable mirror summary when a message only contains media."""
|
||||||
if not media_files:
|
if not media_files:
|
||||||
|
|||||||
Reference in New Issue
Block a user