Skip to content

Commit 16682bf

Browse files
feat: support guarded Telegram spoiler photos
1 parent 7e19f13 commit 16682bf

7 files changed

Lines changed: 449 additions & 38 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ plugin.
1717
final network client rather than replacing the host handler.
1818
- Plugins must use `MediaPayload` + `deliver_media` for attachments. The kit
1919
owns Hermes media directives, task-local `origin` resolution, route redaction,
20-
the typed result, and successful-send final-response suppression. Consumers
20+
the typed result, successful-send final-response suppression, and the narrow
21+
Telegram `spoiler=True` photo extension. Spoiler delivery must retain Hermes
22+
pre/post-tool hooks, route privacy, topic forwarding, and explicit Bot client
23+
shutdown; normal media must remain on host-managed `send_message`. Consumers
2124
must register `transform_media_delivery_output` as Hermes'
2225
`transform_llm_output` hook and `clear_media_delivery_state` as
2326
`on_session_end`; they must not recreate those contracts or substitute

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,28 @@ def deliver_voice_memo(path: str, **runtime_context):
225225
through Hermes' task-local platform/chat/thread context inside the kit, so a
226226
plugin never imports gateway internals or exposes raw group IDs to the model.
227227
The returned `MediaDeliveryResult` carries success, media type, path, requested
228-
route, a privacy-safe display route, and a redacted host result.
228+
route, a privacy-safe display route, spoiler state, and a redacted host result.
229+
230+
Telegram spoiler photos are available without patching Hermes core:
231+
232+
```python
233+
deliver_media(
234+
MediaPayload("/opt/data/avatars/generated/reveal.png", spoiler=True),
235+
target="origin",
236+
**runtime_context,
237+
)
238+
```
239+
240+
The ordinary path remains Hermes' host-managed `send_message`. Because that
241+
host contract does not currently expose Telegram's `has_spoiler`, only
242+
`spoiler=True` uses the kit's narrow Telegram extension. The extension accepts
243+
JPG, JPEG, PNG, and WebP photos, resolves the same Hermes current-chat/home
244+
routes (including group topics), runs the normal Hermes `pre_tool_call` and
245+
`post_tool_call` hooks, forwards `has_spoiler=True`, closes its one-shot Bot
246+
client, and returns the same privacy-safe typed result. Voice, document,
247+
non-Telegram, and unsupported-image requests are rejected rather than silently
248+
losing spoiler intent. The Telegram token remains runtime-owned and is never a
249+
model argument or result field.
229250

230251
Direct delivery and final response delivery are separate stages in Hermes. A
231252
consumer that calls `deliver_media` must register the kit's matching Hermes
@@ -259,7 +280,9 @@ supported host tool; unknown names fail explicitly.
259280
The upstream Hermes contract suite runs image and typed voice payloads through
260281
the real `send_message` target parser, media extractor, and Telegram formatter.
261282
It mocks only the final Bot API client and asserts that Hermes calls `send_photo`
262-
and `send_voice` with the expected files, without separate text messages.
283+
and `send_voice` with the expected files, without separate text messages. It
284+
also runs the kit-owned spoiler extension against Hermes' real config, session,
285+
async bridge, and Telegram library shapes while mocking only Bot network calls.
263286

264287
## Logging contract
265288

hermes_plugin_kit/__init__.py

Lines changed: 202 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ def register(ctx):
106106
_MEDIA_DELIVERY_STATE_TTL_SECONDS = 300.0
107107
_MEDIA_DELIVERY_STATE: dict[str, float] = {}
108108
_MEDIA_DELIVERY_STATE_LOCK = threading.Lock()
109+
_TELEGRAM_SPOILER_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
109110

110111

111112
@dataclass(frozen=True)
@@ -143,6 +144,7 @@ class MediaPayload:
143144
path: Path | str
144145
media_type: MediaType | str = MediaType.AUTO
145146
caption: str = ""
147+
spoiler: bool = False
146148

