@@ -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+
589617def _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+
754921def 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
0 commit comments