@@ -416,7 +416,7 @@ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = Non
416416from dataclasses import dataclass , field
417417from datetime import datetime
418418from pathlib import Path
419- from typing import Dict , List , Optional , Any , Callable , Awaitable , Tuple
419+ from typing import Dict , List , Optional , Any , Callable , Awaitable , Tuple , Union
420420from enum import Enum
421421
422422from pathlib import Path as _Path
@@ -981,7 +981,7 @@ def coerce_plaintext_gateway_command(event: "MessageEvent") -> None:
981981 return
982982
983983
984- @dataclass
984+ @dataclass
985985class SendResult :
986986 """Result of sending a message."""
987987 success : bool
@@ -991,6 +991,45 @@ class SendResult:
991991 retryable : bool = False # True for transient connection errors — base will retry automatically
992992
993993
994+ class EphemeralReply (str ):
995+ """System-notice reply that auto-deletes after a TTL.
996+
997+ Slash-command handlers in ``gateway/run.py`` can return this wrapper
998+ instead of a plain string to request that the reply message be deleted
999+ after ``ttl_seconds`` on platforms that support ``delete_message``.
1000+
1001+ Subclassing ``str`` keeps the wrapper transparent to anything that
1002+ treats handler return values as text (existing tests use ``in`` /
1003+ ``startswith`` / equality; the ``_process_message_background`` pipeline
1004+ extracts attachments from the string content). ``isinstance(r,
1005+ EphemeralReply)`` still distinguishes ephemeral replies from plain
1006+ strings so the send path can schedule deletion.
1007+
1008+ Platforms that don't override :meth:`BasePlatformAdapter.delete_message`
1009+ silently ignore the TTL — the message is sent normally and left in
1010+ place. When ``ttl_seconds`` is ``None``, the pipeline uses the
1011+ configured ``display.ephemeral_system_ttl`` default. A default of ``0``
1012+ disables auto-deletion globally, preserving prior behavior.
1013+ """
1014+
1015+ ttl_seconds : Optional [int ]
1016+
1017+ def __new__ (cls , text : str , ttl_seconds : Optional [int ] = None ):
1018+ instance = super ().__new__ (cls , text )
1019+ instance .ttl_seconds = ttl_seconds
1020+ return instance
1021+
1022+ @property
1023+ def text (self ) -> str :
1024+ """Return the underlying text.
1025+
1026+ Provided for call sites that want an explicit string conversion,
1027+ though ``str(reply)`` and using ``reply`` directly where a string
1028+ is expected both work identically.
1029+ """
1030+ return str .__str__ (self )
1031+
1032+
9941033def merge_pending_message_event (
9951034 pending_messages : Dict [str , MessageEvent ],
9961035 session_key : str ,
@@ -1073,8 +1112,10 @@ def merge_pending_message_event(
10731112)
10741113
10751114
1076- # Type for message handlers
1077- MessageHandler = Callable [[MessageEvent ], Awaitable [Optional [str ]]]
1115+ # Type for message handlers. Handlers may return a plain string (normal
1116+ # reply), an ``EphemeralReply`` to opt the reply into auto-deletion, or
1117+ # ``None`` when the response was already delivered (e.g. via streaming).
1118+ MessageHandler = Callable [[MessageEvent ], Awaitable [Optional [Union [str , "EphemeralReply" ]]]]
10781119
10791120
10801121def resolve_channel_prompt (
@@ -1459,6 +1500,64 @@ async def delete_message(
14591500 """
14601501 return False
14611502
1503+ def _get_ephemeral_system_ttl_default (self ) -> int :
1504+ """Read ``display.ephemeral_system_ttl`` from config.
1505+
1506+ Returns the TTL in seconds to use when an :class:`EphemeralReply`
1507+ does not specify one explicitly. ``0`` (the default) disables
1508+ auto-deletion. Non-fatal if config is unreadable.
1509+ """
1510+ try :
1511+ from hermes_cli .config import load_config as _load_config
1512+ except Exception :
1513+ return 0
1514+ try :
1515+ cfg = _load_config ()
1516+ except Exception :
1517+ return 0
1518+ display = cfg .get ("display" , {}) if isinstance (cfg , dict ) else {}
1519+ if not isinstance (display , dict ):
1520+ return 0
1521+ raw = display .get ("ephemeral_system_ttl" , 0 )
1522+ try :
1523+ return int (raw )
1524+ except (TypeError , ValueError ):
1525+ return 0
1526+
1527+ def _schedule_ephemeral_delete (
1528+ self ,
1529+ chat_id : str ,
1530+ message_id : str ,
1531+ ttl_seconds : int ,
1532+ ) -> None :
1533+ """Spawn a detached task that deletes ``message_id`` after ``ttl_seconds``.
1534+
1535+ Best-effort — failures (gateway restart, permission denied, message
1536+ too old for Telegram's 48h window) are swallowed at debug level.
1537+ Does not block the caller.
1538+ """
1539+
1540+ async def _run_delete () -> None :
1541+ try :
1542+ await asyncio .sleep (max (1 , int (ttl_seconds )))
1543+ await self .delete_message (chat_id = chat_id , message_id = message_id )
1544+ except asyncio .CancelledError :
1545+ raise
1546+ except Exception as e :
1547+ logger .debug (
1548+ "[%s] Ephemeral delete failed for %s/%s: %s" ,
1549+ self .name , chat_id , message_id , e ,
1550+ )
1551+
1552+ coro = _run_delete ()
1553+ try :
1554+ asyncio .create_task (coro )
1555+ except RuntimeError :
1556+ # No running loop (e.g. unit tests that never reach the async
1557+ # path). Close the coroutine cleanly so Python doesn't warn
1558+ # about it never being awaited, then drop silently.
1559+ coro .close ()
1560+
14621561 async def send_slash_confirm (
14631562 self ,
14641563 chat_id : str ,
@@ -2048,6 +2147,28 @@ def _is_timeout_error(error: Optional[str]) -> bool:
20482147 lowered = error .lower ()
20492148 return "timed out" in lowered or "readtimeout" in lowered or "writetimeout" in lowered
20502149
2150+ def _unwrap_ephemeral (self , response : Any ) -> Tuple [Optional [str ], int ]:
2151+ """Unwrap a handler response into (text, ttl_seconds).
2152+
2153+ Accepts a plain string, ``None``, or an :class:`EphemeralReply`.
2154+ Returns ``(text, ttl)`` where ``ttl > 0`` means the caller should
2155+ schedule a deletion via :meth:`_schedule_ephemeral_delete` after
2156+ the send succeeds. ``ttl`` is forced to 0 when the adapter
2157+ doesn't override :meth:`delete_message` so non-supporting
2158+ platforms silently degrade to normal sends.
2159+ """
2160+ if isinstance (response , EphemeralReply ):
2161+ ttl = response .ttl_seconds
2162+ if ttl is None :
2163+ try :
2164+ ttl = int (self ._get_ephemeral_system_ttl_default ())
2165+ except Exception :
2166+ ttl = 0
2167+ if ttl and ttl > 0 and type (self ).delete_message is BasePlatformAdapter .delete_message :
2168+ ttl = 0
2169+ return response .text , int (ttl or 0 )
2170+ return response , 0
2171+
20512172 async def _send_with_retry (
20522173 self ,
20532174 chat_id : str ,
@@ -2355,13 +2476,20 @@ async def _dispatch_active_session_command(
23552476 release_guard = False ,
23562477 discard_pending = False ,
23572478 )
2358- if response :
2359- await self ._send_with_retry (
2479+ _text , _eph_ttl = self ._unwrap_ephemeral (response )
2480+ if _text :
2481+ _r = await self ._send_with_retry (
23602482 chat_id = event .source .chat_id ,
2361- content = response ,
2483+ content = _text ,
23622484 reply_to = event .message_id ,
23632485 metadata = thread_meta ,
23642486 )
2487+ if _eph_ttl > 0 and _r .success and _r .message_id :
2488+ self ._schedule_ephemeral_delete (
2489+ chat_id = event .source .chat_id ,
2490+ message_id = _r .message_id ,
2491+ ttl_seconds = _eph_ttl ,
2492+ )
23652493 except Exception :
23662494 # On failure, restore the original guard if one still exists so
23672495 # we don't leave the session in a half-reset state.
@@ -2441,13 +2569,20 @@ async def handle_message(self, event: MessageEvent) -> None:
24412569 try :
24422570 _thread_meta = {"thread_id" : event .source .thread_id } if event .source .thread_id else None
24432571 response = await self ._message_handler (event )
2444- if response :
2445- await self ._send_with_retry (
2572+ _text , _eph_ttl = self ._unwrap_ephemeral (response )
2573+ if _text :
2574+ _r = await self ._send_with_retry (
24462575 chat_id = event .source .chat_id ,
2447- content = response ,
2576+ content = _text ,
24482577 reply_to = event .message_id ,
24492578 metadata = _thread_meta ,
24502579 )
2580+ if _eph_ttl > 0 and _r .success and _r .message_id :
2581+ self ._schedule_ephemeral_delete (
2582+ chat_id = event .source .chat_id ,
2583+ message_id = _r .message_id ,
2584+ ttl_seconds = _eph_ttl ,
2585+ )
24512586 except Exception as e :
24522587 logger .error ("[%s] Command '/%s' dispatch failed: %s" , self .name , cmd , e , exc_info = True )
24532588 return
@@ -2553,7 +2688,16 @@ async def _stop_typing_task() -> None:
25532688
25542689 # Call the handler (this can take a while with tool calls)
25552690 response = await self ._message_handler (event )
2556-
2691+
2692+ # Slash-command handlers may return an EphemeralReply sentinel to
2693+ # request that their reply message auto-delete after a TTL (used
2694+ # for system notices like "✨ New session started!" that the user
2695+ # doesn't need to keep in the thread). Unwrap here so all the
2696+ # downstream extract_media / text-processing logic sees a plain
2697+ # string, and remember the TTL + platform capability so the
2698+ # post-send block can schedule the deletion.
2699+ response , _ephemeral_ttl = self ._unwrap_ephemeral (response )
2700+
25572701 # Send response if any. A None/empty response is normal when
25582702 # streaming already delivered the text (already_sent=True) or
25592703 # when the message was queued behind an active agent. Log at
@@ -2642,6 +2786,21 @@ async def _stop_typing_task() -> None:
26422786 )
26432787 _record_delivery (result )
26442788
2789+ # Schedule auto-deletion of system-notice replies.
2790+ # Detached so the handler returns immediately; errors
2791+ # (permission denied, message too old) are swallowed.
2792+ if (
2793+ _ephemeral_ttl
2794+ and _ephemeral_ttl > 0
2795+ and result .success
2796+ and result .message_id
2797+ ):
2798+ self ._schedule_ephemeral_delete (
2799+ chat_id = event .source .chat_id ,
2800+ message_id = result .message_id ,
2801+ ttl_seconds = _ephemeral_ttl ,
2802+ )
2803+
26452804 # Human-like pacing delay between text and media
26462805 human_delay = self ._get_human_delay ()
26472806
0 commit comments