diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 5c1d9fde3a..7ebedbe378 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -2252,25 +2252,35 @@ class RelayAdapter(BasePlatformAdapter): _ACK_EMOJI_BY_PLATFORM = { "telegram": ("👀", "👍", "👎"), } + # `_event_from_wire` maps an absent OR unknown wire platform to + # `Platform.RELAY`, so an unresolved lane arrives as the truthy string + # "relay", never as "". Treating only "" as unresolved makes the fallback + # dead code and silently serves ✅ to a Telegram-primary gateway whose + # connector did not stamp the platform. + _ACK_PLATFORM_UNRESOLVED = frozenset({"", "relay"}) def _ack_emoji(self, event, chat_id) -> tuple: """(in_progress, success, failure) for the lane this event arrived on. Prefers the EVENT's own platform: an ack always follows an inbound - event, so the platform is on hand and needs no cache. `_chat_platform` - is the fallback, and it can only answer for a chat already seen inbound - — the descriptor's primary platform covers the rest. + event, so the platform is on hand and needs no cache. Falls back to the + chat's lane as seen inbound, then to the descriptor's primary platform. + Each candidate is checked in turn because any of them can be the + placeholder "relay", which resolves nothing. `Platform` is a plain `Enum`, so `str()` on a member yields "Platform.TELEGRAM", not "telegram" — read `.value` first or every lookup misses and silently falls back to the default set. """ - raw = getattr(getattr(event, "source", None), "platform", None) - platform = str(getattr(raw, "value", raw) or "").lower() - if not platform: - lane = self._chat_platform(str(chat_id)) - platform = str(getattr(lane, "value", lane) or "").lower() - return self._ACK_EMOJI_BY_PLATFORM.get(platform, self._ACK_EMOJI_DEFAULT) + for candidate in ( + getattr(getattr(event, "source", None), "platform", None), + self._platform_by_chat.get(str(chat_id)), + getattr(self.descriptor, "platform", None), + ): + name = str(getattr(candidate, "value", candidate) or "").lower() + if name and name not in self._ACK_PLATFORM_UNRESOLVED: + return self._ACK_EMOJI_BY_PLATFORM.get(name, self._ACK_EMOJI_DEFAULT) + return self._ACK_EMOJI_DEFAULT async def _react( self, diff --git a/tests/gateway/relay/test_relay_interactive.py b/tests/gateway/relay/test_relay_interactive.py index bc3278ebd3..afb23e9473 100644 --- a/tests/gateway/relay/test_relay_interactive.py +++ b/tests/gateway/relay/test_relay_interactive.py @@ -29,6 +29,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.event import MessageEvent, MessageType, ProcessingOutcome from gateway.relay.adapter import RelayAdapter from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor +from gateway.relay.ws_transport import _event_from_wire from gateway.session import SessionSource from tests.gateway.relay.stub_connector import StubConnector @@ -315,16 +316,50 @@ async def test_free_form_reaction_platforms_keep_the_check_mark(): @pytest.mark.asyncio -async def test_ack_platform_falls_back_to_the_chat_lane_then_the_descriptor(): - """An event with no platform on its source still resolves: a multi-platform - gateway records the chat's lane inbound, and the descriptor's primary - platform covers a chat never seen inbound. A telegram-primary descriptor - must therefore never emit ✅.""" - adapter, stub = _adapter() # make_desc default platform is telegram - event = _reactable_event(platform=None) +async def test_ack_platform_falls_back_through_the_real_wire_decode(): + """`_event_from_wire` maps an absent OR unknown wire platform to + `Platform.RELAY` — never to "" — so the fallback must treat the "relay" + placeholder as unresolved. Built through the real decoder, because an + event with no platform at all is a state the wire cannot produce.""" + for wire_platform in ({}, {"platform": "relay"}, {"platform": "not_a_platform"}): + adapter, stub = _adapter() # make_desc default platform is telegram + event = _event_from_wire( + { + "text": "hi", + "message_id": "m42", + "source": {"chat_id": "ch1", "chat_type": "channel", "user_id": "u1", **wire_platform}, + } + ) + assert event.source.platform is Platform.RELAY, wire_platform + await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS) + emojis = [a["emoji"] for a in stub.sent if a["op"] == "react"] + assert emojis == ["👀", "👍"], f"{wire_platform} fell back to {emojis}" + + +@pytest.mark.asyncio +async def test_ack_platform_prefers_the_chat_lane_over_the_primary(): + """A multi-platform gateway records each chat's lane inbound. When the + event itself is unresolved, that lane must win over the primary platform — + otherwise every non-primary lane gets the primary's emoji.""" + adapter, stub = _adapter(platform="slack", label="Slack") + adapter._platform_by_chat["ch1"] = "telegram" + event = _event_from_wire( + {"text": "hi", "message_id": "m42", "source": {"chat_id": "ch1", "chat_type": "channel"}} + ) await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS) - emojis = [a["emoji"] for a in stub.sent if a["op"] == "react"] - assert emojis == ["👀", "👍"] + assert [a["emoji"] for a in stub.sent if a["op"] == "react"] == ["👀", "👍"] + + +@pytest.mark.asyncio +async def test_ack_on_a_genuinely_generic_relay_keeps_the_check_mark(): + """Nothing resolves to a real platform: a relay-primary descriptor with no + inbound lane must not invent Telegram's vocabulary.""" + adapter, stub = _adapter(platform="relay", label="Relay") + event = _event_from_wire( + {"text": "hi", "message_id": "m42", "source": {"chat_id": "ch1", "chat_type": "channel"}} + ) + await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS) + assert [a["emoji"] for a in stub.sent if a["op"] == "react"] == ["👀", "✅"] # ── fanned-out prompt answers (one press, many gateways) ─────────────────