Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,741 @@
|
||||
"""Tests for the ``gateway_platform_event`` observer hook (#64176's observer half).
|
||||
|
||||
Covers the normalized-envelope pattern that replaces raw-SDK handler args:
|
||||
* only ``gateway_platform_event`` is registered in ``VALID_HOOKS`` (no inert
|
||||
hook surface without a concrete fire site)
|
||||
* the adapter forwards normalized events to a runner-owned callback; the runner
|
||||
performs the authoritative post-auth check before invoking plugins
|
||||
* ``TelegramAdapter._normalize_platform_event`` maps an inbound PTB update to a
|
||||
stable ``{platform, event_type, payload}`` envelope (no raw SDK objects),
|
||||
including custom-emoji reactions
|
||||
* ``_on_platform_update`` fires ``gateway_platform_event`` with that envelope,
|
||||
gated on the same authorization decision as inbound gateway traffic
|
||||
(unauthorized reactions never fire), and swallows errors so the observer
|
||||
can't break the adapter
|
||||
* ``_register_handlers`` is the single PTB handler registration site, so a
|
||||
rebuild re-registers the observer alongside the core handlers
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
_repo = str(Path(__file__).resolve().parents[2])
|
||||
if _repo not in sys.path:
|
||||
sys.path.insert(0, _repo)
|
||||
|
||||
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
from gateway.run import GatewayRunner # noqa: E402
|
||||
from gateway.profile_routing import ProfileRoute # noqa: E402
|
||||
from hermes_cli.plugins import ( # noqa: E402
|
||||
VALID_HOOKS,
|
||||
PluginContext,
|
||||
PluginManager,
|
||||
PluginManifest,
|
||||
)
|
||||
|
||||
|
||||
def _adapter(extra=None) -> TelegramAdapter:
|
||||
"""Build a TelegramAdapter without the heavy __init__.
|
||||
|
||||
_fire_gateway_hook / _normalize_platform_event / the post-auth gate only
|
||||
need self.name (a read-only property over self.platform) and self.config,
|
||||
so set stand-ins. The default config opens auth (allow_from=["*"]) so a
|
||||
normal reaction fires; pass a restrictive ``extra`` to exercise the gate.
|
||||
"""
|
||||
a = object.__new__(TelegramAdapter)
|
||||
a.platform = Platform.TELEGRAM
|
||||
a.config = SimpleNamespace(extra=extra if extra is not None else {"allow_from": ["*"]})
|
||||
a.gateway_runner = None
|
||||
return a
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _observer_available(monkeypatch):
|
||||
"""Most fire-site tests exercise the subscribed path explicitly."""
|
||||
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda _name: True)
|
||||
|
||||
|
||||
def _reaction(*, emoji=None, custom_emoji_id=None):
|
||||
"""A PTB ReactionType stand-in.
|
||||
|
||||
PTB exposes ``.emoji`` for standard-emoji reactions and
|
||||
``.custom_emoji_id`` for custom-emoji reactions (one or the other). Set
|
||||
both explicitly so the MagicMock doesn't auto-supply a truthy attribute.
|
||||
"""
|
||||
r = MagicMock()
|
||||
r.emoji = emoji
|
||||
r.custom_emoji_id = custom_emoji_id
|
||||
return r
|
||||
|
||||
|
||||
def _reaction_update(reactions, chat_id: object = 123, message_id: object = 456):
|
||||
"""A PTB Update stand-in carrying a message_reaction with ``reactions``."""
|
||||
update = MagicMock()
|
||||
update.message_reaction = MagicMock()
|
||||
update.message_reaction.chat.id = chat_id
|
||||
update.message_reaction.message_id = message_id
|
||||
update.message_reaction.new_reaction = list(reactions)
|
||||
return update
|
||||
|
||||
|
||||
def _auth_reaction_update(user_id, chat_type="private", chat_id=123, message_id=456):
|
||||
"""A PTB Update stand-in carrying a message_reaction with an actor identity.
|
||||
|
||||
Wraps ``_reaction_update`` and pins the reactor's user id + chat type so the
|
||||
post-auth gate has an identity to authorize against.
|
||||
"""
|
||||
update = _reaction_update(
|
||||
[_reaction(emoji="\U0001F44D")], chat_id=chat_id, message_id=message_id,
|
||||
)
|
||||
update.message_reaction.user.id = str(user_id)
|
||||
update.message_reaction.chat.type = chat_type
|
||||
return update
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hook registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHookRegistration:
|
||||
def test_gateway_platform_event_registered_reserved_absent(self):
|
||||
"""register_hook rejects names not in VALID_HOOKS, so the implemented
|
||||
hook must be present. The reserved gateway_* names are deliberately
|
||||
absent (no inert surface without a concrete fire site); lock that in."""
|
||||
assert "gateway_platform_event" in VALID_HOOKS
|
||||
assert "gateway_session_titled" not in VALID_HOOKS
|
||||
assert "gateway_message_delivered" not in VALID_HOOKS
|
||||
assert "gateway_thread_created" not in VALID_HOOKS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runner-owned dispatch — authoritative post-auth gate + isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunnerDispatch:
|
||||
def test_authorized_event_routes_normalized_envelope(self):
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._is_user_authorized = lambda source: source.user_id == "777"
|
||||
invoke = MagicMock()
|
||||
source = _adapter()._source_from_reaction_for_auth(
|
||||
_auth_reaction_update(user_id=777)
|
||||
)
|
||||
event = {
|
||||
"platform": "telegram",
|
||||
"event_type": "reaction",
|
||||
"payload": {"chat_id": "123", "message_id": "456", "emojis": ["x"]},
|
||||
}
|
||||
|
||||
with patch("hermes_cli.lifecycle.invoke_hook", invoke):
|
||||
asyncio.run(runner._handle_gateway_platform_event(event, source))
|
||||
|
||||
invoke.assert_called_once_with("gateway_platform_event", **event)
|
||||
|
||||
def test_unauthorized_event_never_reaches_hooks(self):
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._is_user_authorized = lambda source: False
|
||||
invoke = MagicMock()
|
||||
source = _adapter()._source_from_reaction_for_auth(
|
||||
_auth_reaction_update(user_id=777)
|
||||
)
|
||||
|
||||
with patch("hermes_cli.lifecycle.invoke_hook", invoke):
|
||||
asyncio.run(runner._handle_gateway_platform_event(
|
||||
{"platform": "telegram", "event_type": "reaction", "payload": {}},
|
||||
source,
|
||||
))
|
||||
|
||||
invoke.assert_not_called()
|
||||
|
||||
def test_skips_dispatch_when_no_subscriber(self):
|
||||
runner = object.__new__(GatewayRunner)
|
||||
authorized = MagicMock(return_value=True)
|
||||
runner._is_user_authorized = authorized
|
||||
invoke = MagicMock()
|
||||
source = _adapter()._source_from_reaction_for_auth(
|
||||
_auth_reaction_update(user_id=777)
|
||||
)
|
||||
|
||||
with patch("hermes_cli.lifecycle.has_hook", return_value=False), patch(
|
||||
"hermes_cli.lifecycle.invoke_hook", invoke
|
||||
):
|
||||
asyncio.run(runner._handle_gateway_platform_event(
|
||||
{"platform": "telegram", "event_type": "reaction", "payload": {}},
|
||||
source,
|
||||
))
|
||||
|
||||
authorized.assert_not_called()
|
||||
invoke.assert_not_called()
|
||||
|
||||
def test_plugin_layer_error_is_isolated(self):
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._is_user_authorized = lambda source: True
|
||||
invoke = MagicMock(side_effect=RuntimeError("plugin boom"))
|
||||
source = _adapter()._source_from_reaction_for_auth(
|
||||
_auth_reaction_update(user_id=777)
|
||||
)
|
||||
|
||||
with patch("hermes_cli.lifecycle.invoke_hook", invoke):
|
||||
asyncio.run(runner._handle_gateway_platform_event(
|
||||
{"platform": "telegram", "event_type": "reaction", "payload": {}},
|
||||
source,
|
||||
)) # no raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TelegramAdapter._normalize_platform_event — envelope normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalizePlatformEvent:
|
||||
def test_standard_emoji_reaction_normalized(self):
|
||||
"""A message_reaction update becomes {platform, event_type, payload} with
|
||||
exactly the fields a real plugin consumes — no raw SDK objects."""
|
||||
a = _adapter()
|
||||
update = _reaction_update([_reaction(emoji="\U0001F44E")], chat_id=123, message_id=456)
|
||||
|
||||
assert a._normalize_platform_event(update) == {
|
||||
"platform": "telegram",
|
||||
"event_type": "reaction",
|
||||
"payload": {
|
||||
"emojis": ["\U0001F44E"],
|
||||
"custom_emoji_ids": [],
|
||||
"chat_id": "123",
|
||||
"message_id": "456",
|
||||
"thread_id": None,
|
||||
},
|
||||
}
|
||||
|
||||
def test_custom_emoji_reaction_normalized(self):
|
||||
"""Custom-emoji reactions expose custom_emoji_id (no .emoji) — captured
|
||||
separately so a string-joining consumer never sees None."""
|
||||
a = _adapter()
|
||||
update = _reaction_update([_reaction(custom_emoji_id="555123")])
|
||||
|
||||
event = a._normalize_platform_event(update)
|
||||
assert event["payload"]["emojis"] == []
|
||||
assert event["payload"]["custom_emoji_ids"] == ["555123"]
|
||||
|
||||
def test_mixed_reactions_split_correctly(self):
|
||||
"""A reaction set with standard + custom emojis splits into both lists."""
|
||||
a = _adapter()
|
||||
update = _reaction_update([
|
||||
_reaction(emoji="\U0001F44D"),
|
||||
_reaction(custom_emoji_id="555"),
|
||||
_reaction(emoji="\U0001F525"),
|
||||
])
|
||||
|
||||
event = a._normalize_platform_event(update)
|
||||
assert event["payload"]["emojis"] == ["\U0001F44D", "\U0001F525"]
|
||||
assert event["payload"]["custom_emoji_ids"] == ["555"]
|
||||
|
||||
def test_malformed_values_never_escape_as_live_objects(self):
|
||||
a = _adapter()
|
||||
update = _reaction_update(
|
||||
[_reaction(emoji=object(), custom_emoji_id=object())]
|
||||
)
|
||||
|
||||
event = a._normalize_platform_event(update)
|
||||
|
||||
assert event is not None
|
||||
assert event["payload"]["emojis"] == []
|
||||
assert event["payload"]["custom_emoji_ids"] == []
|
||||
json.dumps(event)
|
||||
|
||||
def test_reaction_count_and_string_lengths_are_bounded(self):
|
||||
a = _adapter()
|
||||
update = _reaction_update(
|
||||
[_reaction(emoji="x" * 100, custom_emoji_id="9" * 200) for _ in range(100)],
|
||||
chat_id="c" * 200,
|
||||
message_id="m" * 200,
|
||||
)
|
||||
|
||||
event = a._normalize_platform_event(update)
|
||||
|
||||
assert event is not None
|
||||
payload = event["payload"]
|
||||
assert len(payload["emojis"]) == 64
|
||||
assert len(payload["custom_emoji_ids"]) == 64
|
||||
assert all(len(value) == 64 for value in payload["emojis"])
|
||||
assert all(len(value) == 128 for value in payload["custom_emoji_ids"])
|
||||
assert len(payload["chat_id"]) == 128
|
||||
assert len(payload["message_id"]) == 128
|
||||
|
||||
def test_non_reaction_update_returns_none(self):
|
||||
"""Unsupported update types return None until a concrete contract exists."""
|
||||
a = _adapter()
|
||||
update = MagicMock()
|
||||
update.message_reaction = None # e.g. a chat_member update
|
||||
update.edited_message = None
|
||||
|
||||
assert a._normalize_platform_event(update) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TelegramAdapter message_edited normalization (#64176 remaining scope)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _edited_update(
|
||||
*,
|
||||
chat_id: object = 123,
|
||||
message_id: object = 456,
|
||||
text: object = "fixed typo",
|
||||
user_id: object = 777,
|
||||
chat_type: str = "private",
|
||||
):
|
||||
"""A PTB Update stand-in carrying an edited_message."""
|
||||
update = MagicMock()
|
||||
update.message_reaction = None
|
||||
m = MagicMock()
|
||||
m.chat.id = chat_id
|
||||
m.chat.type = chat_type
|
||||
m.chat.is_forum = False
|
||||
m.message_id = message_id
|
||||
m.text = text
|
||||
m.caption = None
|
||||
m.message_thread_id = None
|
||||
m.is_topic_message = False
|
||||
m.edit_date = None
|
||||
m.from_user.id = user_id
|
||||
m.from_user.username = "editor"
|
||||
m.from_user.full_name = "Editor"
|
||||
update.edited_message = m
|
||||
return update
|
||||
|
||||
|
||||
class TestNormalizeMessageEdited:
|
||||
def test_edited_message_normalized(self):
|
||||
a = _adapter()
|
||||
update = _edited_update(chat_id=123, message_id=456, text="fixed typo")
|
||||
|
||||
assert a._normalize_platform_event(update) == {
|
||||
"platform": "telegram",
|
||||
"event_type": "message_edited",
|
||||
"payload": {
|
||||
"chat_id": "123",
|
||||
"message_id": "456",
|
||||
"thread_id": None,
|
||||
"text": "fixed typo",
|
||||
"edited_at": None,
|
||||
},
|
||||
}
|
||||
|
||||
def test_caption_falls_back_when_no_text(self):
|
||||
a = _adapter()
|
||||
update = _edited_update(text=None)
|
||||
update.edited_message.caption = "new caption"
|
||||
|
||||
event = a._normalize_platform_event(update)
|
||||
assert event["payload"]["text"] == "new caption"
|
||||
|
||||
def test_forum_topic_thread_id_included(self):
|
||||
a = _adapter()
|
||||
update = _edited_update(chat_type="supergroup")
|
||||
update.edited_message.message_thread_id = 42
|
||||
update.edited_message.is_topic_message = True
|
||||
update.edited_message.chat.is_forum = True
|
||||
|
||||
event = a._normalize_platform_event(update)
|
||||
assert event["payload"]["thread_id"] == "42"
|
||||
|
||||
def test_edit_date_serialized_iso(self):
|
||||
import datetime as _dt
|
||||
|
||||
a = _adapter()
|
||||
update = _edited_update()
|
||||
update.edited_message.edit_date = _dt.datetime(
|
||||
2026, 8, 12, 10, 30, tzinfo=_dt.timezone.utc,
|
||||
)
|
||||
|
||||
event = a._normalize_platform_event(update)
|
||||
assert event["payload"]["edited_at"] == "2026-08-12T10:30:00+00:00"
|
||||
|
||||
def test_malformed_identities_return_none(self):
|
||||
a = _adapter()
|
||||
update = _edited_update(chat_id=object())
|
||||
assert a._normalize_platform_event(update) is None
|
||||
update = _edited_update(message_id=None)
|
||||
assert a._normalize_platform_event(update) is None
|
||||
|
||||
def test_text_is_bounded_and_json_safe(self):
|
||||
a = _adapter()
|
||||
update = _edited_update(text="x" * 20000)
|
||||
|
||||
event = a._normalize_platform_event(update)
|
||||
assert len(event["payload"]["text"]) == 8192
|
||||
json.dumps(event)
|
||||
|
||||
def test_edited_event_fires_through_boundary_with_editor_source(self):
|
||||
a = _adapter()
|
||||
seen: list = []
|
||||
|
||||
async def observe(event, source):
|
||||
seen.append((event, source))
|
||||
|
||||
a.set_platform_event_handler(observe)
|
||||
asyncio.run(a._on_platform_update(_edited_update(), context=MagicMock()))
|
||||
|
||||
assert len(seen) == 1
|
||||
event, source = seen[0]
|
||||
assert event["event_type"] == "message_edited"
|
||||
assert source.user_id == "777"
|
||||
assert source.chat_id == "123"
|
||||
|
||||
def test_edited_event_missing_editor_fails_closed(self):
|
||||
"""No from_user (and no sender_chat) means no identity to authorize —
|
||||
the boundary must drop the event rather than fire it."""
|
||||
a = _adapter()
|
||||
seen: list = []
|
||||
|
||||
async def observe(event, source):
|
||||
seen.append((event, source))
|
||||
|
||||
a.set_platform_event_handler(observe)
|
||||
update = _edited_update()
|
||||
update.edited_message.from_user = None
|
||||
update.edited_message.sender_chat = None
|
||||
|
||||
asyncio.run(a._on_platform_update(update, context=MagicMock()))
|
||||
assert seen == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TelegramAdapter._on_platform_update — fire-site
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestOnPlatformUpdate:
|
||||
def test_no_subscriber_skips_normalization_source_and_dispatch(self):
|
||||
a = _adapter()
|
||||
a._normalize_platform_event = MagicMock()
|
||||
a._source_from_reaction_for_auth = MagicMock()
|
||||
handler = AsyncMock()
|
||||
a.set_platform_event_handler(handler)
|
||||
|
||||
with patch("hermes_cli.lifecycle.has_hook", return_value=False):
|
||||
asyncio.run(a._on_platform_update(MagicMock(), context=MagicMock()))
|
||||
|
||||
a._normalize_platform_event.assert_not_called()
|
||||
a._source_from_reaction_for_auth.assert_not_called()
|
||||
handler.assert_not_awaited()
|
||||
|
||||
def test_fires_gateway_platform_event_with_envelope(self):
|
||||
a = _adapter()
|
||||
seen: list = []
|
||||
async def observe(event, source):
|
||||
seen.append((event, source))
|
||||
a.set_platform_event_handler(observe)
|
||||
|
||||
asyncio.run(a._on_platform_update(
|
||||
_reaction_update([_reaction(emoji="\U0001F44E")], 123, 456), context=MagicMock(),
|
||||
))
|
||||
|
||||
assert len(seen) == 1
|
||||
event, source = seen[0]
|
||||
assert event["platform"] == "telegram"
|
||||
assert event["event_type"] == "reaction"
|
||||
assert event["payload"]["emojis"] == ["\U0001F44E"]
|
||||
assert event["payload"]["chat_id"] == "123"
|
||||
assert source.chat_id == "123"
|
||||
|
||||
def test_unsupported_update_does_not_fire(self):
|
||||
a = _adapter()
|
||||
seen: list = []
|
||||
async def observe(event, source):
|
||||
seen.append((event, source))
|
||||
a.set_platform_event_handler(observe)
|
||||
|
||||
update = MagicMock()
|
||||
update.message_reaction = None
|
||||
asyncio.run(a._on_platform_update(update, context=MagicMock()))
|
||||
|
||||
assert seen == []
|
||||
|
||||
def test_normalize_error_does_not_propagate(self):
|
||||
"""A malformed update that makes normalize raise must be swallowed — the
|
||||
observer can't break the adapter (regression guard for the try/except)."""
|
||||
a = _adapter()
|
||||
async def observe(event, source):
|
||||
pytest.fail("must not fire on normalize error")
|
||||
a.set_platform_event_handler(observe)
|
||||
|
||||
def boom(update):
|
||||
raise RuntimeError("malformed update")
|
||||
|
||||
a._normalize_platform_event = boom # type: ignore[assignment]
|
||||
asyncio.run(a._on_platform_update(MagicMock(), context=MagicMock())) # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TelegramAdapter._on_platform_update post-auth gate (#64176)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestOnPlatformUpdateAuthBoundary:
|
||||
def test_without_runner_callback_fails_closed(self):
|
||||
"""A catch-all PTB handler must not expose pre-auth updates merely because
|
||||
adapter-local intake would defer an unknown DM to the pairing flow."""
|
||||
a = _adapter(extra={})
|
||||
seen: list = []
|
||||
# No set_platform_event_handler: the authoritative gateway boundary has
|
||||
# not been installed, so even a syntactically valid reaction is dropped.
|
||||
asyncio.run(a._on_platform_update(
|
||||
_auth_reaction_update(user_id=777), context=MagicMock(),
|
||||
))
|
||||
|
||||
assert seen == []
|
||||
|
||||
def test_non_reaction_update_fails_closed(self):
|
||||
"""A future event type whose update carries no message_reaction must
|
||||
NOT fire. _source_from_reaction_for_auth raises and the gate drops it
|
||||
(fail closed); without the guard the no-identity path would authorize
|
||||
and fire it despite the restrictive allow_from."""
|
||||
a = _adapter(extra={"allow_from": ["999"]})
|
||||
seen: list = []
|
||||
async def observe(event, source):
|
||||
seen.append((event, source))
|
||||
a.set_platform_event_handler(observe)
|
||||
|
||||
update = MagicMock()
|
||||
update.message_reaction = None # a future, not-yet-wired event type
|
||||
update.edited_message = None
|
||||
# Simulate that future normalization produced an event for it.
|
||||
a._normalize_platform_event = lambda u: { # type: ignore[assignment]
|
||||
"platform": "telegram", "event_type": "future", "payload": {},
|
||||
}
|
||||
|
||||
asyncio.run(a._on_platform_update(update, context=MagicMock()))
|
||||
|
||||
assert seen == [] # fail closed: never fires without a real auth decision
|
||||
|
||||
@pytest.mark.parametrize("missing", ["actor", "chat", "message_id"])
|
||||
def test_malformed_reaction_identity_fails_closed(self, missing):
|
||||
a = _adapter()
|
||||
seen = []
|
||||
|
||||
async def observe(event, source):
|
||||
seen.append((event, source))
|
||||
|
||||
a.set_platform_event_handler(observe)
|
||||
update = _auth_reaction_update(user_id=777)
|
||||
if missing == "actor":
|
||||
update.message_reaction.user = None
|
||||
update.message_reaction.actor_chat = None
|
||||
elif missing == "chat":
|
||||
update.message_reaction.chat = None
|
||||
else:
|
||||
update.message_reaction.message_id = None
|
||||
|
||||
asyncio.run(a._on_platform_update(update, context=MagicMock()))
|
||||
|
||||
assert seen == []
|
||||
|
||||
|
||||
class TestProfileScopedPlatformEventHandler:
|
||||
def test_primary_shared_transport_uses_profile_route_and_transport_provenance(self):
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = SimpleNamespace( # type: ignore[assignment]
|
||||
multiplex_profiles=True,
|
||||
profile_routes=[
|
||||
ProfileRoute(
|
||||
name="work-chat",
|
||||
platform="telegram",
|
||||
profile="work",
|
||||
chat_id="123",
|
||||
)
|
||||
],
|
||||
)
|
||||
adapter = _adapter()
|
||||
adapter.gateway_runner = runner # type: ignore[assignment]
|
||||
|
||||
# Main now rejects routes to unserved profiles (_profile_name_for_source
|
||||
# checks _multiplex_profile_homes); declare "work" as served so the
|
||||
# route stamps rather than fail-closing to profile=None.
|
||||
with patch(
|
||||
"gateway.run._multiplex_profile_homes",
|
||||
return_value=[("work", Path("/profiles/work"))],
|
||||
):
|
||||
source = adapter._source_from_reaction_for_auth(
|
||||
_auth_reaction_update(user_id=777)
|
||||
)
|
||||
|
||||
assert source.profile == "work"
|
||||
assert getattr(source, "_transport_adapter_ref")() is adapter
|
||||
|
||||
resolver = MagicMock(return_value=Path("/profiles/work"))
|
||||
runner._resolve_profile_home_for_source = resolver
|
||||
dispatch = AsyncMock()
|
||||
runner._handle_gateway_platform_event = dispatch
|
||||
handler = runner._make_default_profile_platform_event_handler()
|
||||
with patch("gateway.run._profile_runtime_scope", side_effect=lambda _home: nullcontext()):
|
||||
asyncio.run(handler({"event_type": "reaction"}, source))
|
||||
|
||||
resolver.assert_called_once_with(source)
|
||||
dispatch.assert_awaited_once_with({"event_type": "reaction"}, source)
|
||||
|
||||
def test_secondary_handler_stamps_profile_before_dispatch(self, monkeypatch):
|
||||
runner = object.__new__(GatewayRunner)
|
||||
captured = {}
|
||||
|
||||
async def dispatch(event, source):
|
||||
captured["event"] = event
|
||||
captured["profile"] = source.profile
|
||||
|
||||
runner._handle_gateway_platform_event = dispatch
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_profile_dir", lambda name: None,
|
||||
)
|
||||
handler = runner._make_profile_platform_event_handler("work")
|
||||
source = _adapter()._source_from_reaction_for_auth(
|
||||
_auth_reaction_update(user_id=777)
|
||||
)
|
||||
|
||||
asyncio.run(handler({"platform": "telegram", "event_type": "reaction", "payload": {}}, source))
|
||||
|
||||
assert captured["profile"] == "work"
|
||||
|
||||
|
||||
class TestFixturePluginObservationPath:
|
||||
def test_reaction_reaches_real_registered_plugin_callback(self):
|
||||
"""Adapter -> normalized source/envelope -> runner auth -> real plugin bus."""
|
||||
manager = PluginManager()
|
||||
context = PluginContext(
|
||||
PluginManifest(name="reaction-fixture", source="user"), manager,
|
||||
)
|
||||
seen = []
|
||||
context.register_hook(
|
||||
"gateway_platform_event", lambda **event: seen.append(event),
|
||||
)
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._is_user_authorized = lambda source: source.user_id == "777"
|
||||
adapter = _adapter()
|
||||
adapter.set_platform_event_handler(runner._handle_gateway_platform_event)
|
||||
|
||||
with patch("hermes_cli.plugins.get_plugin_manager", return_value=manager):
|
||||
asyncio.run(adapter._on_platform_update(
|
||||
_auth_reaction_update(user_id=777), context=MagicMock(),
|
||||
))
|
||||
|
||||
assert seen == [{
|
||||
"platform": "telegram",
|
||||
"event_type": "reaction",
|
||||
"payload": {
|
||||
"emojis": ["\U0001F44D"],
|
||||
"custom_emoji_ids": [],
|
||||
"chat_id": "123",
|
||||
"message_id": "456",
|
||||
"thread_id": None,
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TelegramAdapter._register_handlers single registration site (#64176)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRegisterHandlers:
|
||||
"""_register_handlers is the sole PTB handler registration site, so a
|
||||
handler added there is registered on every (re)build that calls it. The
|
||||
#64176 review asked to share registration between the initial path and any
|
||||
rebuild; these tests pin that the observer (group 99) is included alongside
|
||||
the core handlers."""
|
||||
|
||||
_HANDLER_ATTRS = (
|
||||
"_handle_text_message", "_handle_command", "_handle_location_message",
|
||||
"_handle_media_message", "_handle_callback_query", "_on_platform_update",
|
||||
)
|
||||
|
||||
def _adapter_with_handlers(self) -> TelegramAdapter:
|
||||
a = _adapter()
|
||||
# Stand-ins for the bound handler methods. _register_handlers only
|
||||
# passes them to add_handler, it never calls them.
|
||||
for name in self._HANDLER_ATTRS:
|
||||
setattr(a, name, object())
|
||||
return a
|
||||
|
||||
@staticmethod
|
||||
def _observer_calls(app):
|
||||
return [c for c in app.add_handler.call_args_list if c.kwargs.get("group") == 99]
|
||||
|
||||
def test_registers_core_handlers_plus_observer(self):
|
||||
a = self._adapter_with_handlers()
|
||||
app = MagicMock()
|
||||
a._register_handlers(app)
|
||||
|
||||
# Six core handlers (default group, no group kwarg — incl. the
|
||||
# inline command picker) plus the gateway_platform_event observer
|
||||
# alone in group 99, so it observes alongside rather than
|
||||
# displacing the core handlers.
|
||||
calls = app.add_handler.call_args_list
|
||||
assert len(calls) == 7
|
||||
assert len([c for c in calls if c.kwargs.get("group") == 99]) == 1
|
||||
assert len([c for c in calls if not c.kwargs]) == 6
|
||||
|
||||
def test_rebuild_re_registers_observer(self):
|
||||
"""A second call on a fresh app (e.g. a future rebuild) re-registers
|
||||
every handler, observer included."""
|
||||
a = self._adapter_with_handlers()
|
||||
first_app = MagicMock()
|
||||
rebuilt_app = MagicMock()
|
||||
|
||||
a._register_handlers(first_app)
|
||||
a._register_handlers(rebuilt_app) # the rebuild path
|
||||
|
||||
assert rebuilt_app.add_handler.call_count == 7
|
||||
assert len(self._observer_calls(rebuilt_app)) == 1
|
||||
|
||||
def test_transient_init_rebuild_uses_shared_registration(self, monkeypatch):
|
||||
"""The real connect retry path must call the shared registration method
|
||||
for the rebuilt PTB Application, not duplicate only the core handlers."""
|
||||
a = TelegramAdapter(PlatformConfig(enabled=True, token="test-token"))
|
||||
first_app = MagicMock()
|
||||
first_app.bot = MagicMock()
|
||||
first_app.initialize = MagicMock(side_effect=OSError("transient"))
|
||||
rebuilt_app = MagicMock()
|
||||
rebuilt_app.bot = MagicMock()
|
||||
rebuilt_app.initialize = MagicMock(side_effect=RuntimeError("stop after rebuild"))
|
||||
|
||||
builder = MagicMock()
|
||||
builder.token.return_value = builder
|
||||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
builder.build.side_effect = [first_app, rebuilt_app]
|
||||
monkeypatch.setattr(
|
||||
"plugins.platforms.telegram.adapter.Application",
|
||||
SimpleNamespace(builder=MagicMock(return_value=builder)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"plugins.platforms.telegram.adapter.HTTPXRequest", lambda **kwargs: MagicMock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"plugins.platforms.telegram.adapter.discover_fallback_ips",
|
||||
lambda: _async_value([]),
|
||||
)
|
||||
monkeypatch.setattr("asyncio.sleep", MagicMock(return_value=_async_value(None)))
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.acquire_scoped_lock",
|
||||
lambda scope, identity, metadata=None: (True, None),
|
||||
)
|
||||
a._register_handlers = MagicMock()
|
||||
|
||||
result = asyncio.run(a.connect())
|
||||
|
||||
assert result is False
|
||||
assert a._register_handlers.call_args_list == [
|
||||
((first_app,), {}),
|
||||
((rebuilt_app,), {}),
|
||||
]
|
||||
|
||||
|
||||
async def _async_value(value):
|
||||
return value
|
||||
Reference in New Issue
Block a user