diff --git a/cron/scheduler.py b/cron/scheduler.py index 4545288ce..92400a0d0 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -119,6 +119,12 @@ def _deliver_result(job: dict, content: str) -> None: logger.error("Job '%s': delivery error: %s", job["id"], result["error"]) else: logger.info("Job '%s': delivered to %s:%s", job["id"], platform_name, chat_id) + # Mirror the delivered content into the target's gateway session + try: + from gateway.mirror import mirror_to_session + mirror_to_session(platform_name, chat_id, content, source_label="cron") + except Exception: + pass def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py new file mode 100644 index 000000000..622fed6bd --- /dev/null +++ b/gateway/channel_directory.py @@ -0,0 +1,237 @@ +""" +Channel directory -- cached map of reachable channels/contacts per platform. + +Built on gateway startup, refreshed periodically (every 5 min), and saved to +~/.hermes/channel_directory.json. The send_message tool reads this file for +action="list" and for resolving human-friendly channel names to numeric IDs. +""" + +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +DIRECTORY_PATH = Path.home() / ".hermes" / "channel_directory.json" + + +# --------------------------------------------------------------------------- +# Build / refresh +# --------------------------------------------------------------------------- + +def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: + """ + Build a channel directory from connected platform adapters and session data. + + Returns the directory dict and writes it to DIRECTORY_PATH. + """ + from gateway.config import Platform + + platforms: Dict[str, List[Dict[str, str]]] = {} + + for platform, adapter in adapters.items(): + try: + if platform == Platform.DISCORD: + platforms["discord"] = _build_discord(adapter) + elif platform == Platform.SLACK: + platforms["slack"] = _build_slack(adapter) + except Exception as e: + logger.warning("Channel directory: failed to build %s: %s", platform.value, e) + + # Telegram & WhatsApp can't enumerate chats -- pull from session history + for plat_name in ("telegram", "whatsapp"): + if plat_name not in platforms: + platforms[plat_name] = _build_from_sessions(plat_name) + + directory = { + "updated_at": datetime.now().isoformat(), + "platforms": platforms, + } + + try: + DIRECTORY_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(DIRECTORY_PATH, "w") as f: + json.dump(directory, f, indent=2, ensure_ascii=False) + except Exception as e: + logger.warning("Channel directory: failed to write: %s", e) + + return directory + + +def _build_discord(adapter) -> List[Dict[str, str]]: + """Enumerate all text channels the Discord bot can see.""" + channels = [] + client = getattr(adapter, "_client", None) + if not client: + return channels + + try: + import discord as _discord + except ImportError: + return channels + + for guild in client.guilds: + for ch in guild.text_channels: + channels.append({ + "id": str(ch.id), + "name": ch.name, + "guild": guild.name, + "type": "channel", + }) + # Also include DM-capable users we've interacted with is not + # feasible via guild enumeration; those come from sessions. + + # Merge any DMs from session history + channels.extend(_build_from_sessions("discord")) + return channels + + +def _build_slack(adapter) -> List[Dict[str, str]]: + """List Slack channels the bot has joined.""" + channels = [] + # Slack adapter may expose a web client + client = getattr(adapter, "_app", None) or getattr(adapter, "_client", None) + if not client: + return _build_from_sessions("slack") + + try: + import asyncio + from tools.send_message_tool import _send_slack # noqa: F401 + # Use the Slack Web API directly if available + except Exception: + pass + + # Fallback to session data + return _build_from_sessions("slack") + + +def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]: + """Pull known channels/contacts from sessions.json origin data.""" + sessions_path = Path.home() / ".hermes" / "sessions" / "sessions.json" + if not sessions_path.exists(): + return [] + + entries = [] + try: + with open(sessions_path) as f: + data = json.load(f) + + seen_ids = set() + for _key, session in data.items(): + origin = session.get("origin") or {} + if origin.get("platform") != platform_name: + continue + chat_id = origin.get("chat_id") + if not chat_id or chat_id in seen_ids: + continue + seen_ids.add(chat_id) + entries.append({ + "id": str(chat_id), + "name": origin.get("chat_name") or origin.get("user_name") or str(chat_id), + "type": session.get("chat_type", "dm"), + }) + except Exception as e: + logger.debug("Channel directory: failed to read sessions for %s: %s", platform_name, e) + + return entries + + +# --------------------------------------------------------------------------- +# Read / resolve +# --------------------------------------------------------------------------- + +def load_directory() -> Dict[str, Any]: + """Load the cached channel directory from disk.""" + if not DIRECTORY_PATH.exists(): + return {"updated_at": None, "platforms": {}} + try: + with open(DIRECTORY_PATH) as f: + return json.load(f) + except Exception: + return {"updated_at": None, "platforms": {}} + + +def resolve_channel_name(platform_name: str, name: str) -> Optional[str]: + """ + Resolve a human-friendly channel name to a numeric ID. + + Matching strategy (case-insensitive, first match wins): + - Discord: "bot-home", "#bot-home", "GuildName/bot-home" + - Telegram: display name or group name + - Slack: "engineering", "#engineering" + """ + directory = load_directory() + channels = directory.get("platforms", {}).get(platform_name, []) + if not channels: + return None + + query = name.lstrip("#").lower() + + # 1. Exact name match + for ch in channels: + if ch["name"].lower() == query: + return ch["id"] + + # 2. Guild-qualified match for Discord ("GuildName/channel") + if "/" in query: + guild_part, ch_part = query.rsplit("/", 1) + for ch in channels: + guild = ch.get("guild", "").lower() + if guild == guild_part and ch["name"].lower() == ch_part: + return ch["id"] + + # 3. Partial prefix match (only if unambiguous) + matches = [ch for ch in channels if ch["name"].lower().startswith(query)] + if len(matches) == 1: + return matches[0]["id"] + + return None + + +def format_directory_for_display() -> str: + """Format the channel directory as a human-readable list for the model.""" + directory = load_directory() + platforms = directory.get("platforms", {}) + + if not any(platforms.values()): + return "No messaging platforms connected or no channels discovered yet." + + lines = ["Available messaging targets:\n"] + + for plat_name, channels in sorted(platforms.items()): + if not channels: + continue + + # Group Discord channels by guild + if plat_name == "discord": + guilds: Dict[str, List] = {} + dms: List = [] + for ch in channels: + guild = ch.get("guild") + if guild: + guilds.setdefault(guild, []).append(ch) + else: + dms.append(ch) + + for guild_name, guild_channels in sorted(guilds.items()): + lines.append(f"Discord ({guild_name}):") + for ch in sorted(guild_channels, key=lambda c: c["name"]): + lines.append(f" discord:#{ch['name']}") + if dms: + lines.append("Discord (DMs):") + for ch in dms: + lines.append(f" discord:{ch['name']}") + lines.append("") + else: + lines.append(f"{plat_name.title()}:") + for ch in channels: + type_label = f" ({ch['type']})" if ch.get("type") else "" + lines.append(f" {plat_name}:{ch['name']}{type_label}") + lines.append("") + + lines.append('Use these as the "target" parameter when sending.') + lines.append('Bare platform name (e.g. "telegram") sends to home channel.') + + return "\n".join(lines) diff --git a/gateway/mirror.py b/gateway/mirror.py new file mode 100644 index 000000000..8c2f39983 --- /dev/null +++ b/gateway/mirror.py @@ -0,0 +1,123 @@ +""" +Session mirroring for cross-platform message delivery. + +When a message is sent to a platform (via send_message or cron delivery), +this module appends a "delivery-mirror" record to the target session's +transcript so the receiving-side agent has context about what was sent. + +Standalone -- works from CLI, cron, and gateway contexts without needing +the full SessionStore machinery. +""" + +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +_SESSIONS_DIR = Path.home() / ".hermes" / "sessions" +_SESSIONS_INDEX = _SESSIONS_DIR / "sessions.json" + + +def mirror_to_session( + platform: str, + chat_id: str, + message_text: str, + source_label: str = "cli", +) -> bool: + """ + Append a delivery-mirror message to the target session's transcript. + + Finds the gateway session that matches the given platform + chat_id, + then writes a mirror entry to both the JSONL transcript and SQLite DB. + + Returns True if mirrored successfully, False if no matching session or error. + All errors are caught -- this is never fatal. + """ + try: + session_id = _find_session_id(platform, str(chat_id)) + if not session_id: + logger.debug("Mirror: no session found for %s:%s", platform, chat_id) + return False + + mirror_msg = { + "role": "assistant", + "content": message_text, + "timestamp": datetime.now().isoformat(), + "mirror": True, + "mirror_source": source_label, + } + + _append_to_jsonl(session_id, mirror_msg) + _append_to_sqlite(session_id, mirror_msg) + + logger.debug("Mirror: wrote to session %s (from %s)", session_id, source_label) + return True + + except Exception as e: + logger.debug("Mirror failed for %s:%s: %s", platform, chat_id, e) + return False + + +def _find_session_id(platform: str, chat_id: str) -> Optional[str]: + """ + Find the active session_id for a platform + chat_id pair. + + Scans sessions.json entries and matches where origin.chat_id == chat_id + on the right platform. DM session keys don't embed the chat_id + (e.g. "agent:main:telegram:dm"), so we check the origin dict. + """ + if not _SESSIONS_INDEX.exists(): + return None + + try: + with open(_SESSIONS_INDEX) as f: + data = json.load(f) + except Exception: + return None + + platform_lower = platform.lower() + best_match = None + best_updated = "" + + for _key, entry in data.items(): + origin = entry.get("origin") or {} + entry_platform = (origin.get("platform") or entry.get("platform", "")).lower() + + if entry_platform != platform_lower: + continue + + origin_chat_id = str(origin.get("chat_id", "")) + if origin_chat_id == str(chat_id): + updated = entry.get("updated_at", "") + if updated > best_updated: + best_updated = updated + best_match = entry.get("session_id") + + return best_match + + +def _append_to_jsonl(session_id: str, message: dict) -> None: + """Append a message to the JSONL transcript file.""" + transcript_path = _SESSIONS_DIR / f"{session_id}.jsonl" + try: + with open(transcript_path, "a") as f: + f.write(json.dumps(message, ensure_ascii=False) + "\n") + except Exception as e: + logger.debug("Mirror JSONL write failed: %s", e) + + +def _append_to_sqlite(session_id: str, message: dict) -> None: + """Append a message to the SQLite session database.""" + try: + from hermes_state import SessionDB + db = SessionDB() + db.append_message( + session_id=session_id, + role=message.get("role", "assistant"), + content=message.get("content"), + ) + except Exception as e: + logger.debug("Mirror SQLite write failed: %s", e) diff --git a/gateway/run.py b/gateway/run.py index 8ba9057fc..6e9f771b5 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -202,6 +202,16 @@ class GatewayRunner: if connected_count > 0: logger.info("Gateway running with %s platform(s)", connected_count) + + # Build initial channel directory for send_message name resolution + try: + from gateway.channel_directory import build_channel_directory + directory = build_channel_directory(self.adapters) + ch_count = sum(len(chs) for chs in directory.get("platforms", {}).values()) + logger.info("Channel directory built: %d target(s)", ch_count) + except Exception as e: + logger.warning("Channel directory build failed: %s", e) + logger.info("Press Ctrl+C to stop") return True @@ -220,6 +230,10 @@ class GatewayRunner: self.adapters.clear() self._shutdown_event.set() + + from gateway.status import remove_pid_file + remove_pid_file() + logger.info("Gateway stopped") async def wait_for_shutdown(self) -> None: @@ -387,6 +401,9 @@ class GatewayRunner: if command == "undo": return await self._handle_undo_command(event) + if command in ["set-home", "sethome"]: + return await self._handle_set_home_command(event) + # Check for pending exec approval responses session_key_preview = f"agent:main:{source.platform.value}:{source.chat_type}:{source.chat_id}" if source.chat_type != "dm" else f"agent:main:{source.platform.value}:dm" if session_key_preview in self._pending_approvals: @@ -441,14 +458,30 @@ class GatewayRunner: # Load conversation history from transcript history = self.session_store.load_transcript(session_entry.session_id) - # First-message onboarding for brand-new messaging platform users - if not history: + # First-message onboarding -- only on the very first interaction ever + if not history and not self.session_store.has_any_sessions(): context_prompt += ( - "\n\n[System note: This is the user's very first message in this session. " + "\n\n[System note: This is the user's very first message ever. " "Briefly introduce yourself and mention that /help shows available commands. " "Keep the introduction concise -- one or two sentences max.]" ) + # One-time prompt if no home channel is set for this platform + if not history and source.platform and source.platform != Platform.LOCAL: + platform_name = source.platform.value + env_key = f"{platform_name.upper()}_HOME_CHANNEL" + if not os.getenv(env_key): + adapter = self.adapters.get(source.platform) + if adapter: + await adapter.send( + source.chat_id, + f"đŸ“Ŧ No home channel is set for {platform_name.title()}. " + f"A home channel is where Hermes delivers cron job results " + f"and cross-platform messages.\n\n" + f"Type /set-home to make this chat your home channel, " + f"or ignore to skip." + ) + # ----------------------------------------------------------------- # Auto-analyze images sent by the user # @@ -712,6 +745,7 @@ class GatewayRunner: "`/personality [name]` — Set a personality\n" "`/retry` — Retry your last message\n" "`/undo` — Remove the last exchange\n" + "`/set-home` — Set this chat as the home channel\n" "`/help` — Show this message" ) @@ -817,6 +851,36 @@ class GatewayRunner: preview = removed_msg[:40] + "..." if len(removed_msg) > 40 else removed_msg return f"â†Šī¸ Undid {removed_count} message(s).\nRemoved: \"{preview}\"" + async def _handle_set_home_command(self, event: MessageEvent) -> str: + """Handle /set-home command -- set the current chat as the platform's home channel.""" + source = event.source + platform_name = source.platform.value if source.platform else "unknown" + chat_id = source.chat_id + chat_name = source.chat_name or chat_id + + env_key = f"{platform_name.upper()}_HOME_CHANNEL" + + # Save to config.yaml + try: + import yaml + config_path = Path.home() / '.hermes' / 'config.yaml' + user_config = {} + if config_path.exists(): + with open(config_path) as f: + user_config = yaml.safe_load(f) or {} + user_config[env_key] = chat_id + with open(config_path, 'w') as f: + yaml.dump(user_config, f, default_flow_style=False) + # Also set in the current environment so it takes effect immediately + os.environ[env_key] = str(chat_id) + except Exception as e: + return f"Failed to save home channel: {e}" + + return ( + f"✅ Home channel set to **{chat_name}** (ID: {chat_id}).\n" + f"Cron jobs and cross-platform messages will be delivered here." + ) + def _set_session_env(self, context: SessionContext) -> None: """Set environment variables for the current session.""" os.environ["HERMES_SESSION_PLATFORM"] = context.source.platform.value @@ -1254,6 +1318,10 @@ class GatewayRunner: # Simple text message - just need role and content content = msg.get("content") if content: + # Tag cross-platform mirror messages so the agent knows their origin + if msg.get("mirror"): + source = msg.get("mirror_source", "another session") + content = f"[Delivered from {source}] {content}" agent_history.append({"role": role, "content": content}) result = agent.run_conversation(message, conversation_history=agent_history) @@ -1409,20 +1477,21 @@ class GatewayRunner: return response -def _start_cron_ticker(stop_event: threading.Event, interval: int = 60): +def _start_cron_ticker(stop_event: threading.Event, adapters=None, interval: int = 60): """ Background thread that ticks the cron scheduler at a regular interval. Runs inside the gateway process so cronjobs fire automatically without needing a separate `hermes cron daemon` or system cron entry. - Every 60th tick (~once per hour) the image/audio cache is pruned so - stale temp files don't accumulate. + Also refreshes the channel directory every 5 minutes and prunes the + image/audio cache once per hour. """ from cron.scheduler import tick as cron_tick from gateway.platforms.base import cleanup_image_cache - IMAGE_CACHE_EVERY = 60 # ticks — once per hour at default 60s interval + IMAGE_CACHE_EVERY = 60 # ticks — once per hour at default 60s interval + CHANNEL_DIR_EVERY = 5 # ticks — every 5 minutes logger.info("Cron ticker started (interval=%ds)", interval) tick_count = 0 @@ -1433,6 +1502,14 @@ def _start_cron_ticker(stop_event: threading.Event, interval: int = 60): logger.debug("Cron tick error: %s", e) tick_count += 1 + + if tick_count % CHANNEL_DIR_EVERY == 0 and adapters: + try: + from gateway.channel_directory import build_channel_directory + build_channel_directory(adapters) + except Exception as e: + logger.debug("Channel directory refresh error: %s", e) + if tick_count % IMAGE_CACHE_EVERY == 0: try: removed = cleanup_image_cache(max_age_hours=24) @@ -1483,11 +1560,18 @@ async def start_gateway(config: Optional[GatewayConfig] = None) -> bool: if not success: return False + # Write PID file so CLI can detect gateway is running + import atexit + from gateway.status import write_pid_file, remove_pid_file + write_pid_file() + atexit.register(remove_pid_file) + # Start background cron ticker so scheduled jobs fire automatically cron_stop = threading.Event() cron_thread = threading.Thread( target=_start_cron_ticker, args=(cron_stop,), + kwargs={"adapters": runner.adapters}, daemon=True, name="cron-ticker", ) diff --git a/gateway/session.py b/gateway/session.py index b6603ecfa..2a3d8b6bf 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -367,6 +367,11 @@ class SessionStore: return False + def has_any_sessions(self) -> bool: + """Check if any sessions have ever been created (across all platforms).""" + self._load() + return len(self._entries) > 1 # >1 because the current new session is already in _entries + def get_or_create_session( self, source: SessionSource, diff --git a/gateway/status.py b/gateway/status.py new file mode 100644 index 000000000..f28adc880 --- /dev/null +++ b/gateway/status.py @@ -0,0 +1,39 @@ +""" +Gateway runtime status helpers. + +Provides PID-file based detection of whether the gateway daemon is running, +used by send_message's check_fn to gate availability in the CLI. +""" + +import os +from pathlib import Path + +_PID_FILE = Path.home() / ".hermes" / "gateway.pid" + + +def write_pid_file() -> None: + """Write the current process PID to the gateway PID file.""" + _PID_FILE.parent.mkdir(parents=True, exist_ok=True) + _PID_FILE.write_text(str(os.getpid())) + + +def remove_pid_file() -> None: + """Remove the gateway PID file if it exists.""" + try: + _PID_FILE.unlink(missing_ok=True) + except Exception: + pass + + +def is_gateway_running() -> bool: + """Check if the gateway daemon is currently running.""" + if not _PID_FILE.exists(): + return False + try: + pid = int(_PID_FILE.read_text().strip()) + os.kill(pid, 0) # signal 0 = existence check, no actual signal sent + return True + except (ValueError, ProcessLookupError, PermissionError): + # Stale PID file -- process is gone + remove_pid_file() + return False diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index a50975b35..4dc500bc3 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -921,9 +921,26 @@ def run_setup_wizard(args): else: print_info("âš ī¸ No allowlist set - anyone who finds your bot can use it!") - home_channel = prompt("Home channel ID (optional, for cron delivery)") - if home_channel: - save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) + # Home channel setup with better guidance + print() + print_info("đŸ“Ŧ Home Channel: where Hermes delivers cron job results,") + print_info(" cross-platform messages, and notifications.") + print_info(" For Telegram DMs, this is your user ID (same as above).") + + first_user_id = allowed_users.split(",")[0].strip() if allowed_users else "" + if first_user_id: + if prompt_yes_no(f"Use your user ID ({first_user_id}) as the home channel?", True): + save_env_value("TELEGRAM_HOME_CHANNEL", first_user_id) + print_success(f"Telegram home channel set to {first_user_id}") + else: + home_channel = prompt("Home channel ID (or leave empty to set later with /set-home in Telegram)") + if home_channel: + save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) + else: + print_info(" You can also set this later by typing /set-home in your Telegram chat.") + home_channel = prompt("Home channel ID (leave empty to set later)") + if home_channel: + save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) # Check/update existing Telegram allowlist elif existing_telegram: @@ -958,14 +975,23 @@ def run_setup_wizard(args): print_info(" 1. Enable Developer Mode in Discord settings") print_info(" 2. Right-click your name → Copy ID") print() - allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)") + print_info(" You can also use Discord usernames (resolved on gateway start).") + print() + allowed_users = prompt("Allowed user IDs or usernames (comma-separated, leave empty for open access)") if allowed_users: save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "")) print_success("Discord allowlist configured") else: print_info("âš ī¸ No allowlist set - anyone in servers with your bot can use it!") - home_channel = prompt("Home channel ID (optional, for cron delivery)") + # Home channel setup with better guidance + print() + print_info("đŸ“Ŧ Home Channel: where Hermes delivers cron job results,") + print_info(" cross-platform messages, and notifications.") + print_info(" To get a channel ID: right-click a channel → Copy Channel ID") + print_info(" (requires Developer Mode in Discord settings)") + print_info(" You can also set this later by typing /set-home in a Discord channel.") + home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") if home_channel: save_env_value("DISCORD_HOME_CHANNEL", home_channel) @@ -1039,6 +1065,25 @@ def run_setup_wizard(args): print_info("Start the gateway after setup to bring your bots online:") print_info(" hermes gateway # Run in foreground") print_info(" hermes gateway install # Install as background service (Linux)") + + # Check if any home channels are missing + missing_home = [] + if get_env_value('TELEGRAM_BOT_TOKEN') and not get_env_value('TELEGRAM_HOME_CHANNEL'): + missing_home.append("Telegram") + if get_env_value('DISCORD_BOT_TOKEN') and not get_env_value('DISCORD_HOME_CHANNEL'): + missing_home.append("Discord") + if get_env_value('SLACK_BOT_TOKEN') and not get_env_value('SLACK_HOME_CHANNEL'): + missing_home.append("Slack") + + if missing_home: + print() + print_info(f"âš ī¸ No home channel set for: {', '.join(missing_home)}") + print_info(" Without a home channel, cron jobs and cross-platform") + print_info(" messages can't be delivered to those platforms.") + print_info(" Set one later with /set-home in your chat, or:") + for plat in missing_home: + print_info(f" hermes config set {plat.upper()}_HOME_CHANNEL ") + print_info("━" * 50) # ========================================================================= diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index efe9880cc..fd77409b9 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -1,52 +1,97 @@ """Send Message Tool -- cross-channel messaging via platform APIs. Sends a message to a user or channel on any connected messaging platform -(Telegram, Discord, Slack). Works in both CLI and gateway contexts. +(Telegram, Discord, Slack). Supports listing available targets and resolving +human-friendly channel names to IDs. Works in both CLI and gateway contexts. """ import json import logging +import os logger = logging.getLogger(__name__) SEND_MESSAGE_SCHEMA = { "name": "send_message", - "description": "Send a message to a user or channel on any connected messaging platform. Use this when the user asks you to send something to a different platform, or when delivering notifications/alerts to a specific destination.", + "description": ( + "Send a message to a connected messaging platform, or list available targets.\n\n" + "IMPORTANT: When the user asks to send to a specific channel or person " + "(not just a bare platform name), call send_message(action='list') FIRST to see " + "available targets, then send to the correct one.\n" + "If the user just says a platform name like 'send to telegram', send directly " + "to the home channel without listing first." + ), "parameters": { "type": "object", "properties": { + "action": { + "type": "string", + "enum": ["send", "list"], + "description": "Action to perform. 'send' (default) sends a message. 'list' returns all available channels/contacts across connected platforms." + }, "target": { "type": "string", - "description": "Delivery target. Format: 'platform' (uses home channel) or 'platform:chat_id' (specific chat). Examples: 'telegram', 'discord:123456789', 'slack:C01234ABCDE'" + "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', or 'platform:chat_id'. Examples: 'telegram', 'discord:#bot-home', 'slack:#engineering'" }, "message": { "type": "string", "description": "The message text to send" } }, - "required": ["target", "message"] + "required": [] } } def send_message_tool(args, **kw): - """Handle cross-channel send_message tool calls. + """Handle cross-channel send_message tool calls.""" + action = args.get("action", "send") - Sends a message directly to the target platform using its API. - Works in both CLI and gateway contexts -- does not require the - gateway to be running. Loads credentials from the gateway config - (env vars / ~/.hermes/gateway.json). - """ + if action == "list": + return _handle_list() + + return _handle_send(args) + + +def _handle_list(): + """Return formatted list of available messaging targets.""" + try: + from gateway.channel_directory import format_directory_for_display + return json.dumps({"targets": format_directory_for_display()}) + except Exception as e: + return json.dumps({"error": f"Failed to load channel directory: {e}"}) + + +def _handle_send(args): + """Send a message to a platform target.""" target = args.get("target", "") message = args.get("message", "") if not target or not message: - return json.dumps({"error": "Both 'target' and 'message' are required"}) + return json.dumps({"error": "Both 'target' and 'message' are required when action='send'"}) parts = target.split(":", 1) platform_name = parts[0].strip().lower() chat_id = parts[1].strip() if len(parts) > 1 else None + # Resolve human-friendly channel names to numeric IDs + if chat_id and not chat_id.lstrip("-").isdigit(): + try: + from gateway.channel_directory import resolve_channel_name + resolved = resolve_channel_name(platform_name, chat_id) + if resolved: + chat_id = resolved + else: + return json.dumps({ + "error": f"Could not resolve '{chat_id}' on {platform_name}. " + f"Use send_message(action='list') to see available targets." + }) + except Exception: + return json.dumps({ + "error": f"Could not resolve '{chat_id}' on {platform_name}. " + f"Try using a numeric channel ID instead." + }) + try: from gateway.config import load_gateway_config, Platform config = load_gateway_config() @@ -75,13 +120,28 @@ def send_message_tool(args, **kw): chat_id = home.chat_id used_home_channel = True else: - return json.dumps({"error": f"No home channel set for {platform_name} to determine where to send the message. Either specify a channel directly with '{platform_name}:CHANNEL_ID', or set a home channel via: hermes config set {platform_name.upper()}_HOME_CHANNEL "}) + return json.dumps({ + "error": f"No home channel set for {platform_name} to determine where to send the message. " + f"Either specify a channel directly with '{platform_name}:CHANNEL_NAME', " + f"or set a home channel via: hermes config set {platform_name.upper()}_HOME_CHANNEL " + }) try: from model_tools import _run_async result = _run_async(_send_to_platform(platform, pconfig, chat_id, message)) if used_home_channel and isinstance(result, dict) and result.get("success"): result["note"] = f"Sent to {platform_name} home channel (chat_id: {chat_id})" + + # Mirror the sent message into the target's gateway session + if isinstance(result, dict) and result.get("success"): + try: + from gateway.mirror import mirror_to_session + source_label = os.getenv("HERMES_SESSION_PLATFORM", "cli") + if mirror_to_session(platform_name, chat_id, message, source_label=source_label): + result["mirrored"] = True + except Exception: + pass + return json.dumps(result) except Exception as e: return json.dumps({"error": f"Send failed: {e}"}) @@ -155,6 +215,18 @@ async def _send_slack(token, chat_id, message): return {"error": f"Slack send failed: {e}"} +def _check_send_message(): + """Gate send_message on gateway running (always available on messaging platforms).""" + platform = os.getenv("HERMES_SESSION_PLATFORM", "") + if platform and platform != "local": + return True + try: + from gateway.status import is_gateway_running + return is_gateway_running() + except Exception: + return False + + # --- Registry --- from tools.registry import registry @@ -163,4 +235,5 @@ registry.register( toolset="messaging", schema=SEND_MESSAGE_SCHEMA, handler=send_message_tool, + check_fn=_check_send_message, ) diff --git a/toolsets.py b/toolsets.py index bf12b4a33..ad7879323 100644 --- a/toolsets.py +++ b/toolsets.py @@ -27,7 +27,6 @@ from typing import List, Dict, Any, Set, Optional # Shared tool list for CLI and all messaging platform toolsets. -# Messaging platforms add "send_message" on top of this list. # Edit this once to update all platforms simultaneously. _HERMES_CORE_TOOLS = [ # Web @@ -59,6 +58,8 @@ _HERMES_CORE_TOOLS = [ "execute_code", "delegate_task", # Cronjob management "schedule_cronjob", "list_cronjobs", "remove_cronjob", + # Cross-platform messaging (gated on gateway running via check_fn) + "send_message", ] @@ -204,8 +205,8 @@ TOOLSETS = { # Full Hermes toolsets (CLI + messaging platforms) # # All platforms share the same core tools. Messaging platforms add - # send_message for cross-channel messaging. Defined via _HERMES_CORE_TOOLS - # to avoid duplicating the tool list for each platform. + # All platforms share the same core tools (including send_message, + # which is gated on gateway running via its check_fn). # ========================================================================== "hermes-cli": { @@ -216,25 +217,25 @@ TOOLSETS = { "hermes-telegram": { "description": "Telegram bot toolset - full access for personal use (terminal has safety checks)", - "tools": _HERMES_CORE_TOOLS + ["send_message"], + "tools": _HERMES_CORE_TOOLS, "includes": [] }, "hermes-discord": { "description": "Discord bot toolset - full access (terminal has safety checks via dangerous command approval)", - "tools": _HERMES_CORE_TOOLS + ["send_message"], + "tools": _HERMES_CORE_TOOLS, "includes": [] }, "hermes-whatsapp": { "description": "WhatsApp bot toolset - similar to Telegram (personal messaging, more trusted)", - "tools": _HERMES_CORE_TOOLS + ["send_message"], + "tools": _HERMES_CORE_TOOLS, "includes": [] }, "hermes-slack": { "description": "Slack bot toolset - full access for workspace use (terminal has safety checks)", - "tools": _HERMES_CORE_TOOLS + ["send_message"], + "tools": _HERMES_CORE_TOOLS, "includes": [] },