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"])
|
logger.error("Job '%s': delivery error: %s", job["id"], result["error"])
|
||||||
else:
|
else:
|
||||||
logger.info("Job '%s': delivered to %s:%s", job["id"], platform_name, chat_id)
|
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]]:
|
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:
|
if connected_count > 0:
|
||||||
logger.info("Gateway running with %s platform(s)", connected_count)
|
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")
|
logger.info("Press Ctrl+C to stop")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
@ -220,6 +230,10 @@ class GatewayRunner:
|
||||||
|
|
||||||
self.adapters.clear()
|
self.adapters.clear()
|
||||||
self._shutdown_event.set()
|
self._shutdown_event.set()
|
||||||
|
|
||||||
|
from gateway.status import remove_pid_file
|
||||||
|
remove_pid_file()
|
||||||
|
|
||||||
logger.info("Gateway stopped")
|
logger.info("Gateway stopped")
|
||||||
|
|
||||||
async def wait_for_shutdown(self) -> None:
|
async def wait_for_shutdown(self) -> None:
|
||||||
|
|
@ -387,6 +401,9 @@ class GatewayRunner:
|
||||||
if command == "undo":
|
if command == "undo":
|
||||||
return await self._handle_undo_command(event)
|
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
|
# 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"
|
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:
|
if session_key_preview in self._pending_approvals:
|
||||||
|
|
@ -441,14 +458,30 @@ class GatewayRunner:
|
||||||
# Load conversation history from transcript
|
# Load conversation history from transcript
|
||||||
history = self.session_store.load_transcript(session_entry.session_id)
|
history = self.session_store.load_transcript(session_entry.session_id)
|
||||||
|
|
||||||
# First-message onboarding for brand-new messaging platform users
|
# First-message onboarding -- only on the very first interaction ever
|
||||||
if not history:
|
if not history and not self.session_store.has_any_sessions():
|
||||||
context_prompt += (
|
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. "
|
"Briefly introduce yourself and mention that /help shows available commands. "
|
||||||
"Keep the introduction concise -- one or two sentences max.]"
|
"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
|
# Auto-analyze images sent by the user
|
||||||
#
|
#
|
||||||
|
|
@ -712,6 +745,7 @@ class GatewayRunner:
|
||||||
"`/personality [name]` — Set a personality\n"
|
"`/personality [name]` — Set a personality\n"
|
||||||
"`/retry` — Retry your last message\n"
|
"`/retry` — Retry your last message\n"
|
||||||
"`/undo` — Remove the last exchange\n"
|
"`/undo` — Remove the last exchange\n"
|
||||||
|
"`/set-home` — Set this chat as the home channel\n"
|
||||||
"`/help` — Show this message"
|
"`/help` — Show this message"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -817,6 +851,36 @@ class GatewayRunner:
|
||||||
preview = removed_msg[:40] + "..." if len(removed_msg) > 40 else removed_msg
|
preview = removed_msg[:40] + "..." if len(removed_msg) > 40 else removed_msg
|
||||||
return f"↩️ Undid {removed_count} message(s).\nRemoved: \"{preview}\""
|
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:
|
def _set_session_env(self, context: SessionContext) -> None:
|
||||||
"""Set environment variables for the current session."""
|
"""Set environment variables for the current session."""
|
||||||
os.environ["HERMES_SESSION_PLATFORM"] = context.source.platform.value
|
os.environ["HERMES_SESSION_PLATFORM"] = context.source.platform.value
|
||||||
|
|
@ -1254,6 +1318,10 @@ class GatewayRunner:
|
||||||
# Simple text message - just need role and content
|
# Simple text message - just need role and content
|
||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
if 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})
|
agent_history.append({"role": role, "content": content})
|
||||||
|
|
||||||
result = agent.run_conversation(message, conversation_history=agent_history)
|
result = agent.run_conversation(message, conversation_history=agent_history)
|
||||||
|
|
@ -1409,20 +1477,21 @@ class GatewayRunner:
|
||||||
return response
|
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.
|
Background thread that ticks the cron scheduler at a regular interval.
|
||||||
|
|
||||||
Runs inside the gateway process so cronjobs fire automatically without
|
Runs inside the gateway process so cronjobs fire automatically without
|
||||||
needing a separate `hermes cron daemon` or system cron entry.
|
needing a separate `hermes cron daemon` or system cron entry.
|
||||||
|
|
||||||
Every 60th tick (~once per hour) the image/audio cache is pruned so
|
Also refreshes the channel directory every 5 minutes and prunes the
|
||||||
stale temp files don't accumulate.
|
image/audio cache once per hour.
|
||||||
"""
|
"""
|
||||||
from cron.scheduler import tick as cron_tick
|
from cron.scheduler import tick as cron_tick
|
||||||
from gateway.platforms.base import cleanup_image_cache
|
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)
|
logger.info("Cron ticker started (interval=%ds)", interval)
|
||||||
tick_count = 0
|
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)
|
logger.debug("Cron tick error: %s", e)
|
||||||
|
|
||||||
tick_count += 1
|
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:
|
if tick_count % IMAGE_CACHE_EVERY == 0:
|
||||||
try:
|
try:
|
||||||
removed = cleanup_image_cache(max_age_hours=24)
|
removed = cleanup_image_cache(max_age_hours=24)
|
||||||
|
|
@ -1483,11 +1560,18 @@ async def start_gateway(config: Optional[GatewayConfig] = None) -> bool:
|
||||||
if not success:
|
if not success:
|
||||||
return False
|
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
|
# Start background cron ticker so scheduled jobs fire automatically
|
||||||
cron_stop = threading.Event()
|
cron_stop = threading.Event()
|
||||||
cron_thread = threading.Thread(
|
cron_thread = threading.Thread(
|
||||||
target=_start_cron_ticker,
|
target=_start_cron_ticker,
|
||||||
args=(cron_stop,),
|
args=(cron_stop,),
|
||||||
|
kwargs={"adapters": runner.adapters},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
name="cron-ticker",
|
name="cron-ticker",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -367,6 +367,11 @@ class SessionStore:
|
||||||
|
|
||||||
return False
|
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(
|
def get_or_create_session(
|
||||||
self,
|
self,
|
||||||
source: SessionSource,
|
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:
|
else:
|
||||||
print_info("⚠️ No allowlist set - anyone who finds your bot can use it!")
|
print_info("⚠️ No allowlist set - anyone who finds your bot can use it!")
|
||||||
|
|
||||||
home_channel = prompt("Home channel ID (optional, for cron delivery)")
|
# Home channel setup with better guidance
|
||||||
if home_channel:
|
print()
|
||||||
save_env_value("TELEGRAM_HOME_CHANNEL", home_channel)
|
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
|
# Check/update existing Telegram allowlist
|
||||||
elif existing_telegram:
|
elif existing_telegram:
|
||||||
|
|
@ -958,14 +975,23 @@ def run_setup_wizard(args):
|
||||||
print_info(" 1. Enable Developer Mode in Discord settings")
|
print_info(" 1. Enable Developer Mode in Discord settings")
|
||||||
print_info(" 2. Right-click your name → Copy ID")
|
print_info(" 2. Right-click your name → Copy ID")
|
||||||
print()
|
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:
|
if allowed_users:
|
||||||
save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
||||||
print_success("Discord allowlist configured")
|
print_success("Discord allowlist configured")
|
||||||
else:
|
else:
|
||||||
print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!")
|
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:
|
if home_channel:
|
||||||
save_env_value("DISCORD_HOME_CHANNEL", 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("Start the gateway after setup to bring your bots online:")
|
||||||
print_info(" hermes gateway # Run in foreground")
|
print_info(" hermes gateway # Run in foreground")
|
||||||
print_info(" hermes gateway install # Install as background service (Linux)")
|
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)
|
print_info("━" * 50)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,97 @@
|
||||||
"""Send Message Tool -- cross-channel messaging via platform APIs.
|
"""Send Message Tool -- cross-channel messaging via platform APIs.
|
||||||
|
|
||||||
Sends a message to a user or channel on any connected messaging platform
|
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 json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
SEND_MESSAGE_SCHEMA = {
|
SEND_MESSAGE_SCHEMA = {
|
||||||
"name": "send_message",
|
"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": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"target": {
|
||||||
"type": "string",
|
"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": {
|
"message": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The message text to send"
|
"description": "The message text to send"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["target", "message"]
|
"required": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def send_message_tool(args, **kw):
|
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.
|
if action == "list":
|
||||||
Works in both CLI and gateway contexts -- does not require the
|
return _handle_list()
|
||||||
gateway to be running. Loads credentials from the gateway config
|
|
||||||
(env vars / ~/.hermes/gateway.json).
|
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", "")
|
target = args.get("target", "")
|
||||||
message = args.get("message", "")
|
message = args.get("message", "")
|
||||||
if not target or not 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)
|
parts = target.split(":", 1)
|
||||||
platform_name = parts[0].strip().lower()
|
platform_name = parts[0].strip().lower()
|
||||||
chat_id = parts[1].strip() if len(parts) > 1 else None
|
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:
|
try:
|
||||||
from gateway.config import load_gateway_config, Platform
|
from gateway.config import load_gateway_config, Platform
|
||||||
config = load_gateway_config()
|
config = load_gateway_config()
|
||||||
|
|
@ -75,13 +120,28 @@ def send_message_tool(args, **kw):
|
||||||
chat_id = home.chat_id
|
chat_id = home.chat_id
|
||||||
used_home_channel = True
|
used_home_channel = True
|
||||||
else:
|
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:
|
try:
|
||||||
from model_tools import _run_async
|
from model_tools import _run_async
|
||||||
result = _run_async(_send_to_platform(platform, pconfig, chat_id, message))
|
result = _run_async(_send_to_platform(platform, pconfig, chat_id, message))
|
||||||
if used_home_channel and isinstance(result, dict) and result.get("success"):
|
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})"
|
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)
|
return json.dumps(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": f"Send failed: {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}"}
|
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 ---
|
# --- Registry ---
|
||||||
from tools.registry import registry
|
from tools.registry import registry
|
||||||
|
|
||||||
|
|
@ -163,4 +235,5 @@ registry.register(
|
||||||
toolset="messaging",
|
toolset="messaging",
|
||||||
schema=SEND_MESSAGE_SCHEMA,
|
schema=SEND_MESSAGE_SCHEMA,
|
||||||
handler=send_message_tool,
|
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.
|
# 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.
|
# Edit this once to update all platforms simultaneously.
|
||||||
_HERMES_CORE_TOOLS = [
|
_HERMES_CORE_TOOLS = [
|
||||||
# Web
|
# Web
|
||||||
|
|
@ -59,6 +58,8 @@ _HERMES_CORE_TOOLS = [
|
||||||
"execute_code", "delegate_task",
|
"execute_code", "delegate_task",
|
||||||
# Cronjob management
|
# Cronjob management
|
||||||
"schedule_cronjob", "list_cronjobs", "remove_cronjob",
|
"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)
|
# Full Hermes toolsets (CLI + messaging platforms)
|
||||||
#
|
#
|
||||||
# All platforms share the same core tools. Messaging platforms add
|
# All platforms share the same core tools. Messaging platforms add
|
||||||
# send_message for cross-channel messaging. Defined via _HERMES_CORE_TOOLS
|
# All platforms share the same core tools (including send_message,
|
||||||
# to avoid duplicating the tool list for each platform.
|
# which is gated on gateway running via its check_fn).
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|
||||||
"hermes-cli": {
|
"hermes-cli": {
|
||||||
|
|
@ -216,25 +217,25 @@ TOOLSETS = {
|
||||||
|
|
||||||
"hermes-telegram": {
|
"hermes-telegram": {
|
||||||
"description": "Telegram bot toolset - full access for personal use (terminal has safety checks)",
|
"description": "Telegram bot toolset - full access for personal use (terminal has safety checks)",
|
||||||
"tools": _HERMES_CORE_TOOLS + ["send_message"],
|
"tools": _HERMES_CORE_TOOLS,
|
||||||
"includes": []
|
"includes": []
|
||||||
},
|
},
|
||||||
|
|
||||||
"hermes-discord": {
|
"hermes-discord": {
|
||||||
"description": "Discord bot toolset - full access (terminal has safety checks via dangerous command approval)",
|
"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": []
|
"includes": []
|
||||||
},
|
},
|
||||||
|
|
||||||
"hermes-whatsapp": {
|
"hermes-whatsapp": {
|
||||||
"description": "WhatsApp bot toolset - similar to Telegram (personal messaging, more trusted)",
|
"description": "WhatsApp bot toolset - similar to Telegram (personal messaging, more trusted)",
|
||||||
"tools": _HERMES_CORE_TOOLS + ["send_message"],
|
"tools": _HERMES_CORE_TOOLS,
|
||||||
"includes": []
|
"includes": []
|
||||||
},
|
},
|
||||||
|
|
||||||
"hermes-slack": {
|
"hermes-slack": {
|
||||||
"description": "Slack bot toolset - full access for workspace use (terminal has safety checks)",
|
"description": "Slack bot toolset - full access for workspace use (terminal has safety checks)",
|
||||||
"tools": _HERMES_CORE_TOOLS + ["send_message"],
|
"tools": _HERMES_CORE_TOOLS,
|
||||||
"includes": []
|
"includes": []
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue