Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,69 @@ async def _on_approve(ack, body, action):
action_id,
)

# -- telegram callback handler registration -----------------------------

def register_telegram_callback_handler(
self,
matcher: Any,
callback: Callable,
) -> None:
"""Register a handler for namespaced Telegram callback data.

``matcher`` may be a non-empty literal prefix string (for example
``"inbox:"``) or a compiled regular expression. Callbacks receive
``(adapter, query, context)`` and may be synchronous or asynchronous.
Returning ``True`` or ``{"handled": True}`` claims the callback.
"""
if not callable(callback):
raise ValueError(
f"Plugin '{self.manifest.name}' tried to register a Telegram "
"callback handler with a non-callable callback."
)
if isinstance(matcher, str):
matcher = matcher.strip()
if not matcher or ":" not in matcher:
raise ValueError(
f"Plugin '{self.manifest.name}' Telegram callback prefix "
"must be non-empty and namespaced (for example 'plugin:')."
)
matcher_key = ("prefix", matcher)
elif callable(getattr(matcher, "match", None)):
matcher_key = (
"regex",
getattr(matcher, "pattern", repr(matcher)),
getattr(matcher, "flags", None),
)
else:
raise ValueError(
f"Plugin '{self.manifest.name}' Telegram callback matcher must "
"be a literal prefix string or compiled regular expression."
)

for existing, _callback, plugin_name in self._manager._telegram_callback_handlers:
if isinstance(existing, str):
existing_key = ("prefix", existing)
else:
existing_key = (
"regex",
getattr(existing, "pattern", repr(existing)),
getattr(existing, "flags", None),
)
if existing_key == matcher_key:
raise ValueError(
f"Plugin '{self.manifest.name}' Telegram callback matcher "
f"conflicts with plugin '{plugin_name}'."
)

self._manager._telegram_callback_handlers.append(
(matcher, callback, self.manifest.name)
)
logger.debug(
"Plugin %s registered Telegram callback handler: %s",
self.manifest.name,
matcher_key[1],
)

# -- hook registration --------------------------------------------------

# -- auxiliary task registration ---------------------------------------
Expand Down Expand Up @@ -1333,6 +1396,9 @@ def __init__(self) -> None:
# ``re.Pattern``, or a constraint dict); ``callback`` is an async
# function with the slack_bolt signature ``(ack, body, action)``.
self._slack_action_handlers: List[tuple] = []
# Telegram inline-keyboard callback handlers registered by plugins.
# Each entry is (prefix_or_regex, callback, plugin_name).
self._telegram_callback_handlers: List[tuple] = []

# -----------------------------------------------------------------------
# Public
Expand Down Expand Up @@ -1363,6 +1429,7 @@ def discover_and_load(self, force: bool = False) -> None:
self._portable_mcp_servers.clear()
self._aux_tasks.clear()
self._slack_action_handlers.clear()
self._telegram_callback_handlers.clear()
self._context_engine = None
# Set the flag up front as a re-entrancy guard (a plugin's register()
# can transitively trigger discovery again), but reset it if the sweep
Expand Down Expand Up @@ -2184,6 +2251,10 @@ def get_slack_action_handlers(self) -> List[tuple]:
"""
return list(self._slack_action_handlers)

def get_telegram_callback_handlers(self) -> List[tuple]:
"""Return plugin Telegram callback handlers in registration order."""
return list(self._telegram_callback_handlers)

# -----------------------------------------------------------------------
# Introspection
# -----------------------------------------------------------------------
Expand Down
129 changes: 129 additions & 0 deletions plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6375,6 +6375,69 @@ def get_label(slug):
# Catch-all (e.g. page counter button "mx:noop")
await query.answer()

async def dispatch_callback_text(self, query: Any, text: str) -> None:
"""Dispatch callback-selected text as an ordinary Telegram user turn.