147149
def __post_init__(self) -> None:
148150
path = Path(self.path).expanduser()
@@ -158,6 +160,15 @@ def __post_init__(self) -> None:
158160
raise ValueError("media_type must be auto, voice, or document") from exc
159161
if media_type is MediaType.VOICE and path.suffix.lower() not in {".ogg", ".opus"}:
160162
raise ValueError("voice media must use an ogg or opus container")
163+
if not isinstance(self.spoiler, bool):
164+
raise ValueError("spoiler must be a boolean")
165+
if self.spoiler and (
166+
media_type is not MediaType.AUTO
167+
or path.suffix.lower() not in _TELEGRAM_SPOILER_IMAGE_EXTENSIONS
168+
):
169+
raise ValueError(
170+
"spoiler media must be an image using jpg, jpeg, png, or webp"
171+
)
161172
object.__setattr__(self, "path", path)
162173
object.__setattr__(self, "media_type", media_type)
163174
object.__setattr__(self, "caption", str(self.caption or "").strip())
@@ -191,6 +202,7 @@ class MediaDeliveryResult:
191202
media_type: MediaType
192203
path: Path
193204
host_result: dict[str, Any]
205+
spoiler: bool = False
194206

195207
def as_dict(self) -> dict[str, Any]:
196208
return {
@@ -200,6 +212,7 @@ def as_dict(self) -> dict[str, Any]:
200212
"media_type": self.media_type.value,
201213
"path": str(self.path),
202214
"host_result": self.host_result,
215+
"spoiler": self.spoiler,
203216
}
204217

205218

@@ -518,20 +531,15 @@ def _emit_host_post_tool_call(
518531
)
519532

520533

521-
def invoke_host_tool(name: str, args: dict | None = None, **context: Any) -> str:
522-
"""Invoke a Hermes host capability that is not registry-backed.
523-
524-
Some Hermes capabilities, notably ``send_message``, are runtime services
525-
rather than entries in ``tools.registry``. Plugin handlers must use this
526-
seam instead of ``registry.dispatch`` so the direct host handler is found
527-
while ``pre_tool_call`` and ``post_tool_call`` hooks still observe the
528-
nested operation.
529-
"""
530-
handler = _load_host_tool(name)
531-
tool_args = {} if args is None else args
532-
if not isinstance(tool_args, dict):
533-
raise TypeError("host tool args must be a dict")
534-
534+
def _invoke_guarded_host_operation(
535+
name: str,
536+
tool_args: dict[str, Any],
537+
operation: Callable[[], Any],
538+
*,
539+
context: dict[str, Any],
540+
failure_message: str,
541+
) -> str:
542+
"""Run a host operation behind the real Hermes pre/post-tool hooks."""
535543
try:
536544
from hermes_cli.plugins import resolve_pre_tool_block
537545
except (ImportError, AttributeError) as exc:
@@ -557,17 +565,14 @@ def invoke_host_tool(name: str, args: dict | None = None, **context: Any) -> str
557565

558566
started = time.perf_counter()
559567
try:
560-
result = handler(tool_args, **context)
568+
result = operation()
561569
except Exception as exc:
562570
logging.getLogger("hermes_plugin_kit").exception(
563-
"%s: host handler raised; error_type=%s",
571+
"%s: host operation raised; error_type=%s",
564572
name,
565573
type(exc).__name__,
566574
)
567-
result = json.dumps(
568-
{"error": f"{name} host handler failed: {type(exc).__name__}"},
569-
ensure_ascii=False,
570-
)
575+
result = {"error": f"{failure_message}: {type(exc).__name__}"}
571576
if not isinstance(result, str):
572577
result = json.dumps(result, ensure_ascii=False)
573578

@@ -586,6 +591,29 @@ def invoke_host_tool(name: str, args: dict | None = None, **context: Any) -> str
586591
return result
587592

588593

594+
def invoke_host_tool(name: str, args: dict | None = None, **context: Any) -> str:
595+
"""Invoke a Hermes host capability that is not registry-backed.
596+
597+
Some Hermes capabilities, notably ``send_message``, are runtime services
598+
rather than entries in ``tools.registry``. Plugin handlers must use this
599+
seam instead of ``registry.dispatch`` so the direct host handler is found
600+
while ``pre_tool_call`` and ``post_tool_call`` hooks still observe the
601+
nested operation.
602+
"""
603+
handler = _load_host_tool(name)
604+
tool_args = {} if args is None else args
605+
if not isinstance(tool_args, dict):
606+
raise TypeError("host tool args must be a dict")
607+
608+
return _invoke_guarded_host_operation(
609+
name,
610+
tool_args,
611+
lambda: handler(tool_args, **context),
612+
context=context,
613+
failure_message=f"{name} host handler failed",
614+
)
615+
616+
589617
def _display_delivery_identifier(value: str) -> str:
590618
raw = str(value or "")
591619
sign = "-" if raw.startswith("-") else ""
@@ -751,6 +779,145 @@ def clear_media_delivery_state(
751779
_MEDIA_DELIVERY_STATE.pop(context_id, None)
752780

753781

782+
def _invoke_guarded_spoiler_delivery(
783+
media: MediaPayload,
784+
resolved: ResolvedDeliveryTarget,
785+
context: dict[str, Any],
786+
) -> str:
787+
"""Run the kit-owned Telegram extension through Hermes lifecycle guards."""
788+
tool_args = {
789+
"action": "send",
790+
"target": resolved.host_target,
791+
"message": media.to_message(),
792+
"media_options": {"spoiler": True},
793+
}
794+
return _invoke_guarded_host_operation(
795+
"send_message",
796+
tool_args,
797+
lambda: _deliver_telegram_spoiler(media, resolved),
798+
context=context,
799+
failure_message="Telegram spoiler delivery failed",
800+
)
801+
802+
803+
def _telegram_spoiler_route(
804+
resolved: ResolvedDeliveryTarget,
805+
config: Any,
806+
platform: Any,
807+
) -> tuple[str, str | None]:
808+
parts = resolved.host_target.split(":", 2)
809+
if not parts or parts[0].lower() != "telegram":
810+
raise ValueError("spoiler media delivery requires a Telegram target")
811+
if len(parts) == 1 or not parts[1]:
812+
home = config.get_home_channel(platform)
813+
if home is None or not str(home.chat_id or "").strip():
814+
raise RuntimeError("Telegram has no configured home channel")
815+
return str(home.chat_id), str(home.thread_id) if home.thread_id else None
816+
chat_id = parts[1]
817+
thread_id = parts[2] if len(parts) == 3 and parts[2] else None
818+
return chat_id, thread_id
819+
820+
821+
def _deliver_telegram_spoiler(
822+
media: MediaPayload,
823+
resolved: ResolvedDeliveryTarget,
824+
) -> dict[str, Any]:
825+
"""Send one spoiler photo through Telegram without patching Hermes core."""
826+
from gateway.config import Platform, load_gateway_config
827+
828+
config = load_gateway_config()
829+
telegram_platform = Platform.TELEGRAM
830+
platform_config = config.platforms.get(telegram_platform)
831+
if not platform_config or not platform_config.enabled or not platform_config.token:
832+
raise RuntimeError("Telegram is not configured")
833+
chat_id, thread_id = _telegram_spoiler_route(
834+
resolved,
835+
config,
836+
telegram_platform,
837+
)
838+
839+
try:
840+
from gateway.platforms.base import utf16_len
841+
842+
caption_length = utf16_len(media.caption)
843+
except Exception:
844+
caption_length = len(media.caption)
845+
if caption_length > 1024:
846+
raise ValueError("Telegram spoiler image caption exceeds 1024 characters")
847+
848+
from model_tools import _run_async
849+
850+
async def send() -> dict[str, Any]:
851+
from telegram import Bot
852+
from plugins.platforms.telegram.adapter import TelegramAdapter
853+
from plugins.platforms.telegram.telegram_ids import (
854+
normalize_telegram_chat_id,
855+
)
856+
857+
bot_kwargs: dict[str, Any] = {"token": platform_config.token}
858+
extra = getattr(platform_config, "extra", {}) or {}
859+
if extra.get("base_url"):
860+
bot_kwargs["base_url"] = extra["base_url"]
861+
bot_kwargs["base_file_url"] = extra.get(
862+
"base_file_url",
863+
extra["base_url"],
864+
)
865+
if extra.get("local_mode"):
866+
bot_kwargs["local_mode"] = True
867+
868+
try:
869+
from gateway.platforms.base import resolve_proxy_url
870+
871+
proxy_url = resolve_proxy_url(
872+
"TELEGRAM_PROXY",
873+
target_hosts=["api.telegram.org"],
874+
)
875+
except Exception:
876+
proxy_url = None
877+
if proxy_url:
878+
from telegram.request import HTTPXRequest
879+
880+
bot_kwargs["request"] = HTTPXRequest(proxy=proxy_url)
881+
882+
bot = Bot(**bot_kwargs)
883+
initialized = False
884+
try:
885+
await bot.initialize()
886+
initialized = True
887+
send_kwargs: dict[str, Any] = {
888+
"chat_id": normalize_telegram_chat_id(chat_id),
889+
"photo": None,
890+
"caption": media.caption or None,
891+
"has_spoiler": True,
892+
}
893+
if thread_id is not None:
894+
effective_thread_id = TelegramAdapter._message_thread_id_for_send(
895+
str(thread_id)
896+
)
897+
if effective_thread_id is not None:
898+
send_kwargs["message_thread_id"] = effective_thread_id
899+
with media.path.open("rb") as image_file:
900+
send_kwargs["photo"] = image_file
901+
message = await bot.send_photo(**send_kwargs)
902+
return {
903+
"success": True,
904+
"platform": "telegram",
905+
"chat_id": chat_id,
906+
"message_id": str(message.message_id),
907+
"spoiler": True,
908+
}
909+
finally:
910+
if initialized:
911+
await bot.shutdown()
912+
else:
913+
try:
914+
await bot.shutdown()
915+
except Exception:
916+
pass
917+
918+
return _run_async(send())
919+
920+
754921
def deliver_media(
755922
media: MediaPayload,
756923
*,
@@ -774,15 +941,20 @@ def deliver_media(
774941
media.media_type.value,
775942
media.path,
776943
)
777-
raw = invoke_host_tool(
778-
"send_message",
779-
{
780-
"action": "send",
781-
"target": resolved.host_target,
782-
"message": media.to_message(),
783-
},
784-
**context,
785-
)
944+
if media.spoiler:
945+
if resolved.host_target.split(":", 1)[0].lower() != "telegram":
946+
raise ValueError("spoiler media delivery requires a Telegram target")
947+
raw = _invoke_guarded_spoiler_delivery(media, resolved, context)
948+
else:
949+
raw = invoke_host_tool(
950+
"send_message",
951+
{
952+
"action": "send",
953+
"target": resolved.host_target,
954+
"message": media.to_message(),
955+
},
956+
**context,
957+
)
786958
try:
787959
host_payload = json.loads(raw)
788960
except (TypeError, json.JSONDecodeError):
@@ -809,6 +981,7 @@ def deliver_media(
809981
media_type=media.media_type,
810982
path=media.path,
811983
host_result=safe_result,
984+
spoiler=media.spoiler,
812985
)
813986

814987

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
88

99
[project]
1010
name = "hermes-plugin-kit"
11-
version = "0.2.1"
11+
version = "0.3.0"
1212
description = "Convention-correct lifecycle registration for hermes-agent plugins."
1313
readme = "README.md"
1414
requires-python = ">=3.11"

0 commit comments

Comments
 (0)