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
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:
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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:  → 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."
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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."
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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."
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
Reference in New Issue
Block a user