fix(cron): deliver to relay-fronted platforms via canonical home_channel (#84300)

Cron jobs targeting a relay-fronted logical platform (e.g. Discord behind
the relay connector) failed twice over:

1. Target resolution read only the legacy <PLATFORM>_HOME_CHANNEL env
   mirror. The canonical home_channel block that /sethome persists to
   config.yaml — the only store that exists in a relay-fronted deployment,
   where no native env var is exported — was never consulted, so
   deliver='discord' silently resolved to nothing and the job fell back
   to local-only.

2. Even with a resolved target, the delivery loop's native
   configured/enabled gate rejected the platform ('not configured/enabled')
   although resolve_delivery_transport had already produced a live relay
   transport fronting it. A relay-fronted platform is deliberately NOT
   natively enabled (its credential lives in the connector), so the native
   gate must not apply to a relay transport.

Resolution now falls back from the env mirror to
config.get_home_channel(platform) for both chat_id and thread_id (thread
affinity only when the chat id came from the same config block), which
also makes the 'all' routing token pick up relay-fronted platforms. The
delivery gate honours a resolved relay transport, mirroring the
enablement rule resolve_delivery_transport already applied; the standalone
(no-relay) path keeps the historical gate byte-identical.
This commit is contained in:
Ben Barclay 2026-08-12 14:48:54 +10:00 committed by GitHub
parent 356c702b55
commit 76d832d385
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 246 additions and 8 deletions

View File

@ -1178,8 +1178,35 @@ def _resolve_home_env_var(platform_name: str) -> str:
return _plugin_cron_env_var(name)
def _get_home_target_chat_id(platform_name: str) -> str:
"""Return the configured home target chat/room ID for a delivery platform."""
def _get_config_home_channel(platform_name: str):
"""Return the persisted ``HomeChannel`` for a platform from gateway config.
``/sethome`` declares ``config.yaml`` canonical (it is the only store that
survives for relay-fronted logical platforms, whose adapters are not
natively enabled) and mirrors the value into the legacy
``<PLATFORM>_HOME_CHANNEL`` env var only as a best-effort compatibility
shim. Cron historically read ONLY the env mirror, so a home channel that
existed solely in config.yaml e.g. Discord fronted by the relay
connector, where no ``DISCORD_HOME_CHANNEL`` was ever exported was
invisible and jobs silently fell back to local-only. Reading the
canonical store here fixes that for every relay-fronted platform at once.
"""
try:
from gateway.config import load_gateway_config, Platform
config = load_gateway_config()
platform = Platform(platform_name.lower())
return config.get_home_channel(platform)
except Exception:
logger.debug(
"config home_channel lookup failed for platform %r",
platform_name, exc_info=True,
)
return None
def _env_home_target_chat_id(platform_name: str) -> str:
"""Return the home chat id from the legacy env mirror only (no config)."""
env_var = _resolve_home_env_var(platform_name)
if not env_var:
return ""
@ -1191,6 +1218,22 @@ def _get_home_target_chat_id(platform_name: str) -> str:
return value
def _get_home_target_chat_id(platform_name: str) -> str:
"""Return the configured home target chat/room ID for a delivery platform.
Resolution order: platform env var (legacy mirror, kept first so an
operator override keeps winning) legacy env var name the canonical
``home_channel`` block persisted in config.yaml by ``/sethome``.
"""
value = _env_home_target_chat_id(platform_name)
if value:
return value
home = _get_config_home_channel(platform_name)
if home is not None and home.chat_id:
return str(home.chat_id)
return ""
def _get_home_target_thread_id(platform_name: str) -> Optional[str]:
"""Return the optional thread/topic ID for a platform home target.
@ -1203,18 +1246,26 @@ def _get_home_target_thread_id(platform_name: str) -> Optional[str]:
without changing the lobby invariant.
"""
env_var = _resolve_home_env_var(platform_name)
if not env_var:
return None
if platform_name.lower() == "telegram":
cron_thread = os.getenv("TELEGRAM_CRON_THREAD_ID", "").strip()
if cron_thread:
return cron_thread
value = os.getenv(f"{env_var}_THREAD_ID", "").strip()
if not value:
value = os.getenv(f"{env_var}_THREAD_ID", "").strip() if env_var else ""
if not value and env_var:
legacy = _LEGACY_HOME_TARGET_ENV_VARS.get(env_var)
if legacy:
value = os.getenv(f"{legacy}_THREAD_ID", "").strip()
return value or None
if value:
return value
# Canonical config.yaml fallback — same rationale as
# _get_home_target_chat_id, and thread affinity only applies when the
# chat itself resolved from the same config block (an env-provided chat
# id keeps its env-provided thread semantics).
if not _env_home_target_chat_id(platform_name):
home = _get_config_home_channel(platform_name)
if home is not None and home.thread_id:
return str(home.thread_id)
return None
def _iter_home_target_platforms():
@ -1741,7 +1792,18 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
pconfig = config.platforms.get(platform)
runtime_adapter = None
if not pconfig or not pconfig.enabled:
if transport is not None and transport.is_relay:
# A relay transport carries the RELAY adapter's config, and
# resolve_delivery_transport already applied relay's enablement
# rule (config block absent OR enabled). The logical platform is
# deliberately NOT natively enabled in a relay-fronted deployment
# (its credential lives in the connector), so the native
# configured/enabled gate below must not apply — it used to
# reject exactly the targets the relay was resolved to serve.
if pconfig is None:
from gateway.config import PlatformConfig
pconfig = PlatformConfig(enabled=True)
elif not pconfig or not pconfig.enabled:
msg = f"platform '{platform_name}' not configured/enabled"
logger.warning("Job '%s': %s", job["id"], msg)
delivery_errors.append(msg)

View File

@ -0,0 +1,176 @@
"""Cron delivery for relay-fronted logical platforms.
Bug report: a deployment where Discord is fronted by the relay connector
(``GATEWAY_RELAY_PLATFORMS=discord``) could not use ``deliver='discord'``:
1. Resolution read ONLY the legacy ``DISCORD_HOME_CHANNEL`` env mirror, never
the canonical ``platforms.discord.home_channel`` block that ``/sethome``
persists to config.yaml so the target silently resolved to nothing and
the job fell back to local-only.
2. Even with a resolved target, the delivery loop's native
``pconfig.enabled`` gate rejected the platform ("not configured/enabled")
although ``resolve_delivery_transport`` had already produced a live relay
transport that fronts it a relay-fronted logical platform is
deliberately NOT natively enabled (its credential lives in the connector).
"""
import asyncio
from concurrent.futures import Future
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from cron import scheduler as sched
from cron.scheduler import (
_deliver_result,
_get_home_target_chat_id,
_get_home_target_thread_id,
_resolve_delivery_targets,
)
from gateway.config import HomeChannel, Platform
def _gateway_config_with_home(platform=Platform.DISCORD, chat_id="1517373704248758474",
thread_id=None):
"""A gateway config whose ONLY home-channel source is config.yaml."""
home = HomeChannel(platform=platform, chat_id=chat_id, name="Home",
thread_id=thread_id)
config = MagicMock()
config.platforms = {}
config.get_home_channel = lambda p: home if p == platform else None
return config
def _clear_home_env(monkeypatch):
for var in ("DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_THREAD_ID"):
monkeypatch.delenv(var, raising=False)
# ---------------------------------------------------------------------------
# Resolution: config.yaml home_channel fallback
# ---------------------------------------------------------------------------
class TestConfigHomeChannelFallback:
def test_chat_id_falls_back_to_config_home_channel(self, monkeypatch):
"""Env mirror empty → the canonical config.yaml home_channel is used."""
_clear_home_env(monkeypatch)
with patch("gateway.config.load_gateway_config",
return_value=_gateway_config_with_home()):
assert _get_home_target_chat_id("discord") == "1517373704248758474"
def test_env_mirror_still_wins_over_config(self, monkeypatch):
"""Operator env override keeps precedence over the config block."""
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "999")
with patch("gateway.config.load_gateway_config",
return_value=_gateway_config_with_home()):
assert _get_home_target_chat_id("discord") == "999"
def test_thread_id_falls_back_to_config_home_channel(self, monkeypatch):
_clear_home_env(monkeypatch)
with patch("gateway.config.load_gateway_config",
return_value=_gateway_config_with_home(thread_id="777")):
assert _get_home_target_thread_id("discord") == "777"
def test_config_thread_not_used_when_chat_came_from_env(self, monkeypatch):
"""Thread affinity: a config thread_id must not be grafted onto an
env-provided chat id (they may point at different conversations)."""
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "999")
monkeypatch.delenv("DISCORD_HOME_CHANNEL_THREAD_ID", raising=False)
with patch("gateway.config.load_gateway_config",
return_value=_gateway_config_with_home(thread_id="777")):
assert _get_home_target_thread_id("discord") is None
def test_no_source_returns_empty(self, monkeypatch):
_clear_home_env(monkeypatch)
config = MagicMock()
config.platforms = {}
config.get_home_channel = lambda p: None
with patch("gateway.config.load_gateway_config", return_value=config):
assert _get_home_target_chat_id("discord") == ""
def test_config_load_failure_fails_safe(self, monkeypatch):
_clear_home_env(monkeypatch)
with patch("gateway.config.load_gateway_config",
side_effect=RuntimeError("boom")):
assert _get_home_target_chat_id("discord") == ""
def test_deliver_discord_resolves_via_config_home(self, monkeypatch):
"""End to end: deliver='discord' on a job with no origin resolves a
concrete target from the config.yaml home_channel alone."""
_clear_home_env(monkeypatch)
job = {"id": "j1", "deliver": "discord"}
with patch("gateway.config.load_gateway_config",
return_value=_gateway_config_with_home()):
targets = _resolve_delivery_targets(job)
assert targets == [{
"platform": "discord",
"chat_id": "1517373704248758474",
"thread_id": None,
}]
# ---------------------------------------------------------------------------
# Delivery: relay transport must bypass the native enabled gate
# ---------------------------------------------------------------------------
class TestRelayDeliveryGate:
def _relay_adapter(self):
adapter = AsyncMock()
adapter.fronts_platform = lambda p: p == Platform.DISCORD
return adapter
def _job(self):
return {
"id": "relay-job",
"name": "Relay Job",
"deliver": "discord",
"origin": {"platform": "discord", "chat_id": "123"},
}
def _run(self, adapters, gateway_config):
loop = MagicMock()
loop.is_running.return_value = True
def fake_run_coro(coro, _loop):
future = Future()
try:
future.set_result(asyncio.run(coro))
except BaseException as e: # noqa: BLE001
future.set_exception(e)
return future
router = MagicMock()
async def _deliver_to_platform(target, content, metadata):
return {"success": True, "raw_response": None}
router._deliver_to_platform = _deliver_to_platform
with patch("gateway.config.load_gateway_config",
return_value=gateway_config), \
patch("cron.scheduler.load_config",
return_value={"cron": {"wrap_response": False}}), \
patch("gateway.delivery.DeliveryRouter", return_value=router), \
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
return _deliver_result(self._job(), "Nightly report.",
adapters=adapters, loop=loop)
def test_relay_fronted_platform_is_not_rejected(self, monkeypatch):
"""A live relay transport that fronts Discord must deliver even though
platforms.discord has no native config block at all."""
_clear_home_env(monkeypatch)
config = MagicMock()
config.platforms = {} # neither discord nor relay configured natively
config.get_home_channel = lambda p: None
result = self._run({Platform.RELAY: self._relay_adapter()}, config)
assert result is None # None == delivered without errors
def test_native_gate_preserved_without_relay(self, monkeypatch):
"""No relay transport → the historical configured/enabled gate stays."""
_clear_home_env(monkeypatch)
config = MagicMock()
config.platforms = {}
config.get_home_channel = lambda p: None
result = self._run({}, config)
assert result is not None
assert "not configured/enabled" in result