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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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