feat(gateway): live-stream /update output + interactive prompt buttons (#5180)
* feat(gateway): live-stream /update output + forward interactive prompts Adds real-time output streaming and interactive prompt forwarding for the gateway /update command, so users on Telegram/Discord/etc see the full update progress and can respond to prompts (stash restore, config migration) without needing terminal access. Changes: hermes_cli/main.py: - Add --gateway flag to 'hermes update' argparse - Add _gateway_prompt() file-based IPC function that writes .update_prompt.json and polls for .update_response - Modify _restore_stashed_changes() to accept optional input_fn parameter for gateway mode prompt forwarding - cmd_update() uses _gateway_prompt when --gateway is set, enabling interactive stash restore and config migration prompts gateway/run.py: - _handle_update_command: spawn with --gateway flag and PYTHONUNBUFFERED=1 for real-time output flushing - Store session_key in .update_pending.json for cross-restart session matching - Add _update_prompt_pending dict to track sessions awaiting update prompt responses - Replace _watch_for_update_completion with _watch_update_progress: streams output chunks every ~4s, detects .update_prompt.json and forwards prompts to the user, handles completion/failure/timeout - Add update prompt interception in _handle_message: when a prompt is pending, the user's next message is written to .update_response instead of being processed normally - Preserve _send_update_notification as legacy fallback for post-restart cases where adapter isn't available yet File-based IPC protocol: - .update_prompt.json: written by update process with prompt text, default value, and unique ID - .update_response: written by gateway with user's answer - .update_output.txt: existing, now streamed in real-time - .update_exit_code: existing completion marker Tests: 16 new tests covering _gateway_prompt IPC, output streaming, prompt detection/forwarding, message interception, and cleanup. * feat: interactive buttons for update prompts (Telegram + Discord) Telegram: Inline keyboard with ✓ Yes / ✗ No buttons. Clicking a button answers the callback query, edits the message to show the choice, and writes .update_response directly. CallbackQueryHandler registered on the update_prompt: prefix. Discord: UpdatePromptView (discord.ui.View) with green Yes / red No buttons. Follows the ExecApprovalView pattern — auth check, embed color update, disabled-after-click. Writes .update_response on click. All platforms: /approve and /deny (and /yes, /no) now work as shorthand for yes/no when an update prompt is pending. The text fallback message instructs users to use these commands. Raw message interception still works as a fallback for non-command responses. Gateway watcher checks adapter for send_update_prompt method (class-level check to avoid MagicMock false positives) and falls back to text prompt with /approve instructions when unavailable. * fix: block /update on non-messaging platforms (API, webhooks, ACP) Add _UPDATE_ALLOWED_PLATFORMS frozenset that explicitly lists messaging platforms where /update is permitted. API server, webhook, and ACP platforms get a clear error directing them to run hermes update from the terminal instead. ACP and API server already don't reach _handle_message (separate codepaths), and webhooks have distinct session keys that can't collide with messaging sessions. This guard is belt-and-suspenders.
This commit is contained in:
@@ -1932,6 +1932,37 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_update_prompt(
|
||||
self, chat_id: str, prompt: str, default: str = "",
|
||||
session_key: str = "",
|
||||
) -> SendResult:
|
||||
"""Send an interactive button-based update prompt (Yes / No).
|
||||
|
||||
Used by the gateway ``/update`` watcher when ``hermes update --gateway``
|
||||
needs user input (stash restore, config migration).
|
||||
"""
|
||||
if not self._client or not DISCORD_AVAILABLE:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
try:
|
||||
channel = self._client.get_channel(int(chat_id))
|
||||
if not channel:
|
||||
channel = await self._client.fetch_channel(int(chat_id))
|
||||
|
||||
default_hint = f" (default: {default})" if default else ""
|
||||
embed = discord.Embed(
|
||||
title="⚕ Update Needs Your Input",
|
||||
description=f"{prompt}{default_hint}",
|
||||
color=discord.Color.gold(),
|
||||
)
|
||||
view = UpdatePromptView(
|
||||
session_key=session_key,
|
||||
allowed_user_ids=self._allowed_user_ids,
|
||||
)
|
||||
msg = await channel.send(embed=embed, view=view)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
def _get_parent_channel_id(self, channel: Any) -> Optional[str]:
|
||||
"""Return the parent channel ID for a Discord thread-like channel, if present."""
|
||||
parent = getattr(channel, "parent", None)
|
||||
@@ -2344,3 +2375,82 @@ if DISCORD_AVAILABLE:
|
||||
self.resolved = True
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
class UpdatePromptView(discord.ui.View):
|
||||
"""Interactive Yes/No buttons for ``hermes update`` prompts.
|
||||
|
||||
Clicking a button writes the answer to ``.update_response`` so the
|
||||
detached update process can pick it up. Only authorized users can
|
||||
click. Times out after 5 minutes (the update process also has a
|
||||
5-minute timeout on its side).
|
||||
"""
|
||||
|
||||
def __init__(self, session_key: str, allowed_user_ids: set):
|
||||
super().__init__(timeout=300)
|
||||
self.session_key = session_key
|
||||
self.allowed_user_ids = allowed_user_ids
|
||||
self.resolved = False
|
||||
|
||||
def _check_auth(self, interaction: discord.Interaction) -> bool:
|
||||
if not self.allowed_user_ids:
|
||||
return True
|
||||
return str(interaction.user.id) in self.allowed_user_ids
|
||||
|
||||
async def _respond(
|
||||
self, interaction: discord.Interaction, answer: str,
|
||||
color: discord.Color, label: str,
|
||||
):
|
||||
if self.resolved:
|
||||
await interaction.response.send_message(
|
||||
"Already answered~", ephemeral=True
|
||||
)
|
||||
return
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized~", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
self.resolved = True
|
||||
|
||||
# Update embed
|
||||
embed = interaction.message.embeds[0] if interaction.message.embeds else None
|
||||
if embed:
|
||||
embed.color = color
|
||||
embed.set_footer(text=f"{label} by {interaction.user.display_name}")
|
||||
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
await interaction.response.edit_message(embed=embed, view=self)
|
||||
|
||||
# Write response file
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
home = get_hermes_home()
|
||||
response_path = home / ".update_response"
|
||||
tmp = response_path.with_suffix(".tmp")
|
||||
tmp.write_text(answer)
|
||||
tmp.replace(response_path)
|
||||
logger.info(
|
||||
"Discord update prompt answered '%s' by %s",
|
||||
answer, interaction.user.display_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to write update response: %s", exc)
|
||||
|
||||
@discord.ui.button(label="Yes", style=discord.ButtonStyle.green, emoji="✓")
|
||||
async def yes_btn(
|
||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||
):
|
||||
await self._respond(interaction, "y", discord.Color.green(), "Yes")
|
||||
|
||||
@discord.ui.button(label="No", style=discord.ButtonStyle.red, emoji="✗")
|
||||
async def no_btn(
|
||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||
):
|
||||
await self._respond(interaction, "n", discord.Color.red(), "No")
|
||||
|
||||
async def on_timeout(self):
|
||||
self.resolved = True
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
@@ -17,10 +17,11 @@ from typing import Dict, List, Optional, Any
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from telegram import Update, Bot, Message
|
||||
from telegram import Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
CommandHandler,
|
||||
CallbackQueryHandler,
|
||||
MessageHandler as TelegramMessageHandler,
|
||||
ContextTypes,
|
||||
filters,
|
||||
@@ -33,8 +34,11 @@ except ImportError:
|
||||
Update = Any
|
||||
Bot = Any
|
||||
Message = Any
|
||||
InlineKeyboardButton = Any
|
||||
InlineKeyboardMarkup = Any
|
||||
Application = Any
|
||||
CommandHandler = Any
|
||||
CallbackQueryHandler = Any
|
||||
TelegramMessageHandler = Any
|
||||
HTTPXRequest = Any
|
||||
filters = None
|
||||
@@ -543,6 +547,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
filters.PHOTO | filters.VIDEO | filters.AUDIO | filters.VOICE | filters.Document.ALL | filters.Sticker.ALL,
|
||||
self._handle_media_message
|
||||
))
|
||||
# Handle inline keyboard button callbacks (update prompts)
|
||||
self._app.add_handler(CallbackQueryHandler(self._handle_callback_query))
|
||||
|
||||
# Start polling — retry initialize() for transient TLS resets
|
||||
try:
|
||||
@@ -950,6 +956,72 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_update_prompt(
|
||||
self, chat_id: str, prompt: str, default: str = "",
|
||||
session_key: str = "",
|
||||
) -> SendResult:
|
||||
"""Send an inline-keyboard update prompt (Yes / No buttons).
|
||||
|
||||
Used by the gateway ``/update`` watcher when ``hermes update --gateway``
|
||||
needs user input (stash restore, config migration).
|
||||
"""
|
||||
if not self._bot:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
try:
|
||||
default_hint = f" (default: {default})" if default else ""
|
||||
text = f"⚕ *Update needs your input:*\n\n{prompt}{default_hint}"
|
||||
keyboard = InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton("✓ Yes", callback_data="update_prompt:y"),
|
||||
InlineKeyboardButton("✗ No", callback_data="update_prompt:n"),
|
||||
]
|
||||
])
|
||||
msg = await self._bot.send_message(
|
||||
chat_id=int(chat_id),
|
||||
text=text,
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
except Exception as e:
|
||||
logger.warning("[%s] send_update_prompt failed: %s", self.name, e)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def _handle_callback_query(
|
||||
self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"
|
||||
) -> None:
|
||||
"""Handle inline keyboard button clicks (update prompts)."""
|
||||
query = update.callback_query
|
||||
if not query or not query.data:
|
||||
return
|
||||
data = query.data
|
||||
if not data.startswith("update_prompt:"):
|
||||
return
|
||||
answer = data.split(":", 1)[1] # "y" or "n"
|
||||
await query.answer(text=f"Sent '{answer}' to the update process.")
|
||||
# Edit the message to show the choice and remove buttons
|
||||
label = "Yes" if answer == "y" else "No"
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=f"⚕ Update prompt answered: *{label}*",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
pass # non-fatal if edit fails
|
||||
# Write the response file
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
home = get_hermes_home()
|
||||
response_path = home / ".update_response"
|
||||
tmp = response_path.with_suffix(".tmp")
|
||||
tmp.write_text(answer)
|
||||
tmp.replace(response_path)
|
||||
logger.info("Telegram update prompt answered '%s' by user %s",
|
||||
answer, getattr(query.from_user, "id", "unknown"))
|
||||
except Exception as exc:
|
||||
logger.error("Failed to write update response from callback: %s", exc)
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
||||
Reference in New Issue
Block a user