The synthetic event preserves the callback user's identity and the
picker message's chat/topic identity, then enters the same public
``handle_message`` path as typed Telegram text.
"""
text = str(text or "").strip()
query_message = getattr(query, "message", None)
query_user = getattr(query, "from_user", None)
query_chat = getattr(query_message, "chat", None)
if not text or query_message is None or query_user is None or query_chat is None:
raise ValueError("Callback query is missing text, user, message, or chat identity")

chat_id = str(getattr(query_chat, "id", getattr(query_message, "chat_id", "")))
user_id = str(getattr(query_user, "id", ""))
telegram_chat_type = str(getattr(query_chat, "type", "")).split(".")[-1].lower()
normalized_chat_type = "dm"
if telegram_chat_type in {"group", "supergroup"}:
normalized_chat_type = "group"
elif telegram_chat_type == "channel":
normalized_chat_type = "channel"
# Use the same topic normalizer as ordinary typed messages. Telegram
# also puts reply-anchor IDs in message_thread_id; those are not
# durable session threads, while forum General normalizes to thread 1.
thread_id = self._effective_message_thread_id(query_message)

if not self._is_callback_user_authorized(
user_id,
chat_id=chat_id,
chat_type=normalized_chat_type,
thread_id=thread_id,
user_name=getattr(query_user, "first_name", None),
):
raise PermissionError("Telegram callback user is not authorized")

source = self.build_source(
chat_id=chat_id,
chat_name=(
getattr(query_chat, "title", None)
or getattr(query_chat, "full_name", None)
),
chat_type=normalized_chat_type,
user_id=user_id,
user_name=(
getattr(query_user, "full_name", None)
or getattr(query_user, "first_name", None)
),
thread_id=thread_id,
message_id=str(getattr(query_message, "message_id", "")),
is_bot=False,
)
event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
raw_message=query_message,
message_id=str(getattr(query_message, "message_id", "")),
timestamp=getattr(query_message, "date", None) or datetime.now(timezone.utc),
metadata={"telegram_callback_dispatch": True},
)
await self.handle_message(event)

async def _notify_clarify_expired(self, query, user_display: str) -> None:
"""Tell the user a clarify tap arrived too late to be delivered.

Expand Down Expand Up @@ -6415,6 +6478,72 @@ async def _handle_callback_query(
query_thread_id = getattr(query_message, "message_thread_id", None)
query_user_name = getattr(query.from_user, "first_name", None)

# --- Plugin callback handlers ---
# Plugins claim only their own namespaced callback data. Unclaimed or
# failed callbacks continue through the unchanged built-in routes.
try:
from hermes_cli.plugins import get_plugin_manager

plugin_handlers = get_plugin_manager().get_telegram_callback_handlers()
except Exception as exc:
logger.warning("[%s] Could not load Telegram plugin callback handlers: %s", self.name, exc)
plugin_handlers = []

built_in_prefixes = (
"mp:", "mpg:", "mpv:", "mm:", "mc:", "mb", "mx", "mg:",
"cp:", "gt:", "ea:", "sc:", "cl:", "update_prompt:",
)
# Built-in controls retain precedence even if a plugin registers an
# over-broad matcher. Plugin callbacks are an extension namespace, not
# an override mechanism for approvals, clarify prompts, or settings.
if data.startswith(built_in_prefixes):
plugin_handlers = []

for matcher, callback, plugin_name in plugin_handlers:
try:
matched = (
data.startswith(matcher)
if isinstance(matcher, str)
else bool(matcher.match(data))
)
except Exception as exc:
logger.warning(
"[%s] Telegram callback matcher from plugin %s failed: %s",
self.name,
plugin_name,
exc,
)
continue
if not matched:
continue

caller_id = str(getattr(query.from_user, "id", ""))
if not self._is_callback_user_authorized(
caller_id,
chat_id=query_chat_id,
chat_type=str(query_chat_type) if query_chat_type is not None else None,
thread_id=str(query_thread_id) if query_thread_id is not None else None,
user_name=query_user_name,
):
await query.answer(text="⛔ You are not authorized to use this control.")
return

try:
result = callback(self, query, context)
if inspect.isawaitable(result):
result = await result
except Exception as exc:
logger.warning(
"[%s] Telegram callback handler from plugin %s failed: %s",
self.name,
plugin_name,
exc,
exc_info=True,
)
continue
if result is True or (isinstance(result, dict) and result.get("handled") is True):
return

# --- Model picker callbacks ---
if data.startswith(("mp:", "mpg:", "mpv:", "mm:", "mc:", "mb", "mx", "mg:")):
chat_id = str(query.message.chat_id) if query.message else None
Expand Down
137 changes: 137 additions & 0 deletions tests/gateway/test_telegram_approval_buttons.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,143 @@ async def mock_send_message(**kwargs):
class TestTelegramApprovalCallback:
"""Test the approval callback handling in _handle_callback_query."""

@pytest.mark.asyncio
async def test_plugin_callback_claims_namespaced_data(self):
adapter = _make_adapter()
callback = AsyncMock(return_value={"handled": True})
manager = MagicMock()
manager.get_telegram_callback_handlers.return_value = [
("dp:", callback, "digest-picker")
]
query = AsyncMock()
query.data = "dp:t:abc:2"
query.message = MagicMock(chat_id=12345, message_thread_id=None)
query.message.chat.type = "private"
query.from_user = MagicMock(id="12345", first_name="Tim")
update = MagicMock(callback_query=query)
context = MagicMock()

with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "12345"}, clear=False):
with patch("hermes_cli.plugins.get_plugin_manager", return_value=manager):
await adapter._handle_callback_query(update, context)

callback.assert_awaited_once_with(adapter, query, context)

@pytest.mark.asyncio
async def test_builtin_callback_takes_precedence_over_plugin_matcher(self):
adapter = _make_adapter()
adapter._approval_state[8] = "some-session"
callback = AsyncMock(return_value={"handled": True})
manager = MagicMock()
manager.get_telegram_callback_handlers.return_value = [
("ea:", callback, "broken-plugin")
]
query = AsyncMock()
query.data = "ea:once:8"
query.message = MagicMock(chat_id=12345, message_thread_id=None)
query.message.chat.type = "private"
query.from_user = MagicMock(id="12345", first_name="Tim")
update = MagicMock(callback_query=query)

with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "12345"}, clear=False):
with patch("hermes_cli.plugins.get_plugin_manager", return_value=manager):
with patch("tools.approval.resolve_gateway_approval", return_value=1) as resolve:
await adapter._handle_callback_query(update, MagicMock())

resolve.assert_called_once_with("some-session", "once")
callback.assert_not_awaited()

@pytest.mark.asyncio
async def test_plugin_callback_exception_does_not_break_polling(self):
adapter = _make_adapter()
callback = AsyncMock(side_effect=RuntimeError("boom"))
manager = MagicMock()
manager.get_telegram_callback_handlers.return_value = [
("zz:", callback, "broken-plugin")
]
query = AsyncMock()
query.data = "zz:anything"
query.message = MagicMock(chat_id=12345, message_thread_id=None)
query.message.chat.type = "private"
query.from_user = MagicMock(id="12345", first_name="Tim")
update = MagicMock(callback_query=query)

with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "12345"}, clear=False):
with patch("hermes_cli.plugins.get_plugin_manager", return_value=manager):
await adapter._handle_callback_query(update, MagicMock())

callback.assert_awaited_once()

@pytest.mark.asyncio
async def test_dispatch_callback_text_preserves_identity_and_topic(self):
adapter = _make_adapter()
adapter.handle_message = AsyncMock()
query = MagicMock()
query.from_user.id = 777
query.from_user.full_name = "Tim"
query.message.chat.id = 12345
query.message.chat.type = "private"
query.message.chat.full_name = "Tim"
query.message.message_id = 42
query.message.message_thread_id = 99
query.message.is_topic_message = False
query.message.date = None

with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "777"}, clear=False):
await adapter.dispatch_callback_text(query, "3,1,3")

event = adapter.handle_message.await_args.args[0]
assert event.text == "3,1,3"
assert event.source.platform == Platform.TELEGRAM
assert event.source.chat_id == "12345"
assert event.source.user_id == "777"
assert event.source.thread_id is None
assert event.metadata["telegram_callback_dispatch"] is True

@pytest.mark.asyncio
async def test_dispatch_callback_text_preserves_real_dm_topic(self):
adapter = _make_adapter()
adapter.handle_message = AsyncMock()
query = MagicMock()
query.from_user.id = 777
query.from_user.full_name = "Tim"
query.message.chat.id = 12345
query.message.chat.type = "private"
query.message.chat.full_name = "Tim"
query.message.message_id = 42
query.message.message_thread_id = 99
query.message.is_topic_message = True
query.message.date = None

with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "777"}, clear=False):
await adapter.dispatch_callback_text(query, "3,1,3")

event = adapter.handle_message.await_args.args[0]
assert event.source.chat_type == "dm"
assert event.source.thread_id == "99"

@pytest.mark.asyncio
async def test_dispatch_callback_text_normalizes_forum_general_topic(self):
adapter = _make_adapter()
adapter.handle_message = AsyncMock()
query = MagicMock()
query.from_user.id = 777
query.from_user.full_name = "Tim"
query.message.chat.id = -10012345
query.message.chat.type = "supergroup"
query.message.chat.is_forum = True
query.message.chat.title = "Digest group"
query.message.message_id = 42
query.message.message_thread_id = None
query.message.is_topic_message = True
query.message.date = None

with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "777"}, clear=False):
await adapter.dispatch_callback_text(query, "3,1,3")

event = adapter.handle_message.await_args.args[0]
assert event.source.chat_type == "group"
assert event.source.thread_id == "1"

@pytest.mark.asyncio
async def test_resume_typing_after_inline_approval(self):
Expand Down
Loading