Hermes now routes scratch space through HERMES_HOME/cache/scratch (exported as TMPDIR), so every production path that still spelled out /tmp bypassed that and kept teaching the agent the habit. Fallbacks in tool_result_storage, code_execution_tool, process_registry, the ACP child HOME, mini_swe_runner's local cwd, and the CI/profiling scripts now use tempfile.gettempdir(); shell installers fall back to $TMPDIR (then HERMES_HOME) when mktemp is missing, and repro/eval shells use `mktemp -d -t`. User-facing help text and sample payloads (hermes send, approvals test, hooks test, voice-mode WSL hints, meet_bot debug line) no longer suggest /tmp. Container-side paths (mini_swe_runner docker cwd, sandbox base env, remote sync tarballs) keep the literal because they name the sandbox filesystem, not the host.
301 lines
15 KiB
Python
301 lines
15 KiB
Python
"""CLI subcommand: ``hermes send`` — pipe text from shell scripts to any configured messaging platform
|
|
(Telegram, Discord, Slack, Signal, SMS, etc.).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
|
|
_USAGE_EXIT = 2
|
|
_FAILURE_EXIT = 1
|
|
_SUCCESS_EXIT = 0
|
|
|
|
|
|
def _fail(msg: str, exit_code: int | None = None) -> int:
|
|
"""Print ``msg`` to stderr; exit with ``exit_code`` when given, else return ``_FAILURE_EXIT``."""
|
|
print(msg, file=sys.stderr)
|
|
if exit_code is not None:
|
|
sys.exit(exit_code)
|
|
return _FAILURE_EXIT
|
|
|
|
|
|
def _read_message_body(positional: Optional[str], file_path: Optional[str]) -> Optional[str]:
|
|
"""Resolve the message body: positional arg, then ``--file PATH`` / ``--file -`` (stdin), then
|
|
piped stdin when not attached to a TTY. ``None`` when nothing is available (a usage error)."""
|
|
if positional:
|
|
return positional
|
|
if file_path:
|
|
if file_path == "-":
|
|
return sys.stdin.read()
|
|
try:
|
|
return Path(file_path).read_text(encoding="utf-8")
|
|
except UnicodeDecodeError:
|
|
_fail(
|
|
f"hermes send: {file_path} is not a text file. --file reads the "
|
|
"message *body* (logs, reports, markdown).\n"
|
|
"To send an image/document/audio file as a native attachment, "
|
|
"reference it with MEDIA: in the message text instead:\n"
|
|
f' hermes send --to telegram "MEDIA:{file_path}"\n'
|
|
f' hermes send --to telegram "optional caption MEDIA:{file_path}"\n'
|
|
"Add [[as_document]] to deliver an image as an uncompressed file:\n"
|
|
f' hermes send --to telegram "[[as_document]] MEDIA:{file_path}"',
|
|
_USAGE_EXIT)
|
|
except OSError as exc:
|
|
_fail(f"hermes send: cannot read {file_path}: {exc}", _USAGE_EXIT)
|
|
|
|
# Reading from a TTY would block the user in a half-broken "type your message" state.
|
|
return (sys.stdin.read() or None) if not sys.stdin.isatty() else None
|
|
|
|
|
|
def _invalid_whatsapp_mentions(mentions: list[str]) -> list[str]:
|
|
"""Return mention values that cannot identify a WhatsApp participant."""
|
|
from gateway.whatsapp_identity import normalize_whatsapp_mention_jid
|
|
|
|
return [mention for mention in mentions if not normalize_whatsapp_mention_jid(mention)]
|
|
|
|
|
|
def _emit_result(result_json: str, *, json_mode: bool, quiet: bool) -> int:
|
|
"""Print the ``send_message_tool`` JSON result in the requested format; return the exit code.
|
|
Unknown / unexpected shapes are failures so scripts notice."""
|
|
try:
|
|
payload = json.loads(result_json) if result_json else {}
|
|
except json.JSONDecodeError:
|
|
# Pass the raw string through so the user can still see what went wrong.
|
|
payload = {"error": "invalid JSON from send_message_tool", "raw": result_json}
|
|
if json_mode:
|
|
print(json.dumps(payload, indent=2))
|
|
elif not quiet:
|
|
if payload.get("error"):
|
|
print(f"hermes send: {payload['error']}", file=sys.stderr)
|
|
elif payload.get("success"):
|
|
print(payload.get("note") or "sent")
|
|
else:
|
|
print(json.dumps(payload, indent=2)) # unknown shape — dump it, drop nothing
|
|
if not payload.get("error") and (payload.get("skipped") or payload.get("success")):
|
|
return _SUCCESS_EXIT
|
|
return _FAILURE_EXIT
|
|
|
|
|
|
def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int:
|
|
"""Print the channel directory (all configured targets across platforms), reusing the
|
|
``format_directory_for_display`` rendering the send_message tool shows the model."""
|
|
try:
|
|
from gateway.channel_directory import format_directory_for_display, load_directory
|
|
except Exception as exc:
|
|
return _fail(f"hermes send: failed to load channel directory: {exc}")
|
|
try:
|
|
raw = load_directory()
|
|
except Exception as exc:
|
|
return _fail(f"hermes send: failed to read channel directory: {exc}")
|
|
platforms = dict(raw.get("platforms") or {})
|
|
|
|
# Merge in configured-but-undiscovered platforms (e.g. a fresh SimpleX setup used only for
|
|
# outbound sends) so `--list` never hides a working send target.
|
|
try:
|
|
from gateway.config import load_gateway_config
|
|
for plat in load_gateway_config().get_connected_platforms():
|
|
plat_name = getattr(plat, "value", str(plat))
|
|
if plat_name not in ("local", "api_server", "webhook"):
|
|
platforms.setdefault(plat_name, [])
|
|
except Exception:
|
|
pass # directory contents alone are still useful; don't fail --list on a config problem
|
|
if platform_filter:
|
|
key = platform_filter.strip().lower()
|
|
filtered = {k: v for k, v in platforms.items() if k.lower() == key}
|
|
if not filtered:
|
|
return _fail(
|
|
f"hermes send: no targets found for platform '{platform_filter}'. "
|
|
f"Configured: {', '.join(sorted(platforms)) or '(none)'}")
|
|
platforms = filtered
|
|
if json_mode:
|
|
print(json.dumps({"platforms": platforms}, indent=2, default=str))
|
|
return _SUCCESS_EXIT
|
|
if not platforms:
|
|
print("No messaging platforms configured or no channels discovered yet.")
|
|
print("Set one up with `hermes gateway setup`, or run the gateway once so")
|
|
from hermes_constants import get_default_hermes_root, get_hermes_home, hermes_home_key
|
|
home, root = get_hermes_home(), get_default_hermes_root()
|
|
print(f"channel discovery can populate {home / 'channel_directory.json'}.")
|
|
# A gateway started from the default root writes that root's directory, never this profile's.
|
|
if hermes_home_key(root) != hermes_home_key(home) and (root / "channel_directory.json").exists():
|
|
print(f"A gateway running from {root} already has {root / 'channel_directory.json'}; "
|
|
f"this shell is scoped to profile home {home}, which has none.")
|
|
return _SUCCESS_EXIT
|
|
|
|
# Unfiltered: the shared formatter over the merged view. Filtered: a minimal view of our own.
|
|
if platform_filter is None:
|
|
print(format_directory_for_display(platforms))
|
|
return _SUCCESS_EXIT
|
|
for plat_name in sorted(platforms):
|
|
print(f"{plat_name}:")
|
|
if not platforms[plat_name]:
|
|
print(" (no channels discovered yet)")
|
|
continue
|
|
for ch in platforms[plat_name]:
|
|
name = ch.get("name", "?")
|
|
chat_id = ch.get("id") or ch.get("chat_id") or ""
|
|
print(f" {plat_name}:{name}" + (f" [{chat_id}]" if chat_id and chat_id != name else ""))
|
|
print()
|
|
return _SUCCESS_EXIT
|
|
|
|
|
|
def _load_hermes_env() -> None:
|
|
"""Populate the credential environment from ``<HERMES_HOME>/.env`` AND bridge top-level ``config.yaml``
|
|
keys into it so the gateway config loader sees platform credentials and home channels.
|
|
|
|
The target is ``os.environ`` for the standalone CLI. Inside a multi-profile host (dashboard console
|
|
running ``send`` for profile B under its secret scope) it is the installed scope mapping: writing B's
|
|
``.env`` into the shared process env would hand every other profile's later reads B's tokens
|
|
(``gateway.config._getenv`` reads the scope first, so the loader sees the same values either way).
|
|
The installed scope is already ``build_profile_secret_scope``'s composition — user ``.env``, then
|
|
the profile's external secret sources over it — so it is authoritative as-is; replaying raw
|
|
``.env`` over it would let a stale user value beat the secret-manager one for this request.
|
|
"""
|
|
import os
|
|
try:
|
|
from hermes_cli.config import get_hermes_home
|
|
home = get_hermes_home()
|
|
except Exception:
|
|
return
|
|
from agent.secret_scope import current_secret_scope, is_multiplex_active
|
|
scope = current_secret_scope() if is_multiplex_active() else None
|
|
if isinstance(scope, dict):
|
|
target: dict = scope
|
|
else:
|
|
target = os.environ
|
|
env_path = home / ".env"
|
|
if env_path.exists():
|
|
try:
|
|
from hermes_cli.env_loader import _load_dotenv_with_fallback
|
|
_load_dotenv_with_fallback(env_path, override=True)
|
|
except Exception:
|
|
pass
|
|
|
|
# Bridge top-level scalars the user (or the managed layer) actually wrote — never DEFAULT_CONFIG —
|
|
# into the environment, without overriding existing values.
|
|
config_path = home / "config.yaml"
|
|
if not config_path.exists():
|
|
return
|
|
try:
|
|
from hermes_cli.config_effective import load_user_config_effective
|
|
cfg = load_user_config_effective(config_path)
|
|
except Exception:
|
|
return
|
|
for key, val in cfg.items():
|
|
if isinstance(val, (str, int, float, bool)) and key not in target:
|
|
target[key] = str(val)
|
|
|
|
|
|
def cmd_send(args: argparse.Namespace) -> None:
|
|
"""Entry point wired into the top-level argparse dispatcher."""
|
|
_load_hermes_env() # the downstream gateway config loader reads credentials from os.environ
|
|
if getattr(args, "list_targets", False): # --list short-circuits everything else
|
|
# `hermes send --list telegram` lands "telegram" in the `message` positional.
|
|
exit_code = _list_targets(getattr(args, "message", None), json_mode=getattr(args, "json", False))
|
|
sys.exit(exit_code)
|
|
target = (getattr(args, "to", None) or "").strip()
|
|
if not target:
|
|
_fail(
|
|
"hermes send: --to PLATFORM[:channel[:thread]] is required\n"
|
|
"Examples:\n"
|
|
" hermes send --to telegram \"hello\"\n"
|
|
" hermes send --to discord:#ops --file report.md\n"
|
|
" hermes send --list # list available targets",
|
|
_USAGE_EXIT)
|
|
mentions = list(getattr(args, "mentions", None) or [])
|
|
if mentions and target.split(":", 1)[0].strip().lower() != "whatsapp":
|
|
_fail("hermes send: --mention is only supported for WhatsApp targets.", _USAGE_EXIT)
|
|
invalid_mentions = _invalid_whatsapp_mentions(mentions)
|
|
if invalid_mentions:
|
|
_fail(
|
|
"hermes send: invalid --mention value(s): "
|
|
f"{', '.join(invalid_mentions)}. Use a phone number or participant JID.",
|
|
_USAGE_EXIT)
|
|
message = _read_message_body(getattr(args, "message", None), getattr(args, "file", None))
|
|
if message is None or not message.strip():
|
|
_fail(
|
|
"hermes send: no message provided. Pass text as a positional "
|
|
"argument, use --file PATH, or pipe data via stdin.",
|
|
_USAGE_EXIT)
|
|
|
|
# Optional subject line: a consistent header for alerting scripts.
|
|
subject = getattr(args, "subject", None)
|
|
if subject:
|
|
message = f"{subject}\n\n{message.lstrip()}"
|
|
|
|
# Lazy import keeps `hermes send --help` fast (no tool registry / gateway config stack).
|
|
from tools.send_message_tool import send_message_tool
|
|
|
|
# Routes to the platform adapter (bot-token path for built-ins, live-adapter path for plugin
|
|
# platforms); takes the standard tool-call dict and returns a JSON string.
|
|
tool_args = {"action": "send", "target": target, "message": message}
|
|
if mentions:
|
|
tool_args["mentions"] = mentions
|
|
result = send_message_tool(tool_args)
|
|
sys.exit(_emit_result(result, json_mode=getattr(args, "json", False), quiet=getattr(args, "quiet", False)))
|
|
|
|
|
|
# (flags, add_argument kwargs) in --help order.
|
|
_SEND_ARGUMENTS = (
|
|
(("-t", "--to"), dict(metavar="TARGET", default=None, help=(
|
|
"Delivery target. Format: 'platform' (home channel), "
|
|
"'platform:chat_id', 'platform:chat_id:thread_id', or "
|
|
"'platform:#channel-name'. Examples: telegram, "
|
|
"telegram:-1001234567890:17585, discord:#ops, slack:C0123ABCD, signal:+15551234567."))),
|
|
(("message",), dict(nargs="?", default=None, help="Message text. If omitted, read from --file or stdin.")),
|
|
(("-f", "--file"), dict(metavar="PATH", default=None, help=(
|
|
"Read message body from PATH (text only). Use '-' to force stdin. "
|
|
"To send an image/document as an attachment, use MEDIA:<path> in the message text instead."))),
|
|
(("-s", "--subject"), dict(metavar="LINE", default=None, help="Prepend a subject/header line before the message body.")),
|
|
(("--mention",), dict(dest="mentions", action="append", default=None, metavar="PHONE_OR_JID", help=(
|
|
"WhatsApp only: add a native participant mention. Repeat for multiple recipients; "
|
|
"bare phone numbers are normalized to JIDs. Include each matching @<number> near the start of the message text."))),
|
|
(("-l", "--list"), dict(dest="list_targets", action="store_true", default=False,
|
|
help="List available targets. Optional positional filter: `hermes send --list telegram`.")),
|
|
(("-q", "--quiet"), dict(action="store_true", default=False, help="Suppress stdout on success (exit code only).")),
|
|
(("--json",), dict(action="store_true", default=False, help="Emit raw JSON result instead of human-readable output.")),
|
|
)
|
|
|
|
|
|
def register_send_subparser(subparsers) -> argparse.ArgumentParser:
|
|
"""Create the ``send`` subparser and return it."""
|
|
from hermes_constants import get_hermes_home
|
|
hermes_home = get_hermes_home()
|
|
parser = subparsers.add_parser(
|
|
"send",
|
|
help="Send a message to a configured platform (scripts, cron jobs, CI).",
|
|
description=(
|
|
"Pipe text from any shell script to any messaging platform Hermes "
|
|
"is already configured for. Reuses the gateway's platform "
|
|
f"credentials ({hermes_home / '.env'} + "
|
|
f"{hermes_home / 'config.yaml'}) — no LLM, "
|
|
"no agent loop, no running gateway required for bot-token "
|
|
"platforms like Telegram/Discord/Slack/Signal."
|
|
),
|
|
epilog=(
|
|
"Examples:\n"
|
|
" hermes send --to telegram \"deploy finished\"\n"
|
|
" echo \"RAM 92%\" | hermes send --to telegram:-1001234567890\n"
|
|
" hermes send --to discord:#ops --file ./report.md\n"
|
|
" hermes send --to slack:#eng --subject \"[CI]\" --file build.log\n"
|
|
" hermes send --to whatsapp:GROUP@g.us --mention 15551234567 \"@15551234567 hello\"\n"
|
|
" hermes send --to telegram \"MEDIA:./chart.png\" # send a media attachment\n"
|
|
" hermes send --list # all platforms\n"
|
|
" hermes send --list telegram # filter by platform\n"
|
|
"\n"
|
|
"Exit codes: 0 ok, 1 delivery/backend error, 2 usage error."
|
|
),
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
for flags, kwargs in _SEND_ARGUMENTS:
|
|
parser.add_argument(*flags, **kwargs)
|
|
parser.set_defaults(func=cmd_send)
|
|
return parser
|
|
|
|
|
|
__all__ = ["cmd_send", "register_send_subparser"]
|