mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
92447141d9
commit
08e4dc2563
9 changed files with 644 additions and 31 deletions
|
|
@ -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]]:
|
||||
|
|
|
|||
237
gateway/channel_directory.py
Normal file
237
gateway/channel_directory.py
Normal 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
123
gateway/mirror.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
39
gateway/status.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
15
toolsets.py
15
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": []
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue