feat: implement channel directory and message mirroring for cross-platform communication

- Introduced a new channel directory to cache reachable channels/contacts for messaging platforms, enhancing the send_message tool's ability to resolve human-friendly names to numeric IDs.
- Added functionality to mirror sent messages into the target's session transcript, providing context for cross-platform message delivery.
- Updated the send_message tool to support listing available targets and improved error handling for channel resolution.
- Enhanced the gateway to build and refresh the channel directory during startup and at regular intervals, ensuring up-to-date channel information.
This commit is contained in:
teknium1 2026-02-22 20:44:15 -08:00
parent 92447141d9
commit 08e4dc2563
9 changed files with 644 additions and 31 deletions

View file

@ -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]]:

View file

@ -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)

123
gateway/mirror.py Normal file
View file

@ -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)

View file

@ -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",
)

View file

@ -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,

39
gateway/status.py Normal file
View file

@ -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

View file

@ -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 <channel_id>")
print_info("" * 50)
# =========================================================================

View file

@ -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 <channel_id>"})
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 <channel_id>"
})
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,
)

View file

@ -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": []
},