diff --git a/AGENTS.md b/AGENTS.md index 2eef63d..1943d6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,10 @@ plugin. final network client rather than replacing the host handler. - Plugins must use `MediaPayload` + `deliver_media` for attachments. The kit owns Hermes media directives, task-local `origin` resolution, route redaction, - the typed result, and successful-send final-response suppression. Consumers + the typed result, successful-send final-response suppression, and the narrow + Telegram `spoiler=True` photo extension. Spoiler delivery must retain Hermes + pre/post-tool hooks, route privacy, topic forwarding, and explicit Bot client + shutdown; normal media must remain on host-managed `send_message`. Consumers must register `transform_media_delivery_output` as Hermes' `transform_llm_output` hook and `clear_media_delivery_state` as `on_session_end`; they must not recreate those contracts or substitute diff --git a/README.md b/README.md index 6d0585f..60f9a88 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,28 @@ def deliver_voice_memo(path: str, **runtime_context): through Hermes' task-local platform/chat/thread context inside the kit, so a plugin never imports gateway internals or exposes raw group IDs to the model. The returned `MediaDeliveryResult` carries success, media type, path, requested -route, a privacy-safe display route, and a redacted host result. +route, a privacy-safe display route, spoiler state, and a redacted host result. + +Telegram spoiler photos are available without patching Hermes core: + +```python +deliver_media( + MediaPayload("/opt/data/avatars/generated/reveal.png", spoiler=True), + target="origin", + **runtime_context, +) +``` + +The ordinary path remains Hermes' host-managed `send_message`. Because that +host contract does not currently expose Telegram's `has_spoiler`, only +`spoiler=True` uses the kit's narrow Telegram extension. The extension accepts +JPG, JPEG, PNG, and WebP photos, resolves the same Hermes current-chat/home +routes (including group topics), runs the normal Hermes `pre_tool_call` and +`post_tool_call` hooks, forwards `has_spoiler=True`, closes its one-shot Bot +client, and returns the same privacy-safe typed result. Voice, document, +non-Telegram, and unsupported-image requests are rejected rather than silently +losing spoiler intent. The Telegram token remains runtime-owned and is never a +model argument or result field. Direct delivery and final response delivery are separate stages in Hermes. A consumer that calls `deliver_media` must register the kit's matching Hermes @@ -259,7 +280,9 @@ supported host tool; unknown names fail explicitly. The upstream Hermes contract suite runs image and typed voice payloads through the real `send_message` target parser, media extractor, and Telegram formatter. It mocks only the final Bot API client and asserts that Hermes calls `send_photo` -and `send_voice` with the expected files, without separate text messages. +and `send_voice` with the expected files, without separate text messages. It +also runs the kit-owned spoiler extension against Hermes' real config, session, +async bridge, and Telegram library shapes while mocking only Bot network calls. ## Logging contract diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index 8fd4c1f..d6863de 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -106,6 +106,7 @@ def register(ctx): _MEDIA_DELIVERY_STATE_TTL_SECONDS = 300.0 _MEDIA_DELIVERY_STATE: dict[str, float] = {} _MEDIA_DELIVERY_STATE_LOCK = threading.Lock() +_TELEGRAM_SPOILER_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} @dataclass(frozen=True) @@ -143,6 +144,7 @@ class MediaPayload: path: Path | str media_type: MediaType | str = MediaType.AUTO caption: str = "" + spoiler: bool = False def __post_init__(self) -> None: path = Path(self.path).expanduser() @@ -158,6 +160,15 @@ def __post_init__(self) -> None: raise ValueError("media_type must be auto, voice, or document") from exc if media_type is MediaType.VOICE and path.suffix.lower() not in {".ogg", ".opus"}: raise ValueError("voice media must use an ogg or opus container") + if not isinstance(self.spoiler, bool): + raise ValueError("spoiler must be a boolean") + if self.spoiler and ( + media_type is not MediaType.AUTO + or path.suffix.lower() not in _TELEGRAM_SPOILER_IMAGE_EXTENSIONS + ): + raise ValueError( + "spoiler media must be an image using jpg, jpeg, png, or webp" + ) object.__setattr__(self, "path", path) object.__setattr__(self, "media_type", media_type) object.__setattr__(self, "caption", str(self.caption or "").strip()) @@ -191,6 +202,7 @@ class MediaDeliveryResult: media_type: MediaType path: Path host_result: dict[str, Any] + spoiler: bool = False def as_dict(self) -> dict[str, Any]: return { @@ -200,6 +212,7 @@ def as_dict(self) -> dict[str, Any]: "media_type": self.media_type.value, "path": str(self.path), "host_result": self.host_result, + "spoiler": self.spoiler, } @@ -518,20 +531,15 @@ def _emit_host_post_tool_call( ) -def invoke_host_tool(name: str, args: dict | None = None, **context: Any) -> str: - """Invoke a Hermes host capability that is not registry-backed. - - Some Hermes capabilities, notably ``send_message``, are runtime services - rather than entries in ``tools.registry``. Plugin handlers must use this - seam instead of ``registry.dispatch`` so the direct host handler is found - while ``pre_tool_call`` and ``post_tool_call`` hooks still observe the - nested operation. - """ - handler = _load_host_tool(name) - tool_args = {} if args is None else args - if not isinstance(tool_args, dict): - raise TypeError("host tool args must be a dict") - +def _invoke_guarded_host_operation( + name: str, + tool_args: dict[str, Any], + operation: Callable[[], Any], + *, + context: dict[str, Any], + failure_message: str, +) -> str: + """Run a host operation behind the real Hermes pre/post-tool hooks.""" try: from hermes_cli.plugins import resolve_pre_tool_block except (ImportError, AttributeError) as exc: @@ -557,17 +565,14 @@ def invoke_host_tool(name: str, args: dict | None = None, **context: Any) -> str started = time.perf_counter() try: - result = handler(tool_args, **context) + result = operation() except Exception as exc: logging.getLogger("hermes_plugin_kit").exception( - "%s: host handler raised; error_type=%s", + "%s: host operation raised; error_type=%s", name, type(exc).__name__, ) - result = json.dumps( - {"error": f"{name} host handler failed: {type(exc).__name__}"}, - ensure_ascii=False, - ) + result = {"error": f"{failure_message}: {type(exc).__name__}"} if not isinstance(result, str): result = json.dumps(result, ensure_ascii=False) @@ -586,6 +591,29 @@ def invoke_host_tool(name: str, args: dict | None = None, **context: Any) -> str return result +def invoke_host_tool(name: str, args: dict | None = None, **context: Any) -> str: + """Invoke a Hermes host capability that is not registry-backed. + + Some Hermes capabilities, notably ``send_message``, are runtime services + rather than entries in ``tools.registry``. Plugin handlers must use this + seam instead of ``registry.dispatch`` so the direct host handler is found + while ``pre_tool_call`` and ``post_tool_call`` hooks still observe the + nested operation. + """ + handler = _load_host_tool(name) + tool_args = {} if args is None else args + if not isinstance(tool_args, dict): + raise TypeError("host tool args must be a dict") + + return _invoke_guarded_host_operation( + name, + tool_args, + lambda: handler(tool_args, **context), + context=context, + failure_message=f"{name} host handler failed", + ) + + def _display_delivery_identifier(value: str) -> str: raw = str(value or "") sign = "-" if raw.startswith("-") else "" @@ -751,6 +779,145 @@ def clear_media_delivery_state( _MEDIA_DELIVERY_STATE.pop(context_id, None) +def _invoke_guarded_spoiler_delivery( + media: MediaPayload, + resolved: ResolvedDeliveryTarget, + context: dict[str, Any], +) -> str: + """Run the kit-owned Telegram extension through Hermes lifecycle guards.""" + tool_args = { + "action": "send", + "target": resolved.host_target, + "message": media.to_message(), + "media_options": {"spoiler": True}, + } + return _invoke_guarded_host_operation( + "send_message", + tool_args, + lambda: _deliver_telegram_spoiler(media, resolved), + context=context, + failure_message="Telegram spoiler delivery failed", + ) + + +def _telegram_spoiler_route( + resolved: ResolvedDeliveryTarget, + config: Any, + platform: Any, +) -> tuple[str, str | None]: + parts = resolved.host_target.split(":", 2) + if not parts or parts[0].lower() != "telegram": + raise ValueError("spoiler media delivery requires a Telegram target") + if len(parts) == 1 or not parts[1]: + home = config.get_home_channel(platform) + if home is None or not str(home.chat_id or "").strip(): + raise RuntimeError("Telegram has no configured home channel") + return str(home.chat_id), str(home.thread_id) if home.thread_id else None + chat_id = parts[1] + thread_id = parts[2] if len(parts) == 3 and parts[2] else None + return chat_id, thread_id + + +def _deliver_telegram_spoiler( + media: MediaPayload, + resolved: ResolvedDeliveryTarget, +) -> dict[str, Any]: + """Send one spoiler photo through Telegram without patching Hermes core.""" + from gateway.config import Platform, load_gateway_config + + config = load_gateway_config() + telegram_platform = Platform.TELEGRAM + platform_config = config.platforms.get(telegram_platform) + if not platform_config or not platform_config.enabled or not platform_config.token: + raise RuntimeError("Telegram is not configured") + chat_id, thread_id = _telegram_spoiler_route( + resolved, + config, + telegram_platform, + ) + + try: + from gateway.platforms.base import utf16_len + + caption_length = utf16_len(media.caption) + except Exception: + caption_length = len(media.caption) + if caption_length > 1024: + raise ValueError("Telegram spoiler image caption exceeds 1024 characters") + + from model_tools import _run_async + + async def send() -> dict[str, Any]: + from telegram import Bot + from plugins.platforms.telegram.adapter import TelegramAdapter + from plugins.platforms.telegram.telegram_ids import ( + normalize_telegram_chat_id, + ) + + bot_kwargs: dict[str, Any] = {"token": platform_config.token} + extra = getattr(platform_config, "extra", {}) or {} + if extra.get("base_url"): + bot_kwargs["base_url"] = extra["base_url"] + bot_kwargs["base_file_url"] = extra.get( + "base_file_url", + extra["base_url"], + ) + if extra.get("local_mode"): + bot_kwargs["local_mode"] = True + + try: + from gateway.platforms.base import resolve_proxy_url + + proxy_url = resolve_proxy_url( + "TELEGRAM_PROXY", + target_hosts=["api.telegram.org"], + ) + except Exception: + proxy_url = None + if proxy_url: + from telegram.request import HTTPXRequest + + bot_kwargs["request"] = HTTPXRequest(proxy=proxy_url) + + bot = Bot(**bot_kwargs) + initialized = False + try: + await bot.initialize() + initialized = True + send_kwargs: dict[str, Any] = { + "chat_id": normalize_telegram_chat_id(chat_id), + "photo": None, + "caption": media.caption or None, + "has_spoiler": True, + } + if thread_id is not None: + effective_thread_id = TelegramAdapter._message_thread_id_for_send( + str(thread_id) + ) + if effective_thread_id is not None: + send_kwargs["message_thread_id"] = effective_thread_id + with media.path.open("rb") as image_file: + send_kwargs["photo"] = image_file + message = await bot.send_photo(**send_kwargs) + return { + "success": True, + "platform": "telegram", + "chat_id": chat_id, + "message_id": str(message.message_id), + "spoiler": True, + } + finally: + if initialized: + await bot.shutdown() + else: + try: + await bot.shutdown() + except Exception: + pass + + return _run_async(send()) + + def deliver_media( media: MediaPayload, *, @@ -774,15 +941,20 @@ def deliver_media( media.media_type.value, media.path, ) - raw = invoke_host_tool( - "send_message", - { - "action": "send", - "target": resolved.host_target, - "message": media.to_message(), - }, - **context, - ) + if media.spoiler: + if resolved.host_target.split(":", 1)[0].lower() != "telegram": + raise ValueError("spoiler media delivery requires a Telegram target") + raw = _invoke_guarded_spoiler_delivery(media, resolved, context) + else: + raw = invoke_host_tool( + "send_message", + { + "action": "send", + "target": resolved.host_target, + "message": media.to_message(), + }, + **context, + ) try: host_payload = json.loads(raw) except (TypeError, json.JSONDecodeError): @@ -809,6 +981,7 @@ def deliver_media( media_type=media.media_type, path=media.path, host_result=safe_result, + spoiler=media.spoiler, ) diff --git a/pyproject.toml b/pyproject.toml index ab3cc16..c557c33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-plugin-kit" -version = "0.2.1" +version = "0.3.0" description = "Convention-correct lifecycle registration for hermes-agent plugins." readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_hermes_contract.py b/tests/test_hermes_contract.py index 6898bff..ac34377 100644 --- a/tests/test_hermes_contract.py +++ b/tests/test_hermes_contract.py @@ -231,6 +231,8 @@ def test_host_tool_invocation_reaches_real_telegram_media_contract(self) -> None ) bot = types.SimpleNamespace( + initialize=AsyncMock(), + shutdown=AsyncMock(), send_message=AsyncMock(), send_photo=AsyncMock( return_value=types.SimpleNamespace(message_id=42) @@ -260,6 +262,10 @@ class TelegramAdapter: def format_message(message: str) -> str: return message + @staticmethod + def _message_thread_id_for_send(value: str) -> int | None: + return None if value == "1" else int(value) + telegram_adapter = types.ModuleType("plugins.platforms.telegram.adapter") telegram_adapter.TelegramAdapter = TelegramAdapter telegram_adapter.register = Mock() @@ -320,6 +326,10 @@ def format_message(message: str) -> str: hpk.MediaPayload(voice_path, hpk.MediaType.VOICE), target="telegram:8670382527", ) + spoiler_result = hpk.deliver_media( + hpk.MediaPayload(image_path, spoiler=True), + target="telegram:-5372910000:42", + ) self.assertTrue(result.get("success"), result) self.assertEqual(result["platform"], "telegram") @@ -328,14 +338,16 @@ def format_message(message: str) -> str: self.assertTrue(voice_result.success, voice_result) self.assertEqual(voice_result.media_type, hpk.MediaType.VOICE) self.assertEqual(voice_result.host_result["message_id"], "43") - self.assertEqual(bot_factory.call_count, 2) + self.assertTrue(spoiler_result.success, spoiler_result) + self.assertTrue(spoiler_result.spoiler) + self.assertEqual(bot_factory.call_count, 3) self.assertTrue( all(item.kwargs == {"token": "contract-token"} for item in bot_factory.call_args_list) ) bot.send_message.assert_not_awaited() - bot.send_photo.assert_awaited_once() + self.assertEqual(bot.send_photo.await_count, 2) bot.send_voice.assert_awaited_once() - photo_call = bot.send_photo.await_args + photo_call = bot.send_photo.await_args_list[0] self.assertEqual(photo_call.kwargs["chat_id"], 8670382527) self.assertEqual( photo_call.kwargs["photo"].name, @@ -347,6 +359,12 @@ def format_message(message: str) -> str: voice_call.kwargs["voice"].name, str(voice_path.resolve()), ) + spoiler_call = bot.send_photo.await_args_list[1] + self.assertEqual(spoiler_call.kwargs["chat_id"], -5372910000) + self.assertEqual(spoiler_call.kwargs["message_thread_id"], 42) + self.assertTrue(spoiler_call.kwargs["has_spoiler"]) + bot.initialize.assert_awaited_once() + bot.shutdown.assert_awaited_once() if __name__ == "__main__": diff --git a/tests/test_kit.py b/tests/test_kit.py index 3cc1485..e1e925b 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -1,12 +1,13 @@ from __future__ import annotations +import asyncio import json import sys import tempfile import types import unittest from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import hermes_plugin_kit as hpk @@ -335,6 +336,30 @@ def test_voice_payload_rejects_non_voice_container(self) -> None: with self.assertRaisesRegex(ValueError, "ogg or opus"): hpk.MediaPayload("/opt/data/voice-staging/memo.mp3", hpk.MediaType.VOICE) + def test_spoiler_image_keeps_standard_hermes_media_directive(self) -> None: + payload = hpk.MediaPayload( + "/opt/data/avatars/generated/portrait.png", + hpk.MediaType.AUTO, + spoiler=True, + ) + + self.assertTrue(payload.spoiler) + self.assertEqual( + payload.to_message(), + "MEDIA:/opt/data/avatars/generated/portrait.png", + ) + + def test_spoiler_rejects_non_image_media(self) -> None: + for path, media_type in ( + ("/opt/data/voice-staging/memo.ogg", hpk.MediaType.VOICE), + ("/opt/data/report.pdf", hpk.MediaType.DOCUMENT), + ("/opt/data/video.mp4", hpk.MediaType.AUTO), + ): + with self.subTest(path=path), self.assertRaisesRegex( + ValueError, "spoiler media must be an image" + ): + hpk.MediaPayload(path, media_type, spoiler=True) + def test_origin_target_uses_task_local_gateway_route(self) -> None: session_context = types.ModuleType("gateway.session_context") values = { @@ -432,6 +457,175 @@ def test_deliver_media_invokes_typed_host_contract(self) -> None: self.assertEqual(result.host_result["chat_id"], "…2527") self.assertEqual(result.as_dict()["media_type"], "voice") + def test_spoiler_delivery_uses_guarded_telegram_transport(self) -> None: + plugins = types.ModuleType("hermes_cli.plugins") + plugins.resolve_pre_tool_block = Mock(return_value=None) + plugins.has_hook = Mock(return_value=True) + plugins.invoke_hook = Mock(return_value=[]) + hermes_cli = types.ModuleType("hermes_cli") + hermes_cli.plugins = plugins + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "portrait.png" + path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16) + payload = hpk.MediaPayload(path, spoiler=True) + with ( + patch.dict( + sys.modules, + {"hermes_cli": hermes_cli, "hermes_cli.plugins": plugins}, + ), + patch.object( + hpk, + "_deliver_telegram_spoiler", + return_value={ + "success": True, + "platform": "telegram", + "chat_id": "8670382527", + "message_id": "9", + }, + ) as spoiler_send, + patch.object(hpk, "invoke_host_tool") as host_send, + ): + result = hpk.deliver_media( + payload, + target="telegram:8670382527", + session_id="session-1", + task_id="task-1", + ) + + host_send.assert_not_called() + spoiler_send.assert_called_once() + self.assertEqual(spoiler_send.call_args.args[0], payload) + self.assertEqual( + spoiler_send.call_args.args[1].host_target, + "telegram:8670382527", + ) + guard_args = plugins.resolve_pre_tool_block.call_args.args[1] + self.assertEqual(guard_args["target"], "telegram:8670382527") + self.assertEqual(guard_args["message"], f"MEDIA:{path}") + self.assertEqual(guard_args["media_options"], {"spoiler": True}) + self.assertTrue(result.success) + self.assertTrue(result.spoiler) + self.assertEqual(result.host_result["chat_id"], "…2527") + self.assertEqual( + plugins.invoke_hook.call_args.kwargs["status"], + "success", + ) + + def test_spoiler_delivery_respects_hermes_pre_tool_guard(self) -> None: + plugins = types.ModuleType("hermes_cli.plugins") + plugins.resolve_pre_tool_block = Mock(return_value="Outbound media blocked") + plugins.has_hook = Mock(return_value=True) + plugins.invoke_hook = Mock(return_value=[]) + hermes_cli = types.ModuleType("hermes_cli") + hermes_cli.plugins = plugins + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "portrait.png" + path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16) + with ( + patch.dict( + sys.modules, + {"hermes_cli": hermes_cli, "hermes_cli.plugins": plugins}, + ), + patch.object(hpk, "_deliver_telegram_spoiler") as spoiler_send, + ): + result = hpk.deliver_media( + hpk.MediaPayload(path, spoiler=True), + target="telegram:-5372910000", + session_id="session-1", + ) + + spoiler_send.assert_not_called() + self.assertFalse(result.success) + self.assertEqual(result.host_result["error"], "Outbound media blocked") + self.assertIsNone( + hpk.transform_media_delivery_output( + response_text="delivery failed", + session_id="session-1", + ) + ) + + def test_spoiler_transport_forwards_group_topic_and_closes_bot(self) -> None: + bot = types.SimpleNamespace( + initialize=AsyncMock(), + send_photo=AsyncMock( + return_value=types.SimpleNamespace(message_id=42) + ), + shutdown=AsyncMock(), + ) + telegram = types.ModuleType("telegram") + telegram.Bot = Mock(return_value=bot) + telegram_ids = types.ModuleType( + "plugins.platforms.telegram.telegram_ids" + ) + telegram_ids.normalize_telegram_chat_id = lambda value: int(value) + adapter = types.ModuleType("plugins.platforms.telegram.adapter") + adapter.TelegramAdapter = types.SimpleNamespace( + _message_thread_id_for_send=lambda value: int(value) + ) + gateway_config = types.ModuleType("gateway.config") + platform = types.SimpleNamespace(TELEGRAM="telegram") + gateway_config.Platform = platform + gateway_config.load_gateway_config = Mock( + return_value=types.SimpleNamespace( + platforms={ + "telegram": types.SimpleNamespace( + enabled=True, + token="contract-token", + extra={}, + ) + }, + get_home_channel=lambda _platform: None, + ) + ) + gateway = types.ModuleType("gateway") + gateway.__path__ = [] + gateway.config = gateway_config + plugins = types.ModuleType("plugins") + plugins.__path__ = [] + platforms = types.ModuleType("plugins.platforms") + platforms.__path__ = [] + telegram_package = types.ModuleType("plugins.platforms.telegram") + telegram_package.__path__ = [] + model_tools = types.ModuleType("model_tools") + model_tools._run_async = asyncio.run + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "portrait.png" + path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16) + payload = hpk.MediaPayload(path, caption="A reveal", spoiler=True) + resolved = hpk.ResolvedDeliveryTarget( + requested="origin", + host_target="telegram:-5372910000:42", + display="telegram:-…0000:42", + ) + with patch.dict( + sys.modules, + { + "telegram": telegram, + "gateway": gateway, + "gateway.config": gateway_config, + "plugins": plugins, + "plugins.platforms": platforms, + "plugins.platforms.telegram": telegram_package, + "plugins.platforms.telegram.adapter": adapter, + "plugins.platforms.telegram.telegram_ids": telegram_ids, + "model_tools": model_tools, + }, + ): + result = hpk._deliver_telegram_spoiler(payload, resolved) + + self.assertEqual(result["message_id"], "42") + telegram.Bot.assert_called_once_with(token="contract-token") + bot.initialize.assert_awaited_once() + bot.shutdown.assert_awaited_once() + photo_call = bot.send_photo.await_args.kwargs + self.assertEqual(photo_call["chat_id"], -5372910000) + self.assertEqual(photo_call["message_thread_id"], 42) + self.assertEqual(photo_call["caption"], "A reveal") + self.assertTrue(photo_call["has_spoiler"]) + def test_successful_delivery_suppresses_the_same_turn_final_response_once(self) -> None: with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "memo.ogg" diff --git a/uv.lock b/uv.lock index bf02256..1e83f76 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "hermes-plugin-kit" -version = "0.2.1" +version = "0.3.0" source = { editable = "." } [package.dev-dependencies]