Compare commits

...

1 Commits

Author SHA1 Message Date
Frederico Ribeiro
58f1fe5aee fix(messaging): resolve explicit delivery labels consistently (#1945)
Cron delivery to WhatsApp failed with Baileys jidDecode error when
using human-friendly labels like 'whatsapp:Alice (dm)'. The scheduler
now resolves display labels via the channel directory before passing
them to the WhatsApp bridge.

Changes:
- cron/scheduler.py: _resolve_explicit_delivery_target resolves labels
- gateway/channel_directory.py: _channel_match_names accepts 'Name (type)'
- gateway/delivery.py: shared parse_platform_target_ref for WhatsApp JIDs,
  Signal phones, email addresses
- tools/send_message_tool.py: uses shared parse_platform_target_ref

Cherry-picked from PR #1950 by @ifrederico (delivery fix commit only).
2026-03-22 15:15:50 -07:00
8 changed files with 213 additions and 33 deletions

View File

@@ -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:

View File

@@ -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"]

View File

@@ -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:
""" """

View File

@@ -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."""

View File

@@ -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):

View File

@@ -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)

View File

@@ -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()

View File

@@ -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: