Initial import of NousResearch/hermes-agent
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (push) Has been cancelled

This commit is contained in:
红尘
2026-05-31 09:36:58 +08:00
commit d73ff9b0fb
4188 changed files with 1614916 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
name: discord-platform
label: Discord
kind: platform
version: 1.0.0
description: >
Discord gateway adapter for Hermes Agent.
Connects to Discord via the discord.py library and relays messages
between Discord guilds/DMs and the Hermes agent. Supports voice mode,
slash commands, free-response channels, role-based DM auth, threads,
reactions, and channel skill bindings.
author: NousResearch
requires_env:
- name: DISCORD_BOT_TOKEN
description: "Discord bot token"
prompt: "Discord bot token"
url: "https://discord.com/developers/applications"
password: true
optional_env:
- name: DISCORD_ALLOWED_USERS
description: "Comma-separated Discord user IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: DISCORD_ALLOW_ALL_USERS
description: "Allow any Discord user to trigger the bot (dev only)"
prompt: "Allow all users? (true/false)"
password: false
- name: DISCORD_HOME_CHANNEL
description: "Default channel ID for cron / notification delivery"
prompt: "Home channel ID"
password: false
- name: DISCORD_HOME_CHANNEL_NAME
description: "Display name for the Discord home channel"
prompt: "Home channel display name"
password: false
@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]
File diff suppressed because it is too large Load Diff
+667
View File
@@ -0,0 +1,667 @@
"""User OAuth helper for the Google Chat gateway adapter.
Google Chat's ``media.upload`` REST endpoint hard-rejects service-account
authentication:
"This method doesn't support app authentication with a service
account. Authenticate with a user account."
(See https://developers.google.com/workspace/chat/api/reference/rest/v1/media/upload
and https://developers.google.com/chat/api/guides/auth/users.)
For the bot to deliver native file attachments — the same drag-and-drop
file widget the user gets when they upload manually — each user must
grant the bot the ``chat.messages.create`` scope ONCE in their own DM.
The bot stores per-user refresh tokens and calls ``media.upload`` plus
the subsequent ``messages.create`` *as the requesting user* whenever a
file needs sending.
This module is BOTH a CLI tool (driven by the agent via slash commands or
terminal commands) AND a library imported by ``google_chat.py``:
Library functions (called from the adapter at runtime):
load_user_credentials(email=None) -> Credentials | None
refresh_or_none(creds, email=None) -> Credentials | None
build_user_chat_service(creds) -> chat_v1.Resource
list_authorized_emails() -> List[str]
CLI commands (driven by the agent through the /setup-files slash
command, modeled on skills/productivity/google-workspace/scripts/setup.py):
--check Exit 0 if auth is valid, else 1
--client-secret /path/to.json Persist OAuth client credentials
--auth-url Print the OAuth URL for the user
--auth-code CODE Exchange auth code for token
--revoke Revoke and delete stored token
--install-deps Install Python dependencies
--email EMAIL Scope CLI ops to a specific user
(defaults to legacy single-user
mode when omitted)
The flow mirrors the existing google-workspace skill exactly so anyone
familiar with that flow can read this without surprises.
Token storage layout
--------------------
- Per-user tokens (keyed by sender email):
``${HERMES_HOME}/google_chat_user_tokens/<sanitized_email>.json``
- Legacy single-user token (fallback, untouched for backward compat):
``${HERMES_HOME}/google_chat_user_token.json``
- Per-user pending OAuth state during /setup-files start → exchange:
``${HERMES_HOME}/google_chat_user_oauth_pending/<sanitized_email>.json``
- Legacy pending state:
``${HERMES_HOME}/google_chat_user_oauth_pending.json``
- Shared OAuth client (one per host):
``${HERMES_HOME}/google_chat_user_client_secret.json``
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import secrets
import stat
import subprocess
import sys
from pathlib import Path
from typing import Any, List, Optional, Tuple
# Pin the legacy logger name so operator-side log filters keep matching
# after the in-tree → plugin migration. See adapter.py for context.
logger = logging.getLogger("gateway.platforms.google_chat_user_oauth")
# Use the project's HERMES_HOME helper so the token follows the user's
# profile (e.g. tests can override via HERMES_HOME=/tmp/...).
try:
from hermes_constants import display_hermes_home, get_hermes_home
except (ModuleNotFoundError, ImportError):
# Fallback for environments where hermes_constants isn't importable
# (mirrors the same fallback used by the google-workspace skill's
# _hermes_home.py shim).
def get_hermes_home() -> Path:
val = os.environ.get("HERMES_HOME", "").strip()
return Path(val) if val else Path.home() / ".hermes"
def display_hermes_home() -> str:
home = get_hermes_home()
try:
return "~/" + str(home.relative_to(Path.home()))
except ValueError:
return str(home)
from utils import atomic_replace
def _hermes_home() -> Path:
"""Resolve HERMES_HOME at call time (NOT module import).
Tests and ``HERMES_HOME=...`` env overrides need this to be late-
binding. If we cached the path at import time, switching profiles
or tweaking env vars in tests would silently keep using the old
path."""
return get_hermes_home()
# Filesystem-safe key: lowercase, allow ``[a-z0-9._-@]``, replace anything
# else with ``_``. ``ramon.fernandez@nttdata.com`` stays human-readable
# (``ramon.fernandez@nttdata.com.json``) which makes admin debugging by
# ``ls ~/.hermes/google_chat_user_tokens/`` trivial.
_EMAIL_FS_RE = re.compile(r"[^a-z0-9._@-]+")
def _sanitize_email(email: str) -> str:
cleaned = _EMAIL_FS_RE.sub("_", (email or "").strip().lower())
return cleaned or "_unknown_"
def _legacy_token_path() -> Path:
return _hermes_home() / "google_chat_user_token.json"
def _user_tokens_dir() -> Path:
return _hermes_home() / "google_chat_user_tokens"
def _legacy_pending_path() -> Path:
return _hermes_home() / "google_chat_user_oauth_pending.json"
def _user_pending_dir() -> Path:
return _hermes_home() / "google_chat_user_oauth_pending"
def _token_path(email: Optional[str] = None) -> Path:
"""Return the on-disk token path for ``email`` or the legacy path."""
if email:
return _user_tokens_dir() / f"{_sanitize_email(email)}.json"
return _legacy_token_path()
def _client_secret_path() -> Path:
return _hermes_home() / "google_chat_user_client_secret.json"
def _pending_auth_path(email: Optional[str] = None) -> Path:
if email:
return _user_pending_dir() / f"{_sanitize_email(email)}.json"
return _legacy_pending_path()
# Minimum scope for native Chat attachment delivery.
# `chat.messages.create` covers BOTH `media.upload` and the subsequent
# `messages.create` that references the attachmentDataRef. We deliberately
# do NOT request drive.file or other scopes — least privilege.
SCOPES: List[str] = [
"https://www.googleapis.com/auth/chat.messages.create",
]
# Pip packages required for the OAuth flow.
_REQUIRED_PACKAGES = [
"google-api-python-client",
"google-auth-oauthlib",
"google-auth-httplib2",
]
# Out-of-band redirect: Google deprecated the ``urn:ietf:wg:oauth:2.0:oob``
# flow, so we use a localhost redirect that's expected to FAIL. The user
# copies the auth code from the failed browser URL bar back into chat.
# Same trick used by skills/productivity/google-workspace/scripts/setup.py.
_REDIRECT_URI = "http://localhost:1"
# =============================================================================
# Library API — called from the adapter at runtime
# =============================================================================
def load_user_credentials(email: Optional[str] = None) -> Optional[Any]:
"""Load + validate persisted user OAuth credentials.
``email`` selects the per-user token file; ``None`` falls back to the
legacy single-user path (left in place for installs that ran the
pre-multi-user flow). Returns a ``google.oauth2.credentials.Credentials``
instance ready for use, or ``None`` if no token is stored, the token
is corrupt, or refresh fails. Adapter callers should treat ``None``
as "user has not run /setup-files yet" and surface the setup-instructions
fallback to the user.
Does NOT raise on the no-token case — that's expected.
"""
token_path = _token_path(email)
if not token_path.exists():
return None
try:
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
except ImportError:
logger.warning(
"[google_chat_user_oauth] google-auth not installed; user-OAuth "
"attachment delivery is disabled. Install hermes-agent[google_chat]."
)
return None
try:
# Don't pass scopes — user may have authorized only a subset, and
# passing scopes makes refresh validate them strictly. Same logic
# as the google-workspace skill.
creds = Credentials.from_authorized_user_file(str(token_path))
except Exception as exc:
logger.warning(
"[google_chat_user_oauth] token at %s is corrupt: %s",
token_path, exc,
)
return None
if creds.valid:
return creds
if creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
except Exception as exc:
logger.warning(
"[google_chat_user_oauth] token refresh failed (user "
"should re-run /setup-files): %s", exc,
)
return None
# Persist refreshed token so next start picks up the new access
# token without an unnecessary refresh round-trip.
_persist_credentials(creds, token_path)
return creds
# Token exists but is unusable (e.g. revoked, no refresh token).
return None
def refresh_or_none(creds: Any, email: Optional[str] = None) -> Optional[Any]:
"""Refresh ``creds`` if expired. Returns the credentials or ``None``.
Used by the adapter just before calling media.upload to ensure the
token is current. Returns ``None`` if refresh fails — caller falls
back to the text-notice path. ``email`` controls where the refreshed
token is written back; ``None`` keeps the legacy single-file path.
"""
if creds is None:
return None
if creds.valid:
return creds
try:
from google.auth.transport.requests import Request
except ImportError:
return None
if creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
_persist_credentials(creds, _token_path(email))
return creds
except Exception as exc:
logger.warning(
"[google_chat_user_oauth] refresh failed: %s", exc,
)
return None
return None
def build_user_chat_service(creds: Any) -> Any:
"""Build a Google Chat API client authenticated as the user.
Used for media.upload + the subsequent messages.create that
references the attachmentDataRef. The bot's separate SA-authed
client (``self._chat_api`` in the adapter) is for everything else.
"""
from googleapiclient.discovery import build as build_service
return build_service("chat", "v1", credentials=creds, cache_discovery=False)
def list_authorized_emails() -> List[str]:
"""Return the set of user emails that have stored per-user tokens.
Lists files in the per-user tokens dir; does NOT include the legacy
single-user token (its owner is unknown). Sanitized filenames lose
the ``+suffix`` part of plus-addressed emails — accept that and use
this list only for admin display, not for trust decisions.
"""
d = _user_tokens_dir()
if not d.exists():
return []
out: List[str] = []
for f in d.iterdir():
if f.is_file() and f.suffix == ".json":
out.append(f.stem)
out.sort()
return out
def _persist_credentials(creds: Any, token_path: Path) -> None:
"""Persist refreshed credentials atomically with private permissions."""
try:
_write_private_json(
token_path,
_normalize_authorized_user_payload(json.loads(creds.to_json())),
)
except Exception:
logger.debug(
"[google_chat_user_oauth] failed to persist credentials at %s",
token_path, exc_info=True,
)
# =============================================================================
# CLI commands — driven by the agent via /setup-files
# =============================================================================
def _normalize_authorized_user_payload(payload: dict) -> dict:
"""Ensure the persisted token JSON has the type field google-auth expects."""
normalized = dict(payload)
if not normalized.get("type"):
normalized["type"] = "authorized_user"
return normalized
def _write_private_json(path: Path, data: Any) -> None:
"""Atomically write JSON with 0o600 permissions where supported."""
path.parent.mkdir(parents=True, exist_ok=True)
try:
os.chmod(path.parent, 0o700)
except OSError:
pass
tmp_path = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
try:
fd = os.open(
str(tmp_path),
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
stat.S_IRUSR | stat.S_IWUSR,
)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
fh.flush()
os.fsync(fh.fileno())
atomic_replace(tmp_path, path)
try:
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
finally:
try:
if tmp_path.exists():
tmp_path.unlink()
except OSError:
pass
def _ensure_deps() -> None:
"""Check deps available; install if not; exit on failure."""
try:
import googleapiclient # noqa: F401
import google_auth_oauthlib # noqa: F401
except ImportError:
if not install_deps():
sys.exit(1)
def install_deps() -> bool:
try:
import googleapiclient # noqa: F401
import google_auth_oauthlib # noqa: F401
print("Dependencies already installed.")
return True
except ImportError:
pass
print("Installing Google Chat OAuth dependencies...")
try:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "--quiet"] + _REQUIRED_PACKAGES,
stdout=subprocess.DEVNULL,
)
print("Dependencies installed.")
return True
except subprocess.CalledProcessError as exc:
print(f"ERROR: Failed to install dependencies: {exc}")
print("Or install via the optional extra:")
print(" pip install 'hermes-agent[google_chat]'")
return False
def check_auth(email: Optional[str] = None) -> bool:
"""Print status; return True if creds are usable.
Per-user when ``email`` given, legacy single-user when omitted.
"""
token_path = _token_path(email)
if not token_path.exists():
print(f"NOT_AUTHENTICATED: No token at {token_path}")
return False
creds = load_user_credentials(email)
if creds is None:
print(f"TOKEN_INVALID: Re-run /setup-files (path: {token_path})")
return False
print(f"AUTHENTICATED: Token valid at {token_path}")
return True
def store_client_secret(path: str) -> None:
"""Validate and copy the user's OAuth client_secret.json into HERMES_HOME."""
src = Path(path).expanduser().resolve()
if not src.exists():
print(f"ERROR: File not found: {src}")
sys.exit(1)
try:
data = json.loads(src.read_text())
except json.JSONDecodeError:
print("ERROR: File is not valid JSON.")
sys.exit(1)
if "installed" not in data and "web" not in data:
print(
"ERROR: Not a Google OAuth client secret file (missing "
"'installed' or 'web' key)."
)
print(
"Download from: https://console.cloud.google.com/apis/credentials"
)
sys.exit(1)
target = _client_secret_path()
_write_private_json(target, data)
print(f"OK: Client secret saved to {target}")
def _save_pending_auth(*, state: str, code_verifier: str,
email: Optional[str] = None) -> None:
pending = _pending_auth_path(email)
_write_private_json(
pending,
{
"state": state,
"code_verifier": code_verifier,
"redirect_uri": _REDIRECT_URI,
"email": email or "",
},
)
def _load_pending_auth(email: Optional[str] = None) -> dict:
pending = _pending_auth_path(email)
if not pending.exists():
print("ERROR: No pending OAuth session found. Run --auth-url first.")
sys.exit(1)
try:
data = json.loads(pending.read_text())
except Exception as exc:
print(f"ERROR: Could not read pending OAuth session: {exc}")
print("Run --auth-url again to start a fresh session.")
sys.exit(1)
if not data.get("state") or not data.get("code_verifier"):
print("ERROR: Pending OAuth session is missing PKCE data.")
print("Run --auth-url again.")
sys.exit(1)
return data
def _extract_code_and_state(code_or_url: str) -> Tuple[str, Optional[str]]:
"""Accept a raw auth code OR the full failed-redirect URL the user pastes."""
if not code_or_url.startswith("http"):
return code_or_url, None
from urllib.parse import parse_qs, urlparse
parsed = urlparse(code_or_url)
params = parse_qs(parsed.query)
if "code" not in params:
print("ERROR: No 'code' parameter found in URL.")
sys.exit(1)
state = params.get("state", [None])[0]
return params["code"][0], state
def get_auth_url(email: Optional[str] = None) -> None:
"""Print the OAuth URL for the user to visit. Persists PKCE state.
``email`` namespaces the pending state so two users can be mid-flow
in parallel without trampling each other's PKCE verifier.
"""
if not _client_secret_path().exists():
print("ERROR: No client secret stored. Run --client-secret first.")
sys.exit(1)
_ensure_deps()
from google_auth_oauthlib.flow import Flow
flow = Flow.from_client_secrets_file(
str(_client_secret_path()),
scopes=SCOPES,
redirect_uri=_REDIRECT_URI,
autogenerate_code_verifier=True,
)
auth_url, state = flow.authorization_url(
access_type="offline",
prompt="consent",
)
_save_pending_auth(state=state, code_verifier=flow.code_verifier, email=email)
print(auth_url)
def exchange_auth_code(code: str, email: Optional[str] = None) -> None:
"""Exchange an auth code (or pasted redirect URL) for a refresh token.
``email`` selects the destination token path. ``None`` writes to the
legacy single-user path (kept for the existing CLI entrypoint and for
pre-multi-user installs).
"""
if not _client_secret_path().exists():
print("ERROR: No client secret stored. Run --client-secret first.")
sys.exit(1)
pending_auth = _load_pending_auth(email)
raw_callback = code
code, returned_state = _extract_code_and_state(code)
if returned_state and returned_state != pending_auth["state"]:
print(
"ERROR: OAuth state mismatch. Run --auth-url again to start a "
"fresh session."
)
sys.exit(1)
_ensure_deps()
from google_auth_oauthlib.flow import Flow
from urllib.parse import parse_qs, urlparse
granted_scopes = list(SCOPES)
if isinstance(raw_callback, str) and raw_callback.startswith("http"):
params = parse_qs(urlparse(raw_callback).query)
scope_val = (params.get("scope") or [""])[0].strip()
if scope_val:
granted_scopes = scope_val.split()
flow = Flow.from_client_secrets_file(
str(_client_secret_path()),
scopes=granted_scopes,
redirect_uri=pending_auth.get("redirect_uri", _REDIRECT_URI),
state=pending_auth["state"],
code_verifier=pending_auth["code_verifier"],
)
try:
# Accept partial scopes — user may deselect items in the consent screen.
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
flow.fetch_token(code=code)
except Exception as exc:
print(f"ERROR: Token exchange failed: {exc}")
print("The code may have expired. Run --auth-url to get a fresh URL.")
sys.exit(1)
creds = flow.credentials
token_payload = _normalize_authorized_user_payload(json.loads(creds.to_json()))
actually_granted = (
list(creds.granted_scopes or [])
if hasattr(creds, "granted_scopes") and creds.granted_scopes
else []
)
if actually_granted:
token_payload["scopes"] = actually_granted
elif granted_scopes != SCOPES:
token_payload["scopes"] = granted_scopes
token_path = _token_path(email)
_write_private_json(token_path, token_payload)
_pending_auth_path(email).unlink(missing_ok=True)
print(f"OK: Authenticated. Token saved to {token_path}")
rel_label = (
f"{display_hermes_home()}/google_chat_user_tokens/{_sanitize_email(email)}.json"
if email
else f"{display_hermes_home()}/google_chat_user_token.json"
)
print(f"Profile path: {rel_label}")
def revoke(email: Optional[str] = None) -> None:
"""Revoke the stored token with Google and delete it locally.
Per-user when ``email`` given, legacy single-user when omitted.
"""
token_path = _token_path(email)
if not token_path.exists():
print("No token to revoke.")
return
_ensure_deps()
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
try:
creds = Credentials.from_authorized_user_file(str(token_path), SCOPES)
if creds.expired and creds.refresh_token:
creds.refresh(Request())
import urllib.request
urllib.request.urlopen(
urllib.request.Request(
f"https://oauth2.googleapis.com/revoke?token={creds.token}",
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
),
timeout=15,
)
print("Token revoked with Google.")
except Exception as exc:
print(f"Remote revocation failed (token may already be invalid): {exc}")
token_path.unlink(missing_ok=True)
_pending_auth_path(email).unlink(missing_ok=True)
print(f"Deleted {token_path}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Google Chat user-OAuth setup for Hermes (native attachment delivery)"
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--check", action="store_true",
help="Check if auth is valid (exit 0=yes, 1=no)")
group.add_argument("--client-secret", metavar="PATH",
help="Store OAuth client_secret.json")
group.add_argument("--auth-url", action="store_true",
help="Print OAuth URL for user to visit")
group.add_argument("--auth-code", metavar="CODE",
help="Exchange auth code for token")
group.add_argument("--revoke", action="store_true",
help="Revoke and delete stored token")
group.add_argument("--install-deps", action="store_true",
help="Install Python dependencies")
parser.add_argument("--email", metavar="EMAIL", default=None,
help="Scope operation to a specific user's token "
"(default: legacy single-user path)")
args = parser.parse_args()
email = args.email or None
if args.check:
sys.exit(0 if check_auth(email) else 1)
elif args.client_secret:
store_client_secret(args.client_secret)
elif args.auth_url:
get_auth_url(email)
elif args.auth_code:
exchange_auth_code(args.auth_code, email)
elif args.revoke:
revoke(email)
elif args.install_deps:
sys.exit(0 if install_deps() else 1)
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
name: google_chat-platform
label: Google Chat
kind: platform
version: 1.0.0
description: >
Google Chat gateway adapter for Hermes Agent.
Connects via Cloud Pub/Sub pull subscription for inbound events and the
Google Chat REST API for outbound messages — same ergonomics as Slack
Socket Mode or Telegram long-polling, no public URL required. Native
file attachments are delivered via per-user OAuth (each user runs
/setup-files once in their own DM).
author: Ramón Fernández
# ``requires_env`` entries are surfaced in ``hermes config`` UI via the
# platform-plugin env var injector in ``hermes_cli/config.py``. Using the
# rich-dict form lets us contribute description/url/prompt metadata so users
# see helpful guidance instead of the auto-generated fallback text.
requires_env:
- name: GOOGLE_CHAT_PROJECT_ID
description: "GCP project ID hosting the Pub/Sub topic for Chat events. Falls back to GOOGLE_CLOUD_PROJECT."
prompt: "GCP project ID"
url: "https://console.cloud.google.com/"
password: false
- name: GOOGLE_CHAT_SUBSCRIPTION_NAME
description: "Full Pub/Sub subscription path: projects/<proj>/subscriptions/<sub>. Legacy alias: GOOGLE_CHAT_SUBSCRIPTION."
prompt: "Pub/Sub subscription name"
password: false
- name: GOOGLE_CHAT_SERVICE_ACCOUNT_JSON
description: "Path to Service Account JSON key (or inline JSON). Leave empty to use Application Default Credentials on Cloud Run / GCE. Falls back to GOOGLE_APPLICATION_CREDENTIALS."
prompt: "Path to SA JSON (or empty for ADC)"
password: true
optional_env:
- name: GOOGLE_CHAT_ALLOWED_USERS
description: "Comma-separated user emails allowed to interact with the bot."
prompt: "Allowed user emails (comma-separated)"
password: false
- name: GOOGLE_CHAT_HOME_CHANNEL
description: "Default space for cron / notification delivery (e.g. spaces/AAAA...)."
prompt: "Home space ID (or empty)"
password: false
+3
View File
@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]
+968
View File
@@ -0,0 +1,968 @@
"""
IRC Platform Adapter for Hermes Agent.
A plugin-based gateway adapter that connects to an IRC server and relays
messages to/from the Hermes agent. Zero external dependencies — uses
Python's stdlib asyncio for the IRC protocol.
Configuration in config.yaml::
gateway:
platforms:
irc:
enabled: true
extra:
server: irc.libera.chat
port: 6697
nickname: hermes-bot
channel: "#hermes"
use_tls: true
server_password: "" # optional server password
nickserv_password: "" # optional NickServ identification
allowed_users: [] # empty = allow all, or list of nicks
max_message_length: 450 # IRC line limit (safe default)
Or via environment variables (overrides config.yaml):
IRC_SERVER, IRC_PORT, IRC_NICKNAME, IRC_CHANNEL, IRC_USE_TLS,
IRC_SERVER_PASSWORD, IRC_NICKSERV_PASSWORD
"""
import asyncio
import logging
import os
import re
import ssl
import time
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Lazy import: BasePlatformAdapter and friends live in the main repo.
# We import at function/class level to avoid import errors when the plugin
# is discovered but the gateway hasn't been fully initialised yet.
# ---------------------------------------------------------------------------
from gateway.platforms.base import (
BasePlatformAdapter,
SendResult,
MessageEvent,
MessageType,
)
from gateway.config import Platform
# ---------------------------------------------------------------------------
# IRC protocol helpers
# ---------------------------------------------------------------------------
def _parse_irc_message(raw: str) -> dict:
"""Parse a raw IRC protocol line into components.
Returns dict with keys: prefix, command, params.
"""
prefix = ""
trailing = ""
if raw.startswith(":"):
try:
prefix, raw = raw[1:].split(" ", 1)
except ValueError:
prefix = raw[1:]
raw = ""
if " :" in raw:
raw, trailing = raw.split(" :", 1)
parts = raw.split()
command = parts[0] if parts else ""
params = parts[1:] if len(parts) > 1 else []
if trailing:
params.append(trailing)
return {"prefix": prefix, "command": command, "params": params}
def _extract_nick(prefix: str) -> str:
"""Extract nickname from IRC prefix (nick!user@host)."""
return prefix.split("!")[0] if "!" in prefix else prefix
# ---------------------------------------------------------------------------
# IRC Adapter
# ---------------------------------------------------------------------------
class IRCAdapter(BasePlatformAdapter):
"""Async IRC adapter implementing the BasePlatformAdapter interface.
This class is instantiated by the adapter_factory passed to
register_platform().
"""
def __init__(self, config, **kwargs):
platform = Platform("irc")
super().__init__(config=config, platform=platform)
extra = getattr(config, "extra", {}) or {}
# Connection settings (env vars override config.yaml)
self.server = os.getenv("IRC_SERVER") or extra.get("server", "")
self.port = int(os.getenv("IRC_PORT") or extra.get("port", 6697))
self.nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
self.channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
self.use_tls = (
os.getenv("IRC_USE_TLS", "").lower() in {"1", "true", "yes"}
if os.getenv("IRC_USE_TLS")
else extra.get("use_tls", True)
)
self.server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
self.nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
# Auth
self.allowed_users: list = extra.get("allowed_users", [])
# IRC nicks are case-insensitive — normalise for lookups
self._allowed_users_lower: set = {u.lower() for u in self.allowed_users if isinstance(u, str)}
# IRC limits
max_msg = extra.get("max_message_length")
if max_msg is None:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get("irc")
if entry and entry.max_message_length:
max_msg = entry.max_message_length
except Exception:
pass
self.max_message_length = int(max_msg or 450)
# Runtime state
self._reader: Optional[asyncio.StreamReader] = None
self._writer: Optional[asyncio.StreamWriter] = None
self._recv_task: Optional[asyncio.Task] = None
self._current_nick = self.nickname
self._registered = False # IRC registration complete
self._registration_event = asyncio.Event()
@property
def name(self) -> str:
return "IRC"
# ── Connection lifecycle ──────────────────────────────────────────────
async def connect(self) -> bool:
"""Connect to the IRC server, register, and join the channel."""
if not self.server or not self.channel:
logger.error("IRC: server and channel must be configured")
self._set_fatal_error(
"config_missing",
"IRC_SERVER and IRC_CHANNEL must be set",
retryable=False,
)
return False
# Prevent two profiles from using the same IRC identity
try:
from gateway.status import acquire_scoped_lock, release_scoped_lock
lock_key = f"{self.server}:{self.nickname}"
if not acquire_scoped_lock("irc", lock_key):
logger.error("IRC: %s@%s already in use by another profile", self.nickname, self.server)
self._set_fatal_error("lock_conflict", "IRC identity in use by another profile", retryable=False)
return False
self._lock_key = lock_key
except ImportError:
self._lock_key = None # status module not available (e.g. tests)
try:
ssl_ctx = None
if self.use_tls:
ssl_ctx = ssl.create_default_context()
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(self.server, self.port, ssl=ssl_ctx),
timeout=30.0,
)
except Exception as e:
logger.error("IRC: failed to connect to %s:%s%s", self.server, self.port, e)
self._set_fatal_error("connect_failed", str(e), retryable=True)
return False
# IRC registration sequence
if self.server_password:
await self._send_raw(f"PASS {self.server_password}")
await self._send_raw(f"NICK {self.nickname}")
await self._send_raw(f"USER {self.nickname} 0 * :Hermes Agent")
# Start receive loop
self._recv_task = asyncio.create_task(self._receive_loop())
# Wait for registration (001 RPL_WELCOME) with timeout
try:
await asyncio.wait_for(self._registration_event.wait(), timeout=30.0)
except asyncio.TimeoutError:
logger.error("IRC: registration timed out")
await self.disconnect()
self._set_fatal_error("registration_timeout", "IRC server did not send RPL_WELCOME", retryable=True)
return False
# NickServ identification
if self.nickserv_password:
await self._send_raw(f"PRIVMSG NickServ :IDENTIFY {self.nickserv_password}")
await asyncio.sleep(2) # Give NickServ time to process
# Join channel
await self._send_raw(f"JOIN {self.channel}")
self._mark_connected()
logger.info("IRC: connected to %s:%s as %s, joined %s", self.server, self.port, self._current_nick, self.channel)
return True
async def disconnect(self) -> None:
"""Quit and close the connection."""
# Release the scoped lock so another profile can use this identity
if getattr(self, "_lock_key", None):
try:
from gateway.status import release_scoped_lock
release_scoped_lock("irc", self._lock_key)
except Exception:
pass
self._mark_disconnected()
if self._writer and not self._writer.is_closing():
try:
await self._send_raw("QUIT :Hermes Agent shutting down")
await asyncio.sleep(0.5)
except Exception:
pass
try:
self._writer.close()
await self._writer.wait_closed()
except Exception:
pass
if self._recv_task and not self._recv_task.done():
self._recv_task.cancel()
try:
await self._recv_task
except asyncio.CancelledError:
pass
self._reader = None
self._writer = None
self._registered = False
self._registration_event.clear()
# ── Sending ───────────────────────────────────────────────────────────
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
):
if not self._writer or self._writer.is_closing():
return SendResult(success=False, error="Not connected")
target = chat_id # channel name or nick for DMs
lines = self._split_message(content, target)
for line in lines:
try:
await self._send_raw(f"PRIVMSG {target} :{line}")
# Basic rate limiting to avoid excess flood
await asyncio.sleep(0.3)
except Exception as e:
return SendResult(success=False, error=str(e))
return SendResult(success=True, message_id=str(int(time.time() * 1000)))
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""IRC has no typing indicator — no-op."""
pass
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
is_channel = chat_id.startswith("#") or chat_id.startswith("&")
return {
"name": chat_id,
"type": "group" if is_channel else "dm",
}
# ── Message splitting ─────────────────────────────────────────────────
def _split_message(self, content: str, target: str) -> List[str]:
"""Split a long message into IRC-safe chunks.
IRC has a ~512 byte line limit. After accounting for protocol
overhead (``PRIVMSG <target> :``), we split content into chunks.
"""
# Strip markdown formatting that doesn't render in IRC
content = self._strip_markdown(content)
overhead = len(f"PRIVMSG {target} :".encode("utf-8")) + 2 # +2 for \r\n
max_bytes = 510 - overhead
user_limit = self.max_message_length
lines: List[str] = []
for paragraph in content.split("\n"):
if not paragraph.strip():
continue
while True:
para_bytes = paragraph.encode("utf-8")
limit = min(user_limit, max_bytes)
if len(para_bytes) <= limit:
if paragraph.strip():
lines.append(paragraph)
break
# Binary search for a safe character boundary <= limit
low, high = 1, len(paragraph)
best = 0
while low <= high:
mid = (low + high) // 2
if len(paragraph[:mid].encode("utf-8")) <= limit:
best = mid
low = mid + 1
else:
high = mid - 1
split_at = best
# Prefer a space boundary
space = paragraph.rfind(" ", 0, split_at)
if space > split_at // 3:
split_at = space
lines.append(paragraph[:split_at].rstrip())
paragraph = paragraph[split_at:].lstrip()
return lines if lines else [""]
@staticmethod
def _strip_markdown(text: str) -> str:
"""Convert basic markdown to plain text for IRC."""
# Bold: **text** or __text__ → text
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = re.sub(r"__(.+?)__", r"\1", text)
# Italic: *text* or _text_ → text
text = re.sub(r"\*(.+?)\*", r"\1", text)
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
# Inline code: `text` → text
text = re.sub(r"`(.+?)`", r"\1", text)
# Code blocks: ```...``` → content
text = re.sub(r"```\w*\n?", "", text)
# Images: ![alt](url) → url (must come BEFORE links)
text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", text)
# Links: [text](url) → text (url)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", text)
return text
# ── Raw IRC I/O ──────────────────────────────────────────────────────
async def _send_raw(self, line: str) -> None:
"""Send a raw IRC protocol line."""
if not self._writer or self._writer.is_closing():
return
encoded = (line + "\r\n").encode("utf-8")
self._writer.write(encoded)
await self._writer.drain()
async def _receive_loop(self) -> None:
"""Main receive loop — reads lines and dispatches them."""
buffer = b""
try:
while self._reader and not self._reader.at_eof():
data = await self._reader.read(4096)
if not data:
break
buffer += data
while b"\r\n" in buffer:
line, buffer = buffer.split(b"\r\n", 1)
try:
decoded = line.decode("utf-8", errors="replace")
await self._handle_line(decoded)
except Exception as e:
logger.warning("IRC: error handling line: %s", e)
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("IRC: receive loop error: %s", e)
finally:
if self.is_connected:
logger.warning("IRC: connection lost, marking disconnected")
self._set_fatal_error("connection_lost", "IRC connection closed unexpectedly", retryable=True)
await self._notify_fatal_error()
async def _handle_line(self, raw: str) -> None:
"""Dispatch a single IRC protocol line."""
msg = _parse_irc_message(raw)
command = msg["command"]
params = msg["params"]
# PING/PONG keepalive
if command == "PING":
payload = params[0] if params else ""
await self._send_raw(f"PONG :{payload}")
return
# RPL_WELCOME (001) — registration complete
if command == "001":
self._registered = True
self._registration_event.set()
if params:
# Server may confirm our nick in the first param
self._current_nick = params[0]
return
# ERR_NICKNAMEINUSE (433) — nick collision during registration
if command == "433":
# Retry with incrementing suffix: hermes_, hermes_1, hermes_2...
base = self.nickname.rstrip("_0123456789")
suffix_match = re.search(r"_(\d+)$", self._current_nick)
if suffix_match:
next_num = int(suffix_match.group(1)) + 1
self._current_nick = f"{base}_{next_num}"
elif self._current_nick == self.nickname:
self._current_nick = self.nickname + "_"
else:
self._current_nick = self.nickname + "_1"
await self._send_raw(f"NICK {self._current_nick}")
return
# PRIVMSG — incoming message (channel or DM)
if command == "PRIVMSG" and len(params) >= 2:
sender_nick = _extract_nick(msg["prefix"])
target = params[0]
text = params[1]
# Ignore our own messages
if sender_nick.lower() == self._current_nick.lower():
return
# CTCP ACTION (/me) — convert to text
if text.startswith("\x01ACTION ") and text.endswith("\x01"):
text = f"* {sender_nick} {text[8:-1]}"
# Ignore other CTCP
if text.startswith("\x01"):
return
# Determine if this is a channel message or DM
is_channel = target.startswith("#") or target.startswith("&")
chat_id = target if is_channel else sender_nick
chat_type = "group" if is_channel else "dm"
# In channels, only respond if addressed (nick: or nick,)
if is_channel:
addressed = False
for prefix in (f"{self._current_nick}:", f"{self._current_nick},",
f"{self._current_nick} "):
if text.lower().startswith(prefix.lower()):
text = text[len(prefix):].strip()
addressed = True
break
if not addressed:
return # Ignore unaddressed channel messages
# Auth check (case-insensitive)
if self._allowed_users_lower and sender_nick.lower() not in self._allowed_users_lower:
logger.debug("IRC: ignoring message from unauthorized user %s", sender_nick)
return
await self._dispatch_message(
text=text,
chat_id=chat_id,
chat_type=chat_type,
user_id=sender_nick,
user_name=sender_nick,
)
# NICK — track our own nick changes
if command == "NICK" and _extract_nick(msg["prefix"]).lower() == self._current_nick.lower():
if params:
self._current_nick = params[0]
async def _dispatch_message(
self,
text: str,
chat_id: str,
chat_type: str,
user_id: str,
user_name: str,
) -> None:
"""Build a MessageEvent and hand it to the base class handler."""
if not self._message_handler:
return
source = self.build_source(
chat_id=chat_id,
chat_name=chat_id,
chat_type=chat_type,
user_id=user_id,
user_name=user_name,
)
event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
message_id=str(int(time.time() * 1000)),
timestamp=__import__("datetime").datetime.now(),
)
await self.handle_message(event)
# ---------------------------------------------------------------------------
# Plugin registration
# ---------------------------------------------------------------------------
def check_requirements() -> bool:
"""Check if IRC is configured.
Only requires the server and channel — no external pip packages needed.
"""
server = os.getenv("IRC_SERVER", "")
channel = os.getenv("IRC_CHANNEL", "")
# Also accept config.yaml-only configuration (no env vars).
# The gateway passes PlatformConfig; we just check env for the
# hermes setup / requirements check path.
return bool(server and channel)
def validate_config(config) -> bool:
"""Validate that the platform config has enough info to connect."""
extra = getattr(config, "extra", {}) or {}
server = os.getenv("IRC_SERVER") or extra.get("server", "")
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
return bool(server and channel)
def interactive_setup() -> None:
"""Interactive `hermes gateway setup` flow for the IRC platform.
Lazy-imports ``hermes_cli.setup`` helpers so the plugin stays importable
in non-CLI contexts (gateway runtime, tests).
"""
from hermes_cli.setup import (
prompt,
prompt_yes_no,
save_env_value,
get_env_value,
print_header,
print_info,
print_warning,
print_success,
)
print_header("IRC")
existing_server = get_env_value("IRC_SERVER")
if existing_server:
print_info(f"IRC: already configured (server: {existing_server})")
if not prompt_yes_no("Reconfigure IRC?", False):
return
print_info("Connect Hermes to an IRC network. Uses Python stdlib — no extra packages needed.")
print_info(" Works with Libera.Chat, OFTC, your own ZNC/InspIRCd, etc.")
print()
server = prompt("IRC server hostname (e.g. irc.libera.chat)", default=existing_server or "")
if not server:
print_warning("Server is required — skipping IRC setup")
return
save_env_value("IRC_SERVER", server.strip())
use_tls = prompt_yes_no("Use TLS (recommended)?", True)
save_env_value("IRC_USE_TLS", "true" if use_tls else "false")
default_port = "6697" if use_tls else "6667"
port = prompt(f"Port (default {default_port})", default=get_env_value("IRC_PORT") or "")
if port:
try:
save_env_value("IRC_PORT", str(int(port)))
except ValueError:
print_warning(f"Invalid port — using default {default_port}")
elif get_env_value("IRC_PORT"):
# User cleared the prompt; drop the override so the default applies.
save_env_value("IRC_PORT", "")
nickname = prompt(
"Bot nickname (e.g. hermes-bot)",
default=get_env_value("IRC_NICKNAME") or "",
)
if not nickname:
print_warning("Nickname is required — skipping IRC setup")
return
save_env_value("IRC_NICKNAME", nickname.strip())
channel = prompt(
"Channel to join (e.g. #hermes — comma-separate for multiple)",
default=get_env_value("IRC_CHANNEL") or "",
)
if not channel:
print_warning("Channel is required — skipping IRC setup")
return
save_env_value("IRC_CHANNEL", channel.strip())
print()
print_info("🔑 Optional authentication")
print_info(" Leave blank to skip.")
if prompt_yes_no("Configure a server password (PASS command)?", False):
server_password = prompt("Server password", password=True)
if server_password:
save_env_value("IRC_SERVER_PASSWORD", server_password)
if prompt_yes_no("Identify with NickServ on connect?", False):
nickserv = prompt("NickServ password", password=True)
if nickserv:
save_env_value("IRC_NICKSERV_PASSWORD", nickserv)
print()
print_info("🔒 Access control: restrict who can message the bot")
print_info(" IRC nicks are not authenticated — anyone can claim any nick.")
print_info(" For public channels, pair with NickServ-only mode on your network")
print_info(" if you want stronger identity guarantees.")
allow_all = prompt_yes_no("Allow all users in the channel to talk to the bot?", False)
if allow_all:
save_env_value("IRC_ALLOW_ALL_USERS", "true")
save_env_value("IRC_ALLOWED_USERS", "")
print_warning("⚠️ Open access — any nick in the channel can command the bot.")
else:
save_env_value("IRC_ALLOW_ALL_USERS", "false")
allowed = prompt(
"Allowed nicks (comma-separated, leave empty to deny everyone)",
default=get_env_value("IRC_ALLOWED_USERS") or "",
)
if allowed:
save_env_value("IRC_ALLOWED_USERS", allowed.replace(" ", ""))
print_success("Allowlist configured")
else:
save_env_value("IRC_ALLOWED_USERS", "")
print_info("No nicks allowed — the bot will ignore all messages until you add nicks.")
print()
print_success("IRC configuration saved to ~/.hermes/.env")
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
def is_connected(config) -> bool:
"""Check whether IRC is configured (env or config.yaml)."""
extra = getattr(config, "extra", {}) or {}
server = os.getenv("IRC_SERVER") or extra.get("server", "")
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
return bool(server and channel)
def _env_enablement() -> dict | None:
"""Seed ``PlatformConfig.extra`` from env vars during gateway config load.
Called by the platform registry's env-enablement hook (landed in the
generic-plugin-interface migration) BEFORE adapter construction, so
``gateway status`` and ``get_connected_platforms()`` reflect env-only
configuration without instantiating the IRC client. Returns ``None``
when IRC isn't minimally configured; the caller skips auto-enabling.
The special ``home_channel`` key in the returned dict is handled by
the core hook — it becomes a proper ``HomeChannel`` dataclass on the
``PlatformConfig`` rather than being merged into ``extra``.
"""
server = os.getenv("IRC_SERVER", "").strip()
channel = os.getenv("IRC_CHANNEL", "").strip()
if not (server and channel):
return None
seed: dict = {
"server": server,
"channel": channel,
}
port = os.getenv("IRC_PORT", "").strip()
if port:
try:
seed["port"] = int(port)
except ValueError:
pass
nickname = os.getenv("IRC_NICKNAME", "").strip()
if nickname:
seed["nickname"] = nickname
use_tls = os.getenv("IRC_USE_TLS", "").strip().lower()
if use_tls:
seed["use_tls"] = use_tls in {"1", "true", "yes"}
# Passwords live in PlatformConfig.extra as well for back-compat with
# existing config.yaml users; env-reads at construct time still win.
if os.getenv("IRC_SERVER_PASSWORD"):
seed["server_password"] = os.getenv("IRC_SERVER_PASSWORD")
if os.getenv("IRC_NICKSERV_PASSWORD"):
seed["nickserv_password"] = os.getenv("IRC_NICKSERV_PASSWORD")
# Optional home-channel (usually the same as IRC_CHANNEL, but can be a
# dedicated reports channel). Defaults to IRC_CHANNEL so cron jobs
# with ``deliver=irc`` have a sensible target without extra config.
home = os.getenv("IRC_HOME_CHANNEL") or channel
if home:
seed["home_channel"] = {
"chat_id": home,
"name": os.getenv("IRC_HOME_CHANNEL_NAME", home),
}
return seed
def _strip_irc_control_chars(text: str) -> str:
"""Strip IRC line terminators and the NUL byte from ``text``.
IRC commands are CRLF-delimited; a bare ``\\r`` or ``\\n`` in user
content lets an attacker inject arbitrary IRC commands (CTCP, JOIN,
KICK). ``\\x00`` is a protocol-illegal byte. Everything else is
valid in PRIVMSG payloads.
"""
return text.replace("\r", " ").replace("\n", " ").replace("\x00", "")
def _is_irc_channel(target: str) -> bool:
return bool(target) and target[0] in "#&+!"
async def _standalone_send(
pconfig,
chat_id: str,
message: str,
*,
thread_id: Optional[str] = None,
media_files: Optional[List[str]] = None,
force_document: bool = False,
) -> Dict[str, Any]:
"""Open an ephemeral IRC connection, send a PRIVMSG, and quit.
Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
runner is not in this process (e.g. ``hermes cron`` running as a
separate process from ``hermes gateway``). Without this hook,
``deliver=irc`` cron jobs fail with ``No live adapter for platform``.
The standalone client uses a distinct nick suffix (``-cron``) so it
does not collide with the long-running gateway adapter that may already
be holding the configured nickname on the same network. When the
target is a channel, the client JOINs it before sending PRIVMSG so
networks with the default ``+n`` (no external messages) channel mode
accept the delivery.
``thread_id`` and ``media_files`` are accepted for signature parity but
are not meaningful on IRC: IRC has no native thread or attachment
primitive.
"""
extra = getattr(pconfig, "extra", {}) or {}
server = os.getenv("IRC_SERVER") or extra.get("server", "")
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
if not server or not channel:
return {"error": "IRC standalone send: IRC_SERVER and IRC_CHANNEL must be configured"}
port_value = os.getenv("IRC_PORT") or extra.get("port", 6697)
try:
port = int(port_value)
except (TypeError, ValueError):
return {"error": f"IRC standalone send: invalid port {port_value!r}"}
nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
use_tls_env = os.getenv("IRC_USE_TLS")
if use_tls_env is not None:
use_tls = use_tls_env.lower() in {"1", "true", "yes"}
else:
use_tls = bool(extra.get("use_tls", True))
server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
# Reject control characters in chat_id to block IRC command injection.
raw_target = chat_id or channel
if any(ch in raw_target for ch in ("\r", "\n", "\x00", " ")):
return {"error": "IRC standalone send: chat_id contains illegal IRC characters"}
target = raw_target
# Distinct nick prevents NICK collision with a live gateway adapter
# that may already be holding the configured nickname. Cap to 24 chars
# so subsequent collision retries do not overflow the 30-char NICKLEN
# most networks enforce.
nick_base = nickname.rstrip("_0123456789-")[:24] or "hermes-bot"
standalone_nick = f"{nick_base}-cron"[:30]
plain = IRCAdapter._strip_markdown(message)
ssl_ctx = ssl.create_default_context() if use_tls else None
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(server, port, ssl=ssl_ctx),
timeout=15.0,
)
except asyncio.CancelledError:
raise
except Exception as e:
return {"error": f"IRC standalone connect failed: {e}"}
async def _raw(line: str) -> None:
writer.write((line + "\r\n").encode("utf-8"))
await writer.drain()
nick_attempts = 0
max_nick_attempts = 5
try:
if server_password:
await _raw(f"PASS {_strip_irc_control_chars(server_password)}")
await _raw(f"NICK {standalone_nick}")
await _raw(f"USER {standalone_nick} 0 * :Hermes Agent (cron)")
loop = asyncio.get_running_loop()
deadline = loop.time() + 15.0
registered = False
while not registered:
remaining = deadline - loop.time()
if remaining <= 0:
return {"error": "IRC standalone send: registration timeout (no RPL_WELCOME)"}
try:
raw_line = await asyncio.wait_for(reader.readuntil(b"\r\n"), timeout=remaining)
except asyncio.TimeoutError:
return {"error": "IRC standalone send: registration timeout (no RPL_WELCOME)"}
except asyncio.IncompleteReadError:
return {"error": "IRC standalone send: server closed connection during registration"}
decoded = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
msg = _parse_irc_message(decoded)
cmd = msg["command"]
if cmd == "PING":
payload = msg["params"][0] if msg["params"] else ""
await _raw(f"PONG :{payload}")
elif cmd == "001":
registered = True
elif cmd in {"432", "433"}:
nick_attempts += 1
if nick_attempts > max_nick_attempts:
return {"error": "IRC standalone send: too many nick collisions"}
# Build the next nick from the stable base, not the
# mutated value, so the suffix stays bounded.
standalone_nick = f"{nick_base}-cron-{nick_attempts}"[:30]
await _raw(f"NICK {standalone_nick}")
elif cmd in {"464", "465"}:
return {"error": f"IRC standalone send: server rejected client ({cmd})"}
if nickserv_password:
await _raw(f"PRIVMSG NickServ :IDENTIFY {_strip_irc_control_chars(nickserv_password)}")
await asyncio.sleep(2)
# JOIN before PRIVMSG. IRC channels with the default ``+n`` mode
# (no external messages: Libera, OFTC, EFnet, IRCNet, undernet)
# silently drop PRIVMSG from non-members. Do not JOIN bare nicks
# (DM target) or server queries.
if _is_irc_channel(target):
await _raw(f"JOIN {target}")
join_deadline = loop.time() + 5.0
joined = False
while not joined:
remaining = join_deadline - loop.time()
if remaining <= 0:
# Timed out waiting for a JOIN ack: proceed anyway, the
# server may still deliver the PRIVMSG depending on mode.
break
try:
raw_line = await asyncio.wait_for(reader.readuntil(b"\r\n"), timeout=remaining)
except (asyncio.TimeoutError, asyncio.IncompleteReadError):
break
decoded = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
jmsg = _parse_irc_message(decoded)
jcmd = jmsg["command"]
if jcmd == "PING":
payload = jmsg["params"][0] if jmsg["params"] else ""
await _raw(f"PONG :{payload}")
elif jcmd in {"366", "JOIN"}:
joined = True
elif jcmd in {"403", "405", "471", "473", "474", "475"}:
return {"error": f"IRC standalone send: JOIN {target} rejected ({jcmd})"}
# Bytes-aware per-line splitting so multi-line plain text never
# exceeds the IRC 510-byte protocol limit. Reuses the same
# algorithm as IRCAdapter._split_message, with control-character
# stripping per line to block CRLF injection from message content.
overhead = len(f"PRIVMSG {target} :".encode("utf-8")) + 2
max_bytes = 510 - overhead
sent_any = False
for paragraph in plain.split("\n"):
paragraph = _strip_irc_control_chars(paragraph).rstrip()
if not paragraph:
continue
while paragraph:
encoded = paragraph.encode("utf-8")
if len(encoded) <= max_bytes:
await _raw(f"PRIVMSG {target} :{paragraph}")
await asyncio.sleep(0.3)
sent_any = True
break
# Binary search for largest prefix that fits within max_bytes
low, high, best = 1, len(paragraph), 0
while low <= high:
mid = (low + high) // 2
if len(paragraph[:mid].encode("utf-8")) <= max_bytes:
best = mid
low = mid + 1
else:
high = mid - 1
split_at = best
space = paragraph.rfind(" ", 0, split_at)
if space > split_at // 3:
split_at = space
await _raw(f"PRIVMSG {target} :{paragraph[:split_at].rstrip()}")
await asyncio.sleep(0.3)
sent_any = True
paragraph = paragraph[split_at:].lstrip()
if not sent_any:
return {"error": "IRC standalone send: empty message after stripping"}
await _raw("QUIT :delivered")
try:
await asyncio.wait_for(reader.read(1024), timeout=2.0)
except asyncio.TimeoutError:
pass
return {"success": True, "message_id": str(int(time.time() * 1000))}
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("IRC standalone send raised", exc_info=True)
return {"error": f"IRC standalone send failed: {e}"}
finally:
try:
writer.close()
await asyncio.wait_for(writer.wait_closed(), timeout=5.0)
except (asyncio.TimeoutError, Exception):
pass
def register(ctx):
"""Plugin entry point: called by the Hermes plugin system."""
ctx.register_platform(
name="irc",
label="IRC",
adapter_factory=lambda cfg: IRCAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=["IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"],
install_hint="No extra packages needed (stdlib only)",
setup_fn=interactive_setup,
# Env-driven auto-configuration: seeds PlatformConfig.extra with
# server/channel/port/tls + home_channel so env-only setups show
# up in gateway status without instantiating the adapter.
env_enablement_fn=_env_enablement,
# Cron home-channel delivery support. IRC_HOME_CHANNEL defaults to
# IRC_CHANNEL (see _env_enablement), so cron jobs with
# deliver=irc route to the joined channel by default.
cron_deliver_env_var="IRC_HOME_CHANNEL",
# Out-of-process cron delivery. Without this hook, deliver=irc
# cron jobs fail with "No live adapter" when cron runs separately
# from the gateway.
standalone_sender_fn=_standalone_send,
# Auth env vars for _is_user_authorized() integration
allowed_users_env="IRC_ALLOWED_USERS",
allow_all_env="IRC_ALLOW_ALL_USERS",
# IRC line limit after protocol overhead
max_message_length=450,
# Display
emoji="💬",
# IRC doesn't have phone numbers to redact
pii_safe=False,
allow_update_command=True,
# LLM guidance
platform_hint=(
"You are chatting via IRC. IRC does not support markdown formatting "
"— use plain text only. Messages are limited to ~450 characters per "
"line (long messages are automatically split). In channels, users "
"address you by prefixing your nick. Keep responses concise and "
"conversational."
),
)
+54
View File
@@ -0,0 +1,54 @@
name: irc-platform
label: IRC
kind: platform
version: 1.0.0
description: >
IRC gateway adapter for Hermes Agent.
Connects to an IRC server and relays messages between an IRC channel
(or DMs) and the Hermes agent. No external dependencies — uses
Python's stdlib asyncio for the IRC protocol.
author: Nous Research
# ``requires_env`` entries are surfaced in ``hermes config`` UI via the
# platform-plugin env var injector in ``hermes_cli/config.py``.
requires_env:
- name: IRC_SERVER
description: "IRC server hostname (e.g. irc.libera.chat)"
prompt: "IRC server"
password: false
- name: IRC_CHANNEL
description: "Channel to join (e.g. #hermes — comma-separate for multiple)"
prompt: "IRC channel"
password: false
- name: IRC_NICKNAME
description: "Bot nickname on IRC (default: hermes-bot)"
prompt: "Bot nickname"
password: false
optional_env:
- name: IRC_PORT
description: "IRC server port (default: 6697 with TLS, 6667 without)"
prompt: "IRC port"
password: false
- name: IRC_USE_TLS
description: "Use TLS for the IRC connection (1/true/yes to enable, default: true on port 6697)"
prompt: "Use TLS? (true/false)"
password: false
- name: IRC_SERVER_PASSWORD
description: "Server password for the IRC PASS command (optional)"
prompt: "Server password (optional)"
password: true
- name: IRC_NICKSERV_PASSWORD
description: "NickServ password for automatic IDENTIFY on connect (optional)"
prompt: "NickServ password (optional)"
password: true
- name: IRC_ALLOWED_USERS
description: "Comma-separated IRC nicks allowed to talk to the bot"
prompt: "Allowed nicks (comma-separated)"
password: false
- name: IRC_ALLOW_ALL_USERS
description: "Allow anyone in the channel to talk to the bot (dev only)"
prompt: "Allow all users? (true/false)"
password: false
- name: IRC_HOME_CHANNEL
description: "Channel for cron / notification delivery (defaults to IRC_CHANNEL)"
prompt: "Home channel (or empty)"
password: false
+3
View File
@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
name: line-platform
label: LINE
kind: platform
version: 1.0.0
description: >
LINE Messaging API gateway adapter for Hermes Agent.
Runs an aiohttp webhook server that receives LINE webhook events
(with HMAC-SHA256 signature verification) and relays messages between
LINE chats (1:1, groups, rooms) and the Hermes agent. Outbound replies
prefer the free reply token and fall back to the metered Push API
when the token has expired or is absent. Slow LLM responses surface a
Template Buttons postback bubble so the user can fetch the answer with
a fresh reply token (free) once it's ready.
author: Hermes Agent contributors
# ``requires_env`` and ``optional_env`` entries are surfaced in the
# ``hermes config`` UI via the platform-plugin env var injector in
# ``hermes_cli/config.py``.
requires_env:
- name: LINE_CHANNEL_ACCESS_TOKEN
description: "LINE channel long-lived access token (LINE Developers Console > Messaging API > Channel access token)"
prompt: "LINE channel access token"
url: "https://developers.line.biz/console/"
password: true
- name: LINE_CHANNEL_SECRET
description: "LINE channel secret (used for HMAC-SHA256 webhook signature verification)"
prompt: "LINE channel secret"
url: "https://developers.line.biz/console/"
password: true
optional_env:
- name: LINE_PORT
description: "Webhook listen port (default: 8646)"
prompt: "Webhook port"
password: false
- name: LINE_HOST
description: "Webhook bind host (default: 0.0.0.0)"
prompt: "Webhook host"
password: false
- name: LINE_PUBLIC_URL
description: "Public HTTPS base URL for serving images/audio/video to LINE (e.g. https://my-tunnel.example.com). Required for media sending when the bind address is not directly reachable."
prompt: "Public HTTPS base URL"
password: false
- name: LINE_ALLOWED_USERS
description: "Comma-separated LINE user IDs allowed to DM the bot (U-prefixed)"
prompt: "Allowed user IDs (comma-separated)"
password: false
- name: LINE_ALLOWED_GROUPS
description: "Comma-separated LINE group IDs the bot will respond in (C-prefixed)"
prompt: "Allowed group IDs (comma-separated)"
password: false
- name: LINE_ALLOWED_ROOMS
description: "Comma-separated LINE room IDs the bot will respond in (R-prefixed)"
prompt: "Allowed room IDs (comma-separated)"
password: false
- name: LINE_ALLOW_ALL_USERS
description: "Allow any LINE user to talk to the bot (dev only — disables allowlist)"
prompt: "Allow all users? (true/false)"
password: false
- name: LINE_HOME_CHANNEL
description: "Default user/group/room ID for cron / notification delivery"
prompt: "Home channel ID (or empty)"
password: false
- name: LINE_SLOW_RESPONSE_THRESHOLD
description: "Seconds before the slow-LLM postback button fires (default: 45; set 0 to disable and always Push-fallback)"
prompt: "Slow response threshold (seconds)"
password: false
+3
View File
@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
name: mattermost-platform
label: Mattermost
kind: platform
version: 1.0.0
description: >
Mattermost gateway adapter for Hermes Agent.
Connects to a self-hosted or cloud Mattermost instance via the v4 REST
API + WebSocket event stream and relays messages between Mattermost
channels/DMs and the Hermes agent. Supports thread-mode replies, native
file uploads, channel-scoped allowlists, and home-channel cron delivery.
author: NousResearch
requires_env:
- name: MATTERMOST_URL
description: "Mattermost server URL (e.g. https://mm.example.com)"
prompt: "Mattermost server URL"
password: false
- name: MATTERMOST_TOKEN
description: "Bot account token or personal-access token"
prompt: "Mattermost bot token"
password: true
optional_env:
- name: MATTERMOST_ALLOWED_USERS
description: "Comma-separated Mattermost user IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: MATTERMOST_ALLOW_ALL_USERS
description: "Allow any Mattermost user to trigger the bot (dev only)"
prompt: "Allow all users? (true/false)"
password: false
- name: MATTERMOST_HOME_CHANNEL
description: "Default channel ID for cron / notification delivery"
prompt: "Home channel ID"
password: false
- name: MATTERMOST_REPLY_MODE
description: "How replies are sent: 'thread' (nested) or 'off' (flat). Default: off."
prompt: "Reply mode (thread|off)"
password: false
- name: MATTERMOST_REQUIRE_MENTION
description: "Require @bot mention in channels (default true). Set false for free-response everywhere."
prompt: "Require @mention? (true/false)"
password: false
- name: MATTERMOST_FREE_RESPONSE_CHANNELS
description: "Comma-separated channel IDs where @mention is not required."
prompt: "Free-response channel IDs (comma-separated)"
password: false
- name: MATTERMOST_ALLOWED_CHANNELS
description: "If set, the bot only responds in these channels (whitelist)."
prompt: "Allowed channel IDs (comma-separated)"
password: false
+3
View File
@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]
+593
View File
@@ -0,0 +1,593 @@
"""ntfy platform adapter (Hermes plugin).
Subscribes to a topic on ntfy.sh or any self-hosted ntfy server via
HTTP streaming (``/json`` endpoint with ``poll=false``) and publishes
replies via HTTP POST. No external SDK — only httpx, which is already
a Hermes dependency.
This adapter ships as a Hermes platform plugin under
``plugins/platforms/ntfy/``. The Hermes plugin loader scans the
directory at startup, calls :func:`register`, and the platform becomes
available to ``gateway/run.py`` and ``tools/send_message_tool`` through
the registry — no edits to core files required.
Configuration in config.yaml::
platforms:
ntfy:
enabled: true
extra:
server: "https://ntfy.sh" # or self-hosted URL
topic: "hermes-in" # subscribe topic (incoming)
publish_topic: "hermes-out" # optional — defaults to topic
token: "..." # optional Bearer / Basic auth token
markdown: true # optional — enable markdown (default: false)
Environment variables (all read at adapter construct time, env wins over
config.yaml ``extra``):
NTFY_TOPIC Topic to subscribe to (required)
NTFY_SERVER_URL Server URL (default: https://ntfy.sh)
NTFY_TOKEN Bearer token or 'user:pass' for Basic auth
NTFY_PUBLISH_TOPIC Reply topic (defaults to NTFY_TOPIC)
NTFY_MARKDOWN "true"/"1"/"yes" enables X-Markdown header
NTFY_ALLOWED_USERS Allowlist (treated by gateway as user IDs;
on ntfy these are topic names)
NTFY_ALLOW_ALL_USERS Allow any topic — dev only
NTFY_HOME_CHANNEL Default topic for cron / notification delivery
NTFY_HOME_CHANNEL_NAME Human label for the home channel
Identity model: ntfy has no native authenticated user identity. The
``title`` field is publisher-controlled and is NOT used for
authorization. Each topic is treated as a single trusted channel —
``user_id`` is fixed to the topic name. Use a private topic protected
by a read token for any real trust boundary.
"""
import asyncio
import json
import logging
import os
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
try:
import httpx
HTTPX_AVAILABLE = True
except ImportError:
HTTPX_AVAILABLE = False
httpx = None # type: ignore[assignment]
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
)
logger = logging.getLogger(__name__)
class _FatalStreamError(Exception):
"""Raised when a stream error is unrecoverable (e.g. 401, 404)."""
DEFAULT_SERVER = "https://ntfy.sh"
MAX_MESSAGE_LENGTH = 4096 # ntfy message body limit
DEDUP_WINDOW_SECONDS = 300
DEDUP_MAX_SIZE = 1000
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
STREAM_TIMEOUT_SECONDS = 90 # ntfy keepalive default is 55s; give margin
_ECHO_TAG = "hermes-agent" # tag added to outgoing messages for echo-loop prevention
def _build_auth_header(token: str) -> Dict[str, str]:
"""Build an ``Authorization`` header from an ntfy token.
Shared by :class:`NtfyAdapter._auth_headers` and :func:`_standalone_send`
so both paths follow the same auth shape and whitespace-stripping rules.
Tokens are stripped of surrounding whitespace — pasted tokens often
carry trailing newlines that would otherwise render the header
malformed (``Authorization: Bearer foo\\n``). ``user:pass`` tokens
become Basic auth; anything else is treated as a Bearer token.
Returns ``{}`` when no token is configured.
"""
if not token:
return {}
token = token.strip()
if not token:
return {}
if ":" in token:
import base64
encoded = base64.b64encode(token.encode()).decode()
return {"Authorization": f"Basic {encoded}"}
return {"Authorization": f"Bearer {token}"}
def _truncate_body(message: str, *, context: str) -> bytes:
"""Apply the ntfy 4096-char limit, logging a warning on truncation.
``context`` is included in the log message so adapter and standalone
truncations can be told apart in logs.
"""
if len(message) > MAX_MESSAGE_LENGTH:
logger.warning(
"%s: truncating message from %d to %d chars (ntfy limit)",
context, len(message), MAX_MESSAGE_LENGTH,
)
return message[:MAX_MESSAGE_LENGTH].encode("utf-8")
def check_requirements() -> bool:
"""Check whether the ntfy adapter is installable and minimally configured.
Reads ``NTFY_TOPIC`` directly to avoid the cost of a full
``load_gateway_config()`` (which also writes to ``os.environ``) on
every pre-flight check.
"""
if not HTTPX_AVAILABLE:
return False
topic = os.getenv("NTFY_TOPIC", "").strip()
return bool(topic)
def validate_config(config) -> bool:
"""Validate that the configured ntfy platform has a topic set."""
extra = getattr(config, "extra", {}) or {}
topic = extra.get("topic") or os.getenv("NTFY_TOPIC", "")
return bool(topic)
def is_connected(config) -> bool:
"""Check whether ntfy is configured (env or config.yaml)."""
extra = getattr(config, "extra", {}) or {}
topic = os.getenv("NTFY_TOPIC") or extra.get("topic", "")
return bool(topic)
class NtfyAdapter(BasePlatformAdapter):
"""ntfy adapter.
Subscribes to a topic via HTTP streaming (``/json`` endpoint) and
publishes replies via HTTP POST. No external SDK — only httpx.
"""
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
def __init__(self, config: PlatformConfig):
platform = Platform("ntfy")
super().__init__(config=config, platform=platform)
extra = config.extra or {}
self._server: str = (
extra.get("server")
or os.getenv("NTFY_SERVER_URL", DEFAULT_SERVER)
).rstrip("/")
self._topic: str = extra.get("topic") or os.getenv("NTFY_TOPIC", "")
self._publish_topic: str = (
extra.get("publish_topic")
or os.getenv("NTFY_PUBLISH_TOPIC", "")
or self._topic
)
self._token: str = extra.get("token") or os.getenv("NTFY_TOKEN", "")
self._stream_task: Optional[asyncio.Task] = None
self._http_client: Optional["httpx.AsyncClient"] = None
# Message deduplication: msg_id -> timestamp
self._seen_messages: Dict[str, float] = {}
# -- Connection lifecycle -----------------------------------------------
async def connect(self) -> bool:
"""Connect to ntfy by starting the streaming subscription task."""
if not HTTPX_AVAILABLE:
logger.warning("[%s] httpx not installed. Run: pip install httpx", self.name)
return False
if not self._topic:
logger.warning("[%s] NTFY_TOPIC not configured", self.name)
return False
try:
self._http_client = httpx.AsyncClient(timeout=None)
self._stream_task = asyncio.create_task(self._run_stream())
self._mark_connected()
logger.info("[%s] Connected — subscribing to %s/%s", self.name, self._server, self._topic)
return True
except Exception as e:
logger.error("[%s] Failed to connect: %s", self.name, e)
return False
async def _run_stream(self) -> None:
"""Subscribe to the ntfy topic with automatic reconnection."""
backoff_idx = 0
stream_start: float = 0.0
url = f"{self._server}/{self._topic}/json"
headers = self._auth_headers()
while self._running:
try:
logger.debug("[%s] Opening stream to %s", self.name, url)
stream_start = time.monotonic()
await self._consume_stream(url, headers)
except asyncio.CancelledError:
return
except _FatalStreamError:
self._running = False
return
except Exception as e:
if not self._running:
return
logger.warning("[%s] Stream error: %s", self.name, e)
if not self._running:
return
# Reset backoff if stream stayed alive for at least 60s
if time.monotonic() - stream_start >= 60.0:
backoff_idx = 0
delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)]
logger.info("[%s] Reconnecting in %ds...", self.name, delay)
await asyncio.sleep(delay)
backoff_idx += 1
async def _consume_stream(self, url: str, headers: Dict[str, str]) -> None:
"""Open an HTTP streaming connection and dispatch events."""
# poll=false keeps a persistent streaming connection alive with keepalive events
params = {"poll": "false"}
async with self._http_client.stream(
"GET",
url,
headers=headers,
params=params,
timeout=httpx.Timeout(connect=15.0, read=STREAM_TIMEOUT_SECONDS, write=15.0, pool=15.0),
) as response:
if response.status_code == 401:
logger.error(
"[%s] Authentication failed (401) — stopping reconnect loop. Check NTFY_TOKEN.",
self.name,
)
self._set_fatal_error(
"ntfy_unauthorized",
"ntfy server rejected auth (401). Check NTFY_TOKEN.",
retryable=False,
)
raise _FatalStreamError("401 Unauthorized")
if response.status_code == 404:
logger.error(
"[%s] Topic not found (404): %s — stopping reconnect loop.",
self.name, self._topic,
)
self._set_fatal_error(
"ntfy_topic_not_found",
f"ntfy topic '{self._topic}' returned 404. Check NTFY_TOPIC.",
retryable=False,
)
raise _FatalStreamError("404 Not Found")
response.raise_for_status()
async for line in response.aiter_lines():
if not self._running:
return
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("event") == "message":
await self._on_message(event)
async def disconnect(self) -> None:
"""Disconnect from ntfy."""
self._running = False
self._mark_disconnected()
if self._stream_task:
self._stream_task.cancel()
try:
await self._stream_task
except asyncio.CancelledError:
pass
self._stream_task = None
if self._http_client:
await self._http_client.aclose()
self._http_client = None
self._seen_messages.clear()
logger.info("[%s] Disconnected", self.name)
# -- Inbound message processing -----------------------------------------
async def _on_message(self, event: Dict[str, Any]) -> None:
"""Process an incoming ntfy message event."""
msg_id = event.get("id") or uuid.uuid4().hex
if self._is_duplicate(msg_id):
logger.debug("[%s] Duplicate message %s, skipping", self.name, msg_id)
return
# Echo-loop prevention: skip messages tagged by this adapter.
tags = event.get("tags") or []
if _ECHO_TAG in tags:
logger.debug("[%s] Skipping own message (echo tag)", self.name)
return
text = (event.get("message") or "").strip()
if not text:
logger.debug("[%s] Empty message body, skipping", self.name)
return
topic = event.get("topic") or self._topic
# ntfy has no native authenticated user identity. The title field is
# publisher-controlled and must NOT be used for authorization — any
# publisher who knows the topic can set title to an allowed username.
# Treat ntfy as a single trusted channel; user_id is fixed to the
# topic name. NTFY_ALLOWED_USERS is only a real trust boundary when
# the topic itself is protected by a read token.
user_id = topic
user_name = topic
source = self.build_source(
chat_id=topic,
chat_name=topic,
chat_type="dm",
user_id=user_id,
user_name=user_name,
)
unix_ts = event.get("time")
try:
timestamp = (
datetime.fromtimestamp(int(unix_ts), tz=timezone.utc)
if unix_ts else datetime.now(tz=timezone.utc)
)
except (ValueError, OSError, TypeError):
timestamp = datetime.now(tz=timezone.utc)
message_event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
message_id=msg_id,
raw_message=event,
timestamp=timestamp,
)
logger.debug("[%s] Message on topic %s: %s", self.name, topic, text[:80])
await self.handle_message(message_event)
# -- Deduplication ------------------------------------------------------
def _is_duplicate(self, msg_id: str) -> bool:
"""Return True if this message ID was already seen within the dedup window."""
now = time.time()
if len(self._seen_messages) > DEDUP_MAX_SIZE:
cutoff = now - DEDUP_WINDOW_SECONDS
self._seen_messages = {k: v for k, v in self._seen_messages.items() if v > cutoff}
if msg_id in self._seen_messages:
return True
self._seen_messages[msg_id] = now
return False
# -- Outbound messaging -------------------------------------------------
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Publish a message to the configured publish topic."""
metadata = metadata or {}
publish_topic = metadata.get("publish_topic") or self._publish_topic or chat_id
if not self._http_client:
return SendResult(success=False, error="HTTP client not initialized")
url = f"{self._server}/{publish_topic}"
markdown_enabled = (self.config.extra or {}).get("markdown", False)
headers = {
**self._auth_headers(),
"Content-Type": "text/plain; charset=utf-8",
"X-Tags": _ECHO_TAG,
}
if markdown_enabled:
headers["X-Markdown"] = "true"
if len(content) > self.MAX_MESSAGE_LENGTH:
logger.warning(
"[%s] Message truncated from %d to %d chars (ntfy limit)",
self.name, len(content), self.MAX_MESSAGE_LENGTH,
)
body = content[:self.MAX_MESSAGE_LENGTH]
try:
resp = await self._http_client.post(
url, content=body.encode("utf-8"), headers=headers, timeout=15.0,
)
if resp.status_code < 300:
try:
data = resp.json()
returned_id = data.get("id") or uuid.uuid4().hex[:12]
except Exception:
returned_id = uuid.uuid4().hex[:12]
return SendResult(success=True, message_id=returned_id)
body_text = resp.text
logger.warning("[%s] Send failed HTTP %d: %s", self.name, resp.status_code, body_text[:200])
return SendResult(success=False, error=f"HTTP {resp.status_code}: {body_text[:200]}")
except httpx.TimeoutException:
return SendResult(success=False, error="Timeout publishing to ntfy")
except Exception as e:
logger.error("[%s] Send error: %s", self.name, e)
return SendResult(success=False, error=str(e))
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""ntfy does not support typing indicators."""
pass
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Return basic info about an ntfy topic."""
return {"name": chat_id, "type": "dm"}
# -- Helpers ------------------------------------------------------------
def _auth_headers(self) -> Dict[str, str]:
"""Build Authorization header if a token is configured."""
return _build_auth_header(self._token)
# ---------------------------------------------------------------------------
# Plugin registration
# ---------------------------------------------------------------------------
def _env_enablement() -> dict | None:
"""Seed ``PlatformConfig.extra`` from env vars during gateway config load.
Called by the platform registry's env-enablement hook BEFORE adapter
construction, so ``gateway status`` and ``get_connected_platforms()``
reflect env-only configuration without instantiating the HTTP client.
Returns ``None`` when ntfy isn't minimally configured; the caller skips
auto-enabling.
The special ``home_channel`` key in the returned dict is handled by the
core hook — it becomes a proper ``HomeChannel`` dataclass on the
``PlatformConfig`` rather than being merged into ``extra``.
"""
topic = os.getenv("NTFY_TOPIC", "").strip()
if not topic:
return None
seed: dict = {
"topic": topic,
"server": os.getenv("NTFY_SERVER_URL", DEFAULT_SERVER).rstrip("/"),
}
publish_topic = os.getenv("NTFY_PUBLISH_TOPIC", "").strip()
if publish_topic:
seed["publish_topic"] = publish_topic
token = os.getenv("NTFY_TOKEN", "").strip()
if token:
seed["token"] = token
markdown = os.getenv("NTFY_MARKDOWN", "").strip().lower()
if markdown:
seed["markdown"] = markdown in ("1", "true", "yes")
home = os.getenv("NTFY_HOME_CHANNEL", "").strip() or topic
if home:
seed["home_channel"] = {
"chat_id": home,
"name": os.getenv("NTFY_HOME_CHANNEL_NAME", home),
}
return seed
async def _standalone_send(
pconfig,
chat_id: str,
message: str,
*,
thread_id: Optional[str] = None,
media_files: Optional[List[str]] = None,
force_document: bool = False,
) -> Dict[str, Any]:
"""Out-of-process publish for cron / send_message_tool fallbacks.
Used by ``tools/send_message_tool._send_via_adapter`` and the cron
scheduler when the gateway runner is not in this process (e.g.
``hermes cron`` running standalone). Without this hook,
``deliver=ntfy`` cron jobs fail with ``No live adapter for platform``.
``thread_id`` and ``media_files`` are accepted for signature parity
only — ntfy has no thread or attachment primitive. Markdown is
honored if ``NTFY_MARKDOWN`` is set OR ``pconfig.extra["markdown"]``
is True.
"""
if not HTTPX_AVAILABLE:
return {"error": "ntfy standalone send: httpx not installed"}
extra = getattr(pconfig, "extra", {}) or {}
server = (
extra.get("server")
or os.getenv("NTFY_SERVER_URL", DEFAULT_SERVER)
).rstrip("/")
publish_topic = (
chat_id
or extra.get("publish_topic")
or os.getenv("NTFY_PUBLISH_TOPIC", "").strip()
or extra.get("topic")
or os.getenv("NTFY_TOPIC", "").strip()
)
if not publish_topic:
return {"error": "ntfy standalone send: NTFY_TOPIC not configured"}
token = extra.get("token") or os.getenv("NTFY_TOKEN", "")
markdown_env = os.getenv("NTFY_MARKDOWN", "").strip().lower()
markdown_enabled = bool(extra.get("markdown")) or markdown_env in ("1", "true", "yes")
headers = {"Content-Type": "text/plain; charset=utf-8", "X-Tags": _ECHO_TAG, **_build_auth_header(token)}
if markdown_enabled:
headers["X-Markdown"] = "true"
body = _truncate_body(message, context="ntfy standalone")
url = f"{server}/{publish_topic}"
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(url, content=body, headers=headers)
if resp.status_code >= 300:
return {"error": f"ntfy HTTP {resp.status_code}: {resp.text[:200]}"}
try:
data = resp.json()
msg_id = data.get("id") or uuid.uuid4().hex[:12]
except Exception:
msg_id = uuid.uuid4().hex[:12]
return {"success": True, "platform": "ntfy", "chat_id": publish_topic, "message_id": msg_id}
except Exception as e:
return {"error": f"ntfy standalone send failed: {e}"}
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system at startup."""
ctx.register_platform(
name="ntfy",
label="ntfy",
adapter_factory=lambda cfg: NtfyAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=["NTFY_TOPIC"],
install_hint="pip install httpx # already a Hermes dependency",
# Env-driven auto-configuration: seeds PlatformConfig.extra so
# env-only setups show up in `hermes gateway status` without
# instantiating the HTTP client.
env_enablement_fn=_env_enablement,
# Cron home-channel delivery support — `deliver=ntfy` cron jobs
# route to NTFY_HOME_CHANNEL when set.
cron_deliver_env_var="NTFY_HOME_CHANNEL",
# Out-of-process cron delivery. Without this hook, deliver=ntfy
# cron jobs fail with "No live adapter" when cron runs separately
# from the gateway.
standalone_sender_fn=_standalone_send,
# Auth env vars for _is_user_authorized() integration.
allowed_users_env="NTFY_ALLOWED_USERS",
allow_all_env="NTFY_ALLOW_ALL_USERS",
max_message_length=MAX_MESSAGE_LENGTH,
emoji="🔔",
# ntfy publishers have no persistent identity — topic names are
# the only identifier, no phone numbers / emails to redact.
pii_safe=True,
allow_update_command=True,
platform_hint=(
"You are communicating via ntfy push notifications. "
"Use plain text by default — ntfy supports optional markdown "
"(set markdown: true in config or NTFY_MARKDOWN=true). "
"Keep responses concise; ntfy is a push notification service "
"with a 4096-character per-message limit."
),
)
+56
View File
@@ -0,0 +1,56 @@
name: ntfy-platform
label: ntfy
kind: platform
version: 1.0.0
description: >
ntfy push-notification gateway adapter for Hermes Agent.
Subscribes to a topic on ntfy.sh or any self-hosted ntfy server via
HTTP streaming, and publishes replies via HTTP POST. Lightweight —
no external SDK, only httpx (already a Hermes dependency).
ntfy has no native user-identity primitive; the adapter treats each
topic as a single trusted channel and never derives user identity
from publisher-controlled fields. Use a private topic + read token
for any real trust boundary.
author: sprmn24
# ``requires_env`` and ``optional_env`` entries are surfaced in the
# ``hermes config`` UI via the platform-plugin env var injector in
# ``hermes_cli/config.py``.
requires_env:
- name: NTFY_TOPIC
description: "Topic name to subscribe to (e.g. hermes-in)"
prompt: "ntfy subscribe topic"
password: false
optional_env:
- name: NTFY_SERVER_URL
description: "ntfy server URL (default: https://ntfy.sh)"
prompt: "ntfy server URL"
password: false
- name: NTFY_TOKEN
description: "Bearer token or 'user:pass' for Basic auth (optional)"
prompt: "ntfy auth token (or empty)"
password: true
- name: NTFY_PUBLISH_TOPIC
description: "Topic to publish replies to (defaults to NTFY_TOPIC)"
prompt: "ntfy publish topic (or empty)"
password: false
- name: NTFY_MARKDOWN
description: "Send replies with X-Markdown: true header (true/false, default: false)"
prompt: "Enable markdown formatting? (true/false)"
password: false
- name: NTFY_ALLOWED_USERS
description: "Comma-separated topic names allowed (allowlist)"
prompt: "Allowed topic names (comma-separated)"
password: false
- name: NTFY_ALLOW_ALL_USERS
description: "Allow any topic to talk to the bot (dev only — disables allowlist)"
prompt: "Allow all topics? (true/false)"
password: false
- name: NTFY_HOME_CHANNEL
description: "Default topic for cron / notification delivery"
prompt: "Home channel topic (or empty)"
password: false
- name: NTFY_HOME_CHANNEL_NAME
description: "Human label for the home channel (defaults to the topic name)"
prompt: "Home channel display name (or empty)"
password: false
+3
View File
@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]
+746
View File
@@ -0,0 +1,746 @@
"""SimpleX Chat platform adapter (Hermes plugin).
Connects to a simplex-chat daemon running in WebSocket mode.
Inbound messages arrive via a persistent WebSocket connection.
Outbound messages use the same WebSocket with JSON commands.
This adapter ships as a Hermes platform plugin under
``plugins/platforms/simplex/``. The Hermes plugin loader scans the
directory at startup, calls ``register(ctx)``, and the platform
becomes available to ``gateway/run.py`` and ``tools/send_message_tool``
through the registry — no edits to core files are required.
SimpleX chat daemon setup:
simplex-chat -p 5225 # start daemon on port 5225
# or via Docker:
# docker run -p 5225:5225 simplexchat/simplex-chat-cli -p 5225
Required environment variables:
SIMPLEX_WS_URL WebSocket URL of the daemon
(default: ws://127.0.0.1:5225)
Optional environment variables:
SIMPLEX_ALLOWED_USERS Comma-separated contact IDs (allowlist)
SIMPLEX_ALLOW_ALL_USERS Set 'true' to allow all contacts
SIMPLEX_HOME_CHANNEL Default contact/group ID for cron delivery
SIMPLEX_HOME_CHANNEL_NAME Human label for the home channel
The ``websockets`` Python package is imported lazily — the plugin is
discoverable and `hermes setup` can describe it even when websockets is
not installed. ``check_requirements()`` returns False until the package
is present, so the gateway will not attempt to instantiate the adapter.
"""
import asyncio
import json
import logging
import os
import random
import time
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
# Lazy import: BasePlatformAdapter and friends live in the main repo.
# Imported at module top because they're stdlib-only inside Hermes — no
# external dependency that would block the plugin from loading.
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
cache_image_from_bytes,
cache_audio_from_bytes,
cache_document_from_bytes,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MAX_MESSAGE_LENGTH = 16_000 # SimpleX has no hard limit; keep chunking sane
TYPING_INTERVAL = 10.0
WS_RETRY_DELAY_INITIAL = 2.0
WS_RETRY_DELAY_MAX = 60.0
HEALTH_CHECK_INTERVAL = 30.0
HEALTH_CHECK_STALE_THRESHOLD = 120.0
# Correlation ID prefix for requests we send so we can ignore our own echoes.
_CORR_PREFIX = "hermes-"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_comma_list(value: str) -> List[str]:
"""Split a comma-separated string into a stripped list."""
return [v.strip() for v in value.split(",") if v.strip()]
def _guess_extension(data: bytes) -> str:
"""Guess file extension from magic bytes."""
if data[:4] == b"\x89PNG":
return ".png"
if data[:2] == b"\xff\xd8":
return ".jpg"
if data[:4] == b"GIF8":
return ".gif"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp"
if data[:4] == b"%PDF":
return ".pdf"
if len(data) >= 8 and data[4:8] == b"ftyp":
return ".mp4"
if data[:4] == b"OggS":
return ".ogg"
if len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0:
return ".mp3"
return ".bin"
def _is_image_ext(ext: str) -> bool:
return ext.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}
def _is_audio_ext(ext: str) -> bool:
return ext.lower() in {".mp3", ".wav", ".ogg", ".m4a", ".aac"}
# ---------------------------------------------------------------------------
# SimpleX Adapter
# ---------------------------------------------------------------------------
class SimplexAdapter(BasePlatformAdapter):
"""SimpleX Chat adapter using the simplex-chat daemon WebSocket API.
Instantiated by the ``adapter_factory`` passed to
``ctx.register_platform()`` in :func:`register`.
"""
def __init__(self, config: PlatformConfig, **kwargs):
platform = Platform("simplex")
super().__init__(config=config, platform=platform)
extra = getattr(config, "extra", {}) or {}
self.ws_url = extra.get("ws_url", "ws://127.0.0.1:5225").rstrip("/")
# Running state
self._ws = None # websockets connection
self._ws_task: Optional[asyncio.Task] = None
self._health_task: Optional[asyncio.Task] = None
self._typing_tasks: Dict[str, asyncio.Task] = {}
self._running = False
self._last_ws_activity = 0.0
# Track sent correlation IDs to filter echoes
self._pending_corr_ids: set = set()
self._max_pending_corr = 200
logger.info("SimpleX adapter initialized: url=%s", self.ws_url)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def connect(self) -> bool:
"""Connect to the simplex-chat daemon and start the WebSocket listener."""
try:
import websockets # noqa: F401
except ImportError:
logger.error(
"SimpleX: 'websockets' package not installed. "
"Run: pip install websockets"
)
return False
if not self.ws_url:
logger.error("SimpleX: SIMPLEX_WS_URL is required")
return False
# Quick connectivity check — try to open and immediately close
try:
import websockets as _wsclient
async with _wsclient.connect(self.ws_url, open_timeout=10):
pass
except Exception as e:
logger.error("SimpleX: cannot reach daemon at %s: %s", self.ws_url, e)
return False
self._running = True
self._last_ws_activity = time.time()
self._ws_task = asyncio.create_task(self._ws_listener())
self._health_task = asyncio.create_task(self._health_monitor())
logger.info("SimpleX: connected to %s", self.ws_url)
return True
async def disconnect(self) -> None:
"""Stop WebSocket listener and clean up."""
self._running = False
if self._ws_task:
self._ws_task.cancel()
try:
await self._ws_task
except asyncio.CancelledError:
pass
if self._health_task:
self._health_task.cancel()
try:
await self._health_task
except asyncio.CancelledError:
pass
for task in self._typing_tasks.values():
task.cancel()
self._typing_tasks.clear()
if self._ws:
try:
await self._ws.close()
except Exception:
pass
self._ws = None
logger.info("SimpleX: disconnected")
# ------------------------------------------------------------------
# WebSocket listener
# ------------------------------------------------------------------
async def _ws_listener(self) -> None:
"""Maintain a persistent WebSocket connection to the daemon."""
import websockets as _wsclient
import websockets as _wsexc
backoff = WS_RETRY_DELAY_INITIAL
while self._running:
try:
logger.debug("SimpleX WS: connecting to %s", self.ws_url)
async with _wsclient.connect(
self.ws_url,
ping_interval=20,
ping_timeout=20,
) as ws:
self._ws = ws
backoff = WS_RETRY_DELAY_INITIAL
self._last_ws_activity = time.time()
logger.info("SimpleX WS: connected")
async for raw in ws:
if not self._running:
break
self._last_ws_activity = time.time()
try:
msg = json.loads(raw)
await self._handle_event(msg)
except json.JSONDecodeError:
logger.debug("SimpleX WS: invalid JSON: %.100s", raw)
except Exception:
logger.exception("SimpleX WS: error handling event")
except asyncio.CancelledError:
break
except _wsexc.WebSocketException as e:
if self._running:
logger.warning(
"SimpleX WS: error: %s (reconnecting in %.0fs)", e, backoff
)
except Exception as e:
if self._running:
logger.warning(
"SimpleX WS: unexpected error: %s (reconnecting in %.0fs)",
e, backoff,
)
finally:
self._ws = None
if self._running:
jitter = backoff * 0.2 * random.random()
await asyncio.sleep(backoff + jitter)
backoff = min(backoff * 2, WS_RETRY_DELAY_MAX)
# ------------------------------------------------------------------
# Health monitor
# ------------------------------------------------------------------
async def _health_monitor(self) -> None:
"""Force reconnect if the WebSocket has been idle too long."""
while self._running:
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
if not self._running:
break
elapsed = time.time() - self._last_ws_activity
if elapsed > HEALTH_CHECK_STALE_THRESHOLD:
logger.warning(
"SimpleX: WS idle for %.0fs, forcing reconnect", elapsed
)
self._last_ws_activity = time.time()
if self._ws:
try:
await self._ws.close()
except Exception:
pass
# ------------------------------------------------------------------
# Inbound event handling
# ------------------------------------------------------------------
async def _handle_event(self, event: dict) -> None:
"""Dispatch a daemon event to the appropriate handler."""
resp_type = event.get("type") or event.get("resp", {}).get("type", "")
# Filter responses to our own commands (echoes)
corr_id = event.get("corrId", "")
if corr_id and corr_id.startswith(_CORR_PREFIX):
self._pending_corr_ids.discard(corr_id)
return
if resp_type == "newChatItem":
await self._handle_new_chat_item(event)
elif resp_type == "newChatItems":
# Batch variant — process each item
items = event.get("chatItems") or []
for item_wrapper in items:
await self._handle_new_chat_item(item_wrapper)
# Ignore all other event types (delivery receipts, contact updates, etc.)
async def _handle_new_chat_item(self, wrapper: dict) -> None:
"""Process a single newChatItem event into a MessageEvent."""
# The daemon wraps the chat item differently depending on version;
# normalise both layouts.
chat_info = wrapper.get("chatInfo") or wrapper.get("chat") or {}
chat_item = wrapper.get("chatItem") or wrapper.get("item") or {}
# Only process messages (not calls, deleted items, etc.)
item_content = chat_item.get("content") or {}
msg_content = item_content.get("msgContent") or {}
if not msg_content:
return
# Filter out messages sent by us (direction == "snd")
meta = chat_item.get("meta") or {}
direction = (meta.get("itemStatus") or {}).get("type", "")
if direction in {"sndSent", "sndSentDirect", "sndSentViaProxy", "sndNew"}:
return
# Determine chat type and IDs
chat_type_raw = chat_info.get("type", "")
is_group = chat_type_raw in {"group", "groupInfo"}
if is_group:
group_info = chat_info.get("groupInfo") or chat_info.get("group") or {}
group_id = str(group_info.get("groupId") or group_info.get("id") or "")
group_name = group_info.get("displayName") or group_info.get("groupProfile", {}).get("displayName", "")
chat_id = f"group:{group_id}" if group_id else ""
chat_name = group_name
else:
contact_info = chat_info.get("contact") or {}
contact_id = str(contact_info.get("contactId") or contact_info.get("id") or "")
contact_name = (
contact_info.get("displayName")
or contact_info.get("localDisplayName")
or contact_id
)
chat_id = contact_id
chat_name = contact_name
if not chat_id:
logger.debug("SimpleX: ignoring event with no chat_id")
return
# Sender — for groups the message includes a chatItemMember sub-object
member = chat_item.get("chatItemMember") or {}
if is_group and member:
sender_id = str(member.get("memberId") or member.get("id") or chat_id)
sender_name = (
member.get("displayName")
or member.get("localDisplayName")
or sender_id
)
else:
sender_id = chat_id
sender_name = chat_name
# Extract text
text = msg_content.get("text") or ""
# Media attachments
media_urls: List[str] = []
media_types: List[str] = []
file_info = chat_item.get("file") or {}
if file_info and file_info.get("fileStatus") not in {"cancelled", "error"}:
file_id = file_info.get("fileId")
file_name = file_info.get("fileName", "file")
if file_id:
try:
cached = await self._fetch_file(file_id, file_name)
if cached:
ext = cached.rsplit(".", 1)[-1]
if _is_image_ext("." + ext):
media_types.append("image/" + ext.replace("jpg", "jpeg"))
elif _is_audio_ext("." + ext):
media_types.append("audio/" + ext)
else:
media_types.append("application/octet-stream")
media_urls.append(cached)
except Exception:
logger.exception("SimpleX: failed to fetch file %s", file_id)
# Timestamp
ts_str = meta.get("itemTs") or meta.get("createdAt") or ""
try:
timestamp = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
except (ValueError, AttributeError):
timestamp = datetime.now(tz=timezone.utc)
# Build source
source = self.build_source(
chat_id=chat_id,
chat_name=chat_name,
chat_type="group" if is_group else "dm",
user_id=sender_id,
user_name=sender_name,
)
# Message type
msg_type = MessageType.TEXT
if media_types:
if any(mt.startswith("audio/") for mt in media_types):
msg_type = MessageType.VOICE
elif any(mt.startswith("image/") for mt in media_types):
msg_type = MessageType.PHOTO
event_obj = MessageEvent(
source=source,
text=text,
message_type=msg_type,
media_urls=media_urls,
media_types=media_types,
timestamp=timestamp,
raw_message=wrapper,
)
await self.handle_message(event_obj)
async def _fetch_file(self, file_id: Any, file_name: str) -> Optional[str]:
"""Ask the daemon to receive and return a file attachment."""
# simplex-chat exposes `/api/v1/files/{fileId}` on an HTTP port
# when started with --http-port. However, the canonical WebSocket API
# does not have a direct binary download command; files are stored on
# the local filesystem after the daemon accepts them.
#
# We request acceptance first, then read from the daemon's local path.
corr_id = self._make_corr_id()
cmd = {
"corrId": corr_id,
"cmd": f"/freceive {file_id}",
}
await self._send_ws(cmd)
# The daemon will emit a chatItemUpdated event when the file lands;
# for simplicity we just wait briefly and rely on the daemon's default path.
await asyncio.sleep(2)
# simplex-chat stores received files in ~/Downloads or a configured path.
# We try common locations.
for search_dir in (
os.path.expanduser("~/Downloads"),
os.path.expanduser("~/.simplex/files"),
"/tmp/simplex_files",
):
candidate = os.path.join(search_dir, file_name)
if os.path.exists(candidate):
with open(candidate, "rb") as f:
data = f.read()
ext = _guess_extension(data)
if _is_image_ext(ext):
return cache_image_from_bytes(data, ext)
elif _is_audio_ext(ext):
return cache_audio_from_bytes(data, ext)
else:
return cache_document_from_bytes(data, file_name)
return None
# ------------------------------------------------------------------
# Outbound messages
# ------------------------------------------------------------------
def _make_corr_id(self) -> str:
"""Generate a unique correlation ID for a request."""
corr_id = f"{_CORR_PREFIX}{int(time.time() * 1000)}-{random.randint(0, 9999)}"
self._pending_corr_ids.add(corr_id)
if len(self._pending_corr_ids) > self._max_pending_corr:
# Trim oldest — sets are unordered so just clear the oldest half
to_remove = list(self._pending_corr_ids)[:self._max_pending_corr // 2]
self._pending_corr_ids -= set(to_remove)
return corr_id
async def _send_ws(self, payload: dict) -> None:
"""Send a JSON payload over the WebSocket, queuing if not yet connected."""
import websockets as _wsexc
ws = self._ws
if not ws:
logger.debug("SimpleX: WS not connected, dropping outbound command")
return
try:
await ws.send(json.dumps(payload))
except _wsexc.ConnectionClosed:
logger.warning("SimpleX: WS closed while sending")
except Exception as e:
logger.warning("SimpleX: WS send error: %s", e)
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a text message to a contact or group."""
corr_id = self._make_corr_id()
if chat_id.startswith("group:"):
group_id = chat_id[6:]
cmd_str = f"#[{group_id}] {content}"
else:
cmd_str = f"@[{chat_id}] {content}"
payload = {
"corrId": corr_id,
"cmd": cmd_str,
}
await self._send_ws(payload)
return SendResult(success=True)
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""SimpleX does not expose a typing indicator API — no-op."""
pass
async def send_image(
self,
chat_id: str,
image_url: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an image (URL) as a message with optional caption.
SimpleX has no native ``send_image`` over the WebSocket API — file
attachments require the daemon's filesystem-backed flow which is
not driven from this adapter. Fall back to a plain text message
containing the URL and caption.
"""
text = f"{caption}\n{image_url}".strip() if caption else image_url
return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
async def get_chat_info(self, chat_id: str) -> dict:
"""Return basic chat info."""
if chat_id.startswith("group:"):
return {"chat_id": chat_id, "type": "group", "name": chat_id[6:]}
return {"chat_id": chat_id, "type": "dm", "name": chat_id}
# ---------------------------------------------------------------------------
# Plugin entry-point hooks
# ---------------------------------------------------------------------------
def check_requirements() -> bool:
"""Plugin gate: require SIMPLEX_WS_URL AND the websockets package.
Returning False keeps the platform out of ``get_connected_platforms()``
so the gateway never instantiates the adapter when the dependency is
missing or no daemon URL is configured.
"""
if not os.getenv("SIMPLEX_WS_URL"):
return False
try:
import websockets # noqa: F401
except ImportError:
return False
return True
def validate_config(config) -> bool:
"""Validate that the platform config has enough info to connect."""
extra = getattr(config, "extra", {}) or {}
ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "")
return bool(ws_url)
def is_connected(config) -> bool:
"""Check whether SimpleX is configured (env or config.yaml)."""
extra = getattr(config, "extra", {}) or {}
ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "")
return bool(ws_url)
def _env_enablement() -> dict | None:
"""Seed ``PlatformConfig.extra`` from env vars during gateway config load.
Called by the platform registry's env-enablement hook BEFORE adapter
construction, so ``gateway status`` and ``get_connected_platforms()``
reflect env-only configuration without instantiating the WebSocket
client. Returns ``None`` when SimpleX isn't minimally configured.
The special ``home_channel`` key in the returned dict is handled by
the core hook — it becomes a proper ``HomeChannel`` dataclass on the
``PlatformConfig`` rather than being merged into ``extra``.
"""
ws_url = os.getenv("SIMPLEX_WS_URL", "").strip()
if not ws_url:
return None
seed: dict = {"ws_url": ws_url}
home = os.getenv("SIMPLEX_HOME_CHANNEL", "").strip()
if home:
seed["home_channel"] = {
"chat_id": home,
"name": os.getenv("SIMPLEX_HOME_CHANNEL_NAME", "").strip() or home,
}
return seed
async def _standalone_send(
pconfig,
chat_id: str,
message: str,
*,
thread_id: Optional[str] = None,
media_files: Optional[List[str]] = None,
force_document: bool = False,
) -> Dict[str, Any]:
"""Open an ephemeral WebSocket to the daemon, send, and close.
Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
runner is not in this process (e.g. ``hermes cron`` running as a
separate process from ``hermes gateway``). Without this hook,
``deliver=simplex`` cron jobs fail with "No live adapter for platform".
``thread_id`` and ``force_document`` are accepted for signature parity
with other plugins but are not meaningful here. ``media_files`` is
accepted but only the text body is delivered — SimpleX requires the
daemon's filesystem-backed file flow which an ephemeral connection
cannot drive safely.
"""
try:
import websockets as _wsclient
except ImportError:
return {"error": "websockets not installed. Run: pip install websockets"}
extra = getattr(pconfig, "extra", {}) or {}
ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "ws://127.0.0.1:5225")
if not ws_url:
return {"error": "SimpleX standalone send: SIMPLEX_WS_URL is required"}
try:
if chat_id.startswith("group:"):
group_id = chat_id[6:]
cmd_str = f"#[{group_id}] {message}"
else:
cmd_str = f"@[{chat_id}] {message}"
payload = {
"corrId": f"hermes-snd-{int(time.time() * 1000)}",
"cmd": cmd_str,
}
async with _wsclient.connect(ws_url, open_timeout=10, close_timeout=5) as ws:
await ws.send(json.dumps(payload))
# Give the daemon a moment to process the command before closing.
await asyncio.sleep(0.5)
return {"success": True, "platform": "simplex", "chat_id": chat_id}
except Exception as e:
return {"error": f"SimpleX send failed: {e}"}
def interactive_setup() -> None:
"""Minimal stdin wizard for ``hermes setup gateway`` → SimpleX.
Prompts for the WebSocket URL and the optional allowlist / home channel.
Writes to ``~/.hermes/.env`` via ``hermes_cli.config``.
"""
print()
print("SimpleX Chat setup")
print("------------------")
print("Requirements:")
print(" 1. simplex-chat daemon running (e.g. `simplex-chat -p 5225`).")
print(" 2. Python package `websockets` installed (`pip install websockets`).")
print()
try:
from hermes_cli.config import get_env_value, save_env_value
except ImportError:
print("hermes_cli.config not available; set SIMPLEX_* vars manually in ~/.hermes/.env")
return
def _prompt(var: str, prompt: str, *, secret: bool = False) -> None:
existing = get_env_value(var) if callable(get_env_value) else None
suffix = " [keep current]" if existing else ""
try:
if secret:
from hermes_cli.secret_prompt import masked_secret_prompt
value = masked_secret_prompt(f"{prompt}{suffix}: ")
else:
value = input(f"{prompt}{suffix}: ").strip()
except (EOFError, KeyboardInterrupt):
print()
return
if value:
save_env_value(var, value)
_prompt("SIMPLEX_WS_URL", "Daemon WebSocket URL (default ws://127.0.0.1:5225)")
_prompt("SIMPLEX_ALLOWED_USERS", "Allowed contact IDs (comma-separated; blank=skip)")
_prompt("SIMPLEX_HOME_CHANNEL", "Home channel contact/group ID (or empty)")
print("Done. Make sure the simplex-chat daemon is running before starting the gateway.")
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system at startup."""
ctx.register_platform(
name="simplex",
label="SimpleX Chat",
adapter_factory=lambda cfg: SimplexAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=["SIMPLEX_WS_URL"],
install_hint="pip install websockets # SimpleX adapter requires the websockets package",
setup_fn=interactive_setup,
# Env-driven auto-configuration: seeds PlatformConfig.extra so
# env-only setups show up in `hermes gateway status` without
# instantiating the adapter.
env_enablement_fn=_env_enablement,
# Cron home-channel delivery support — `deliver=simplex` cron jobs
# route to SIMPLEX_HOME_CHANNEL when set.
cron_deliver_env_var="SIMPLEX_HOME_CHANNEL",
# Out-of-process cron delivery. Without this hook, deliver=simplex
# cron jobs fail with "No live adapter" when cron runs separately
# from the gateway.
standalone_sender_fn=_standalone_send,
# Auth env vars for _is_user_authorized() integration
allowed_users_env="SIMPLEX_ALLOWED_USERS",
allow_all_env="SIMPLEX_ALLOW_ALL_USERS",
# SimpleX has no hard line length; we still chunk for sanity.
max_message_length=MAX_MESSAGE_LENGTH,
# Display
emoji="🔒",
# SimpleX uses opaque contact IDs only — no phone numbers or
# email addresses to redact.
pii_safe=True,
allow_update_command=True,
# LLM guidance
platform_hint=(
"You are chatting via SimpleX Chat, a private decentralised "
"messenger. Contacts are identified by opaque internal IDs, "
"not phone numbers or usernames. SimpleX supports standard "
"markdown formatting. There is no typing indicator and no "
"hard message length limit, but keep responses conversational."
),
)
+37
View File
@@ -0,0 +1,37 @@
name: simplex-platform
label: SimpleX Chat
kind: platform
version: 1.0.0
description: >
SimpleX Chat gateway adapter for Hermes Agent.
Connects to a local simplex-chat daemon via WebSocket and relays
messages between SimpleX contacts/groups and the Hermes agent.
SimpleX is decentralised and assigns no persistent user IDs —
every contact is an opaque internal ID generated at connection
time, making it one of the most private messengers available.
author: Mibayy
# ``requires_env`` and ``optional_env`` entries are surfaced in the
# ``hermes config`` UI via the platform-plugin env var injector in
# ``hermes_cli/config.py``.
requires_env:
- name: SIMPLEX_WS_URL
description: "WebSocket URL of the simplex-chat daemon (e.g. ws://127.0.0.1:5225)"
prompt: "SimpleX daemon WebSocket URL"
password: false
optional_env:
- name: SIMPLEX_ALLOWED_USERS
description: "Comma-separated SimpleX contact IDs allowed to talk to the bot"
prompt: "Allowed contact IDs (comma-separated)"
password: false
- name: SIMPLEX_ALLOW_ALL_USERS
description: "Allow any contact to talk to the bot (dev only — disables allowlist)"
prompt: "Allow all contacts? (true/false)"
password: false
- name: SIMPLEX_HOME_CHANNEL
description: "Default contact/group ID for cron / notification delivery"
prompt: "Home channel contact/group ID (or empty)"
password: false
- name: SIMPLEX_HOME_CHANNEL_NAME
description: "Human label for the home channel (defaults to the ID)"
prompt: "Home channel display name (or empty)"
password: false
+3
View File
@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
name: teams-platform
label: Microsoft Teams
kind: platform
version: 1.0.0
description: >
Microsoft Teams gateway adapter for Hermes Agent.
Connects to Microsoft Teams via the Bot Framework and relays messages
between Teams chats (personal DMs, group chats, channel posts) and
the Hermes agent. Supports Adaptive Card approval prompts.
author: Aamir Jawaid
# ``requires_env`` entries are surfaced in ``hermes config`` UI via the
# platform-plugin env var injector in ``hermes_cli/config.py``.
requires_env:
- name: TEAMS_CLIENT_ID
description: "Azure AD application (Bot Framework) client ID"
prompt: "Teams / Azure AD client ID"
url: "https://portal.azure.com/"
password: false
- name: TEAMS_CLIENT_SECRET
description: "Azure AD application client secret"
prompt: "Teams / Azure AD client secret"
url: "https://portal.azure.com/"
password: true
- name: TEAMS_TENANT_ID
description: "Azure AD tenant ID hosting the bot application"
prompt: "Teams / Azure AD tenant ID"
password: false
optional_env:
- name: TEAMS_PORT
description: "Webhook listen port (Bot Framework default: 3978)"
prompt: "Webhook port"
password: false
- name: TEAMS_ALLOWED_USERS
description: "Comma-separated Teams user IDs / UPNs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: TEAMS_ALLOW_ALL_USERS
description: "Allow any Teams user to trigger the bot (dev only)"
prompt: "Allow all users? (true/false)"
password: false
- name: TEAMS_HOME_CHANNEL
description: "Default chat/channel ID for cron / notification delivery"
prompt: "Home channel (or empty)"
password: false
- name: TEAMS_HOME_CHANNEL_NAME
description: "Display name for the Teams home channel"
prompt: "Home channel display name"
password: false