diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 1ea97c681dc..a4f8d73ec96 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -1018,6 +1018,11 @@ class GoogleChatAdapter(BasePlatformAdapter): # impersonate any allowlisted user without ever being marked # as a bot. Default to "HUMAN" for backward compatibility when # the relay does not provide the field. + # + # Operator contract: the relay MUST forward sender.type from + # the upstream Chat event as ``sender_type``. Relays that + # forward bot replies as HUMAN (or omit the field) cannot be + # distinguished from genuine humans here. sender_type_raw = (envelope.get("sender_type") or "HUMAN") sender_type = str(sender_type_raw).strip().upper() or "HUMAN" if sender_type not in {"HUMAN", "BOT"}: diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py index a5a1bc0dac2..248650aaf82 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -485,6 +485,49 @@ class TestOnPubsubMessage: submit.assert_not_called() msg.ack.assert_called_once() + def test_relay_flat_bot_sender_is_filtered_end_to_end(self, adapter): + """Format 3 end-to-end: a relay envelope declaring sender_type=BOT + flows through ``_extract_message_payload`` → ``_on_pubsub_message`` + and is dropped by the BOT self-filter without dispatch. This is + the actual security contract (the unit tests on + ``_extract_message_payload`` only assert the intermediate dict + shape; this test asserts the dispatch is suppressed). + """ + envelope = { + "event_type": "MESSAGE", + "sender_email": "bot@bots.example.com", + "sender_display_name": "HermesBot", + "sender_type": "BOT", + "text": "reply from bot", + "space_name": "spaces/RELAY", + "message_name": "spaces/RELAY/messages/M.M", + } + msg = _make_pubsub_message(envelope) + with patch.object(adapter, "_submit_on_loop") as submit: + adapter._on_pubsub_message(msg) + submit.assert_not_called() + msg.ack.assert_called_once() + + def test_relay_flat_human_sender_dispatches(self, adapter): + """Format 3 negative control: an envelope without sender_type + (or with sender_type=HUMAN) still dispatches to the agent loop, + confirming the BOT-filter doesn't accidentally drop legitimate + human messages from a relay. + """ + envelope = { + "event_type": "MESSAGE", + "sender_email": "alice@example.com", + "sender_display_name": "Alice", + "text": "hello agent", + "space_name": "spaces/RELAY", + "message_name": "spaces/RELAY/messages/M.M", + } + msg = _make_pubsub_message(envelope) + with patch.object(adapter, "_submit_on_loop") as submit: + adapter._on_pubsub_message(msg) + submit.assert_called_once() + msg.ack.assert_called_once() + def test_duplicate_message_dropped(self, adapter): env = _make_chat_envelope(msg_name="spaces/S/messages/DUP.DUP") # Prime dedup @@ -603,6 +646,74 @@ class TestExtractMessagePayload: assert msg["name"] == "spaces/RELAY/messages/M.M" assert space["name"] == "spaces/RELAY" + def test_relay_flat_honors_declared_sender_type_bot(self): + """Format 3 propagates ``envelope.sender_type`` so the downstream + BOT self-filter fires for relay-forwarded bot replies. + + Without this, a relay misconfigured to forward the bot's own + replies into the same Pub/Sub topic produced a feedback loop: + the adapter would mark the synthesized sender ``HUMAN`` and the + ``sender.type == "BOT"`` self-filter would never fire. + """ + envelope = { + "event_type": "MESSAGE", + "sender_email": "bot@bots.example.com", + "sender_display_name": "HermesBot", + "sender_type": "BOT", + "text": "reply from bot", + "space_name": "spaces/RELAY", + "message_name": "spaces/RELAY/messages/M.M", + } + result = GoogleChatAdapter._extract_message_payload(envelope) + assert result is not None + msg, _space, fmt = result + assert fmt == "relay_flat" + assert msg["sender"]["type"] == "BOT" + + def test_relay_flat_defaults_sender_type_human_when_absent(self): + """Backward compatibility: relays that don't declare sender_type + continue to flow as HUMAN exactly as before this change.""" + envelope = { + "event_type": "MESSAGE", + "sender_email": "alice@example.com", + "text": "hi", + "space_name": "spaces/RELAY", + "message_name": "spaces/RELAY/messages/M.M", + } + result = GoogleChatAdapter._extract_message_payload(envelope) + assert result is not None + msg, _space, _fmt = result + assert msg["sender"]["type"] == "HUMAN" + + def test_relay_flat_coerces_unknown_sender_type_to_human(self): + """Defensive coercion: only ``HUMAN`` and ``BOT`` are accepted; + any other value (including stray casing on those two) is either + normalized or falls back to ``HUMAN`` so a malformed relay can't + slip an unrecognized type through to the downstream filter.""" + # Lower / mixed case is normalized to upper. + envelope_lower = { + "event_type": "MESSAGE", + "sender_email": "bot@example.com", + "sender_type": " bot ", + "text": "hi", + "space_name": "spaces/RELAY", + "message_name": "spaces/RELAY/messages/M.M", + } + msg, _space, _fmt = GoogleChatAdapter._extract_message_payload(envelope_lower) + assert msg["sender"]["type"] == "BOT" + + # Unknown value falls back to HUMAN, not the raw string. + envelope_bogus = { + "event_type": "MESSAGE", + "sender_email": "alice@example.com", + "sender_type": "ROBOT", + "text": "hi", + "space_name": "spaces/RELAY", + "message_name": "spaces/RELAY/messages/M.M", + } + msg, _space, _fmt = GoogleChatAdapter._extract_message_payload(envelope_bogus) + assert msg["sender"]["type"] == "HUMAN" + def test_unrecognized_envelope_returns_none(self): """Random JSON with no known shape returns None (caller acks).""" envelope = {"foo": "bar", "baz": 123}