mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
feat(plugins/google_chat): Google Chat platform adapter as a bundled plugin
Adds Google Chat as a new gateway platform, shipped under plugins/platforms/google_chat/ following the canonical bundled-plugin pattern (Teams, IRC). Rewired from the original PR #18425 to use the new env_enablement_fn + cron_deliver_env_var plugin interfaces landed in the preceding commit, so the adapter touches ZERO core files. What it does: - Inbound DM + group messages via Cloud Pub/Sub pull subscription (no public URL needed), with attachments (PDFs, images, audio, video) downloaded through an SSRF-guarded Google-host allowlist. - Outbound text replies with the 'Hermes is thinking…' patch-in-place pattern — no tombstones. - Native file attachment delivery via per-user OAuth. Google Chat's media.upload endpoint rejects service-account auth, so each user runs /setup-files once in their own DM to grant chat.messages.create for themselves; the adapter then uploads as them. Tokens stored per email at ~/.hermes/google_chat_user_tokens/<email>.json. - Thread isolation: side-threads get isolated sessions, top-level DM messages share one continuous session. Persistent thread-count store survives gateway restart. - Supervisor reconnect with exponential backoff. - Multi-user out of the box. How it plugs in (no core edits): - env_enablement_fn seeds PlatformConfig.extra with project_id, subscription_name, service_account_json, and the home_channel dict (which the core hook turns into a HomeChannel dataclass). Reads GOOGLE_CHAT_PROJECT_ID (falls back to GOOGLE_CLOUD_PROJECT), GOOGLE_CHAT_SUBSCRIPTION_NAME (falls back to GOOGLE_CHAT_SUBSCRIPTION), GOOGLE_CHAT_SERVICE_ACCOUNT_JSON (falls back to GOOGLE_APPLICATION_CREDENTIALS), GOOGLE_CHAT_HOME_CHANNEL. - cron_deliver_env_var='GOOGLE_CHAT_HOME_CHANNEL' gets cron delivery for free — cron/scheduler.py consults the platform registry for any name not in its hardcoded built-in sets. - plugin.yaml's rich requires_env / optional_env blocks auto-populate OPTIONAL_ENV_VARS via the new hermes_cli/config.py injector, so 'hermes config' UI surfaces them with description / url / prompt / password metadata. - Module-level Platform('google_chat') call in adapter.py triggers the Platform._missing_() registration so Platform.GOOGLE_CHAT attribute access works without an enum entry. Distribution: ships inside the existing hermes-agent package. Users opt in via 'pip install hermes-agent[google_chat]' and follow the 8-step GCP walkthrough at website/docs/user-guide/messaging/google_chat.md. Test coverage: 153 tests in tests/gateway/test_google_chat.py, all passing. Spans platform registration, env config loading, Pub/Sub envelope routing, outbound send + chunking + typing patch-in-place, attachment send paths, SSRF guard, thread/session model, supervisor reconnect, authorization, per-user OAuth, and the new plugin-registry cron delivery wiring. Credit: adapter + OAuth + tests + docs authored by @donramon77 (PR #18425). Rewire onto the new plugin hooks + salvage commit by Teknium. Co-Authored-By: Ramón Fernández <112875006+donramon77@users.noreply.github.com>
This commit is contained in:
parent
af9336d575
commit
44cd79e798
12 changed files with 6781 additions and 1 deletions
21
.env.example
21
.env.example
|
|
@ -423,3 +423,24 @@ IMAGE_TOOLS_DEBUG=false
|
|||
# TEAMS_HOME_CHANNEL= # Default channel/chat ID for cron delivery
|
||||
# TEAMS_HOME_CHANNEL_NAME= # Display name for the home channel
|
||||
# TEAMS_PORT=3978 # Webhook listen port (Bot Framework default)
|
||||
|
||||
# =============================================================================
|
||||
# GOOGLE CHAT INTEGRATION
|
||||
# =============================================================================
|
||||
# Connects via Cloud Pub/Sub pull subscription (no public URL required).
|
||||
# Setup walkthrough: website/docs/user-guide/messaging/google_chat.md.
|
||||
# 1. Create a GCP project, enable the Google Chat API and Cloud Pub/Sub.
|
||||
# 2. Create a Service Account with roles/pubsub.subscriber on the
|
||||
# subscription (NOT project-wide); download the JSON key.
|
||||
# 3. Configure your Chat app at console.cloud.google.com/apis/credentials
|
||||
# → Google Chat API → Configuration → Cloud Pub/Sub topic.
|
||||
# 4. (Optional, for native attachment delivery) Each user runs
|
||||
# `/setup-files` once in their own DM after Pub/Sub is wired up.
|
||||
#
|
||||
# GOOGLE_CHAT_PROJECT_ID= # GCP project hosting the topic (or set GOOGLE_CLOUD_PROJECT)
|
||||
# GOOGLE_CHAT_SUBSCRIPTION_NAME= # Full path: projects/<id>/subscriptions/<name>
|
||||
# GOOGLE_CHAT_SERVICE_ACCOUNT_JSON= # Path to SA JSON (or set GOOGLE_APPLICATION_CREDENTIALS)
|
||||
# GOOGLE_CHAT_ALLOWED_USERS= # Comma-separated emails allowed to talk to the bot
|
||||
# GOOGLE_CHAT_ALLOW_ALL_USERS=false # Set true to skip the allowlist
|
||||
# GOOGLE_CHAT_HOME_CHANNEL= # Default space (spaces/XXXX) for cron delivery
|
||||
# GOOGLE_CHAT_HOME_CHANNEL_NAME= # Display name for the home channel
|
||||
|
|
|
|||
|
|
@ -601,7 +601,7 @@ agent:
|
|||
# - A preset like "hermes-cli" or "hermes-telegram" (curated tool set)
|
||||
# - A list of individual toolsets to compose your own (see list below)
|
||||
#
|
||||
# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot, teams
|
||||
# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot, teams, google_chat
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
|
|
@ -632,6 +632,7 @@ agent:
|
|||
# homeassistant: hermes-homeassistant (same as telegram)
|
||||
# qqbot: hermes-qqbot (same as telegram)
|
||||
# teams: hermes-teams (same as telegram)
|
||||
# google_chat: hermes-google_chat (same as telegram)
|
||||
#
|
||||
platform_toolsets:
|
||||
cli: [hermes-cli]
|
||||
|
|
@ -644,6 +645,7 @@ platform_toolsets:
|
|||
qqbot: [hermes-qqbot]
|
||||
yuanbao: [hermes-yuanbao]
|
||||
teams: [hermes-teams]
|
||||
google_chat: [hermes-google_chat]
|
||||
|
||||
# =============================================================================
|
||||
# Gateway Platform Settings
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@ services:
|
|||
# - TEAMS_TENANT_ID=${TEAMS_TENANT_ID}
|
||||
# - TEAMS_ALLOWED_USERS=${TEAMS_ALLOWED_USERS}
|
||||
# - TEAMS_PORT=${TEAMS_PORT:-3978}
|
||||
# Google Chat — uncomment and fill in to enable the Google Chat gateway.
|
||||
# See website/docs/user-guide/messaging/google_chat.md for the full setup.
|
||||
# The SA JSON path must point to a file mounted into the container —
|
||||
# add a volume entry above (e.g. ``- ~/.hermes/google-chat-sa.json:/secrets/google-chat-sa.json:ro``)
|
||||
# then set GOOGLE_CHAT_SERVICE_ACCOUNT_JSON to that mount path.
|
||||
# - GOOGLE_CHAT_PROJECT_ID=${GOOGLE_CHAT_PROJECT_ID}
|
||||
# - GOOGLE_CHAT_SUBSCRIPTION_NAME=${GOOGLE_CHAT_SUBSCRIPTION_NAME}
|
||||
# - GOOGLE_CHAT_SERVICE_ACCOUNT_JSON=${GOOGLE_CHAT_SERVICE_ACCOUNT_JSON}
|
||||
# - GOOGLE_CHAT_ALLOWED_USERS=${GOOGLE_CHAT_ALLOWED_USERS}
|
||||
command: ["gateway", "run"]
|
||||
|
||||
dashboard:
|
||||
|
|
|
|||
3
plugins/platforms/google_chat/__init__.py
Normal file
3
plugins/platforms/google_chat/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
3085
plugins/platforms/google_chat/adapter.py
Normal file
3085
plugins/platforms/google_chat/adapter.py
Normal file
File diff suppressed because it is too large
Load diff
638
plugins/platforms/google_chat/oauth.py
Normal file
638
plugins/platforms/google_chat/oauth.py
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
"""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 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)
|
||||
|
||||
|
||||
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:
|
||||
"""Atomic-ish JSON write of refreshed credentials."""
|
||||
try:
|
||||
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
token_path.write_text(
|
||||
json.dumps(
|
||||
_normalize_authorized_user_payload(json.loads(creds.to_json())),
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
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 _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()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json.dumps(data, indent=2))
|
||||
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)
|
||||
pending.parent.mkdir(parents=True, exist_ok=True)
|
||||
pending.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"state": state,
|
||||
"code_verifier": code_verifier,
|
||||
"redirect_uri": _REDIRECT_URI,
|
||||
"email": email or "",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
token_path.write_text(json.dumps(token_payload, indent=2))
|
||||
_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"},
|
||||
)
|
||||
)
|
||||
print("Token revoked with Google.")
|
||||
except Exception as exc:
|
||||
print(f"Remote revocation failed (token may already be invalid): {exc}")
|
||||
|
||||
token_path.unlink(missing_ok=True)
|
||||
_pending_auth_path(email).unlink(missing_ok=True)
|
||||
print(f"Deleted {token_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Google Chat user-OAuth setup for Hermes (native attachment delivery)"
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--check", action="store_true",
|
||||
help="Check if auth is valid (exit 0=yes, 1=no)")
|
||||
group.add_argument("--client-secret", metavar="PATH",
|
||||
help="Store OAuth client_secret.json")
|
||||
group.add_argument("--auth-url", action="store_true",
|
||||
help="Print OAuth URL for user to visit")
|
||||
group.add_argument("--auth-code", metavar="CODE",
|
||||
help="Exchange auth code for token")
|
||||
group.add_argument("--revoke", action="store_true",
|
||||
help="Revoke and delete stored token")
|
||||
group.add_argument("--install-deps", action="store_true",
|
||||
help="Install Python dependencies")
|
||||
parser.add_argument("--email", metavar="EMAIL", default=None,
|
||||
help="Scope operation to a specific user's token "
|
||||
"(default: legacy single-user path)")
|
||||
args = parser.parse_args()
|
||||
|
||||
email = args.email or None
|
||||
if args.check:
|
||||
sys.exit(0 if check_auth(email) else 1)
|
||||
elif args.client_secret:
|
||||
store_client_secret(args.client_secret)
|
||||
elif args.auth_url:
|
||||
get_auth_url(email)
|
||||
elif args.auth_code:
|
||||
exchange_auth_code(args.auth_code, email)
|
||||
elif args.revoke:
|
||||
revoke(email)
|
||||
elif args.install_deps:
|
||||
sys.exit(0 if install_deps() else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
39
plugins/platforms/google_chat/plugin.yaml
Normal file
39
plugins/platforms/google_chat/plugin.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: google_chat-platform
|
||||
label: Google Chat
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Google Chat gateway adapter for Hermes Agent.
|
||||
Connects via Cloud Pub/Sub pull subscription for inbound events and the
|
||||
Google Chat REST API for outbound messages — same ergonomics as Slack
|
||||
Socket Mode or Telegram long-polling, no public URL required. Native
|
||||
file attachments are delivered via per-user OAuth (each user runs
|
||||
/setup-files once in their own DM).
|
||||
author: Ramón Fernández
|
||||
# ``requires_env`` entries are surfaced in ``hermes config`` UI via the
|
||||
# platform-plugin env var injector in ``hermes_cli/config.py``. Using the
|
||||
# rich-dict form lets us contribute description/url/prompt metadata so users
|
||||
# see helpful guidance instead of the auto-generated fallback text.
|
||||
requires_env:
|
||||
- name: GOOGLE_CHAT_PROJECT_ID
|
||||
description: "GCP project ID hosting the Pub/Sub topic for Chat events. Falls back to GOOGLE_CLOUD_PROJECT."
|
||||
prompt: "GCP project ID"
|
||||
url: "https://console.cloud.google.com/"
|
||||
password: false
|
||||
- name: GOOGLE_CHAT_SUBSCRIPTION_NAME
|
||||
description: "Full Pub/Sub subscription path: projects/<proj>/subscriptions/<sub>. Legacy alias: GOOGLE_CHAT_SUBSCRIPTION."
|
||||
prompt: "Pub/Sub subscription name"
|
||||
password: false
|
||||
- name: GOOGLE_CHAT_SERVICE_ACCOUNT_JSON
|
||||
description: "Path to Service Account JSON key (or inline JSON). Leave empty to use Application Default Credentials on Cloud Run / GCE. Falls back to GOOGLE_APPLICATION_CREDENTIALS."
|
||||
prompt: "Path to SA JSON (or empty for ADC)"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: GOOGLE_CHAT_ALLOWED_USERS
|
||||
description: "Comma-separated user emails allowed to interact with the bot."
|
||||
prompt: "Allowed user emails (comma-separated)"
|
||||
password: false
|
||||
- name: GOOGLE_CHAT_HOME_CHANNEL
|
||||
description: "Default space for cron / notification delivery (e.g. spaces/AAAA...)."
|
||||
prompt: "Home space ID (or empty)"
|
||||
password: false
|
||||
|
|
@ -90,6 +90,20 @@ google = [
|
|||
"google-auth-oauthlib>=1.0,<2",
|
||||
"google-auth-httplib2>=0.2,<1",
|
||||
]
|
||||
google_chat = [
|
||||
# Google Chat gateway adapter (plugins/platforms/google_chat/): Pub/Sub for
|
||||
# inbound events, Chat REST API for outbound. Shares the api-client and
|
||||
# httplib2 transport with [google] but adds the Pub/Sub library.
|
||||
# google-auth-oauthlib is required for the user-OAuth consent flow that
|
||||
# backs native attachment delivery — Chat's media.upload endpoint rejects
|
||||
# service-account auth, so the user grants chat.messages.create once via
|
||||
# /setup-files in chat. See plugins/platforms/google_chat/oauth.py.
|
||||
"google-cloud-pubsub>=2.20,<3",
|
||||
"google-api-python-client>=2.100,<3",
|
||||
"google-auth>=2.20,<3",
|
||||
"google-auth-httplib2>=0.2,<1",
|
||||
"google-auth-oauthlib>=1.0,<2",
|
||||
]
|
||||
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
|
||||
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
|
||||
rl = [
|
||||
|
|
@ -124,6 +138,7 @@ all = [
|
|||
"hermes-agent[dingtalk]",
|
||||
"hermes-agent[feishu]",
|
||||
"hermes-agent[google]",
|
||||
"hermes-agent[google_chat]",
|
||||
"hermes-agent[mistral]",
|
||||
"hermes-agent[bedrock]",
|
||||
"hermes-agent[web]",
|
||||
|
|
|
|||
2582
tests/gateway/test_google_chat.py
Normal file
2582
tests/gateway/test_google_chat.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -267,6 +267,17 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
|
|||
| `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs |
|
||||
| `SLACK_HOME_CHANNEL` | Default Slack channel for cron delivery |
|
||||
| `SLACK_HOME_CHANNEL_NAME` | Display name for the Slack home channel |
|
||||
| `GOOGLE_CHAT_PROJECT_ID` | GCP project hosting the Pub/Sub topic (falls back to `GOOGLE_CLOUD_PROJECT`) |
|
||||
| `GOOGLE_CHAT_SUBSCRIPTION_NAME` | Full Pub/Sub subscription path, `projects/{proj}/subscriptions/{sub}` (legacy alias: `GOOGLE_CHAT_SUBSCRIPTION`) |
|
||||
| `GOOGLE_CHAT_SERVICE_ACCOUNT_JSON` | Path to Service Account JSON, or the JSON inline (falls back to `GOOGLE_APPLICATION_CREDENTIALS`) |
|
||||
| `GOOGLE_CHAT_ALLOWED_USERS` | Comma-separated user emails allowed to chat with the bot |
|
||||
| `GOOGLE_CHAT_ALLOW_ALL_USERS` | Allow any Google Chat user to trigger the bot (dev only) |
|
||||
| `GOOGLE_CHAT_HOME_CHANNEL` | Default space (e.g. `spaces/AAAA...`) for cron delivery |
|
||||
| `GOOGLE_CHAT_HOME_CHANNEL_NAME` | Display name for the Google Chat home space |
|
||||
| `GOOGLE_CHAT_MAX_MESSAGES` | Pub/Sub FlowControl max in-flight messages (default: `1`) |
|
||||
| `GOOGLE_CHAT_MAX_BYTES` | Pub/Sub FlowControl max in-flight bytes (default: `16777216`, 16 MiB) |
|
||||
| `GOOGLE_CHAT_BOOTSTRAP_SPACES` | Comma-separated extra space IDs to probe at startup when resolving the bot's own `users/{id}` |
|
||||
| `GOOGLE_CHAT_DEBUG_RAW` | Set to any value to log redacted Pub/Sub envelopes at DEBUG level (debugging only) |
|
||||
| `WHATSAPP_ENABLED` | Enable the WhatsApp bridge (`true`/`false`) |
|
||||
| `WHATSAPP_MODE` | `bot` (separate number) or `self-chat` (message yourself) |
|
||||
| `WHATSAPP_ALLOWED_USERS` | Comma-separated phone numbers (with country code, no `+`), or `*` to allow all senders |
|
||||
|
|
|
|||
370
website/docs/user-guide/messaging/google_chat.md
Normal file
370
website/docs/user-guide/messaging/google_chat.md
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
---
|
||||
sidebar_position: 12
|
||||
title: "Google Chat"
|
||||
description: "Set up Hermes Agent as a Google Chat bot using Cloud Pub/Sub"
|
||||
---
|
||||
|
||||
# Google Chat Setup
|
||||
|
||||
Connect Hermes Agent to Google Chat as a bot. The integration uses Cloud Pub/Sub
|
||||
pull subscriptions for inbound events and the Chat REST API for outbound messages.
|
||||
Equivalent ergonomics to Slack Socket Mode or Telegram long-polling: your Hermes
|
||||
process does not need a public URL, a tunnel, or a TLS certificate. It connects,
|
||||
authenticates, and listens on a subscription — the same way a Telegram bot listens
|
||||
on a token.
|
||||
|
||||
:::note Workspace edition
|
||||
Google Chat is part of Google Workspace. You can use this integration with a
|
||||
personal Workspace (`@yourdomain.com` registered through Google) or a work
|
||||
Workspace where you have the Admin rights to publish an app. Gmail-only accounts
|
||||
cannot host Chat apps.
|
||||
:::
|
||||
|
||||
## Overview
|
||||
|
||||
| Component | Value |
|
||||
|-----------|-------|
|
||||
| **Libraries** | `google-cloud-pubsub`, `google-api-python-client`, `google-auth` |
|
||||
| **Inbound transport** | Cloud Pub/Sub pull subscription (no public endpoint) |
|
||||
| **Outbound transport** | Chat REST API (`chat.googleapis.com`) |
|
||||
| **Authentication** | Service Account JSON with `roles/pubsub.subscriber` on the subscription |
|
||||
| **User identification** | Chat resource names (`users/{id}`) + email |
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create or pick a GCP project
|
||||
|
||||
You need a Google Cloud project to host the Pub/Sub topic. If you don't have one,
|
||||
create it at [console.cloud.google.com](https://console.cloud.google.com) —
|
||||
personal accounts get a free tier that easily covers bot traffic.
|
||||
|
||||
Note the project ID (e.g., `my-chat-bot-123`). You'll use it in every subsequent
|
||||
step.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Enable two APIs
|
||||
|
||||
In the console, go to **APIs & Services → Library** and enable:
|
||||
|
||||
- **Google Chat API**
|
||||
- **Cloud Pub/Sub API**
|
||||
|
||||
Both are free for the volumes a personal bot generates.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Create a Service Account
|
||||
|
||||
**IAM & Admin → Service Accounts → Create Service Account.**
|
||||
|
||||
- Name: `hermes-chat-bot`
|
||||
- Skip the "Grant this service account access to project" step. IAM on the specific
|
||||
subscription is all you need — do **NOT** grant project-level Pub/Sub roles.
|
||||
|
||||
After creation, open the SA, go to **Keys → Add Key → Create new key → JSON** and
|
||||
download the file. Save it somewhere only Hermes can read (e.g.,
|
||||
`~/.hermes/google-chat-sa.json`, `chmod 600`).
|
||||
|
||||
:::caution There is NO "Chat Bot Caller" role
|
||||
A common mistake is to search for a Chat-specific IAM role and grant it at the
|
||||
project level. That role doesn't exist. Chat bot authority comes from being
|
||||
installed in a space, not from IAM. All your SA needs is Pub/Sub subscriber on
|
||||
the subscription you create in the next step.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Create the Pub/Sub topic and subscription
|
||||
|
||||
**Pub/Sub → Topics → Create topic.**
|
||||
|
||||
- Topic ID: `hermes-chat-events`
|
||||
- Leave the defaults for everything else.
|
||||
|
||||
After creation, the topic's detail page has a **Subscriptions** tab. Create one:
|
||||
|
||||
- Subscription ID: `hermes-chat-events-sub`
|
||||
- Delivery type: **Pull**
|
||||
- Message retention: **7 days** (so backlog survives a hermes restart)
|
||||
- Leave the rest default.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: IAM binding on the topic (critical)
|
||||
|
||||
On the **topic** (not the subscription), add an IAM principal:
|
||||
|
||||
- Principal: `chat-api-push@system.gserviceaccount.com`
|
||||
- Role: `Pub/Sub Publisher`
|
||||
|
||||
Without this, Google Chat cannot publish events to your topic and your bot will
|
||||
never receive anything.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: IAM binding on the subscription
|
||||
|
||||
On the **subscription**, add your own Service Account as a principal:
|
||||
|
||||
- Principal: `hermes-chat-bot@<your-project>.iam.gserviceaccount.com`
|
||||
- Role: `Pub/Sub Subscriber`
|
||||
|
||||
Also grant `Pub/Sub Viewer` on the same subscription — Hermes calls
|
||||
`subscription.get()` at startup as a reachability check.
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Configure the Chat app
|
||||
|
||||
Go to **APIs & Services → Google Chat API → Configuration**.
|
||||
|
||||
- **App name**: whatever you want users to see ("Hermes" is reasonable).
|
||||
- **Avatar URL**: any public PNG (Google has some defaults).
|
||||
- **Description**: a short sentence shown in the app directory.
|
||||
- **Functionality**: enable **Receive 1:1 messages** and **Join spaces and group
|
||||
conversations**.
|
||||
- **Connection settings**: select **Cloud Pub/Sub**, enter the topic name
|
||||
`projects/<your-project>/topics/hermes-chat-events`.
|
||||
- **Visibility**: restrict to your workspace (or specific users) — do not publish
|
||||
to everyone while you're testing.
|
||||
|
||||
Save.
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Install the bot in a test space
|
||||
|
||||
Open Google Chat in a browser. Start a DM with your app by searching for its name
|
||||
in the **+ New Chat** menu. The first time you message it, Google sends an
|
||||
`ADDED_TO_SPACE` event that Hermes uses to cache the bot's own `users/{id}` for
|
||||
self-message filtering.
|
||||
|
||||
---
|
||||
|
||||
## Step 9: Configure Hermes
|
||||
|
||||
Add the Google Chat section to `~/.hermes/.env`:
|
||||
|
||||
```bash
|
||||
# Required
|
||||
GOOGLE_CHAT_PROJECT_ID=my-chat-bot-123
|
||||
GOOGLE_CHAT_SUBSCRIPTION_NAME=projects/my-chat-bot-123/subscriptions/hermes-chat-events-sub
|
||||
GOOGLE_CHAT_SERVICE_ACCOUNT_JSON=/home/you/.hermes/google-chat-sa.json
|
||||
|
||||
# Authorization — paste the emails of people allowed to talk to the bot
|
||||
GOOGLE_CHAT_ALLOWED_USERS=you@yourdomain.com,coworker@yourdomain.com
|
||||
|
||||
# Optional
|
||||
GOOGLE_CHAT_HOME_CHANNEL=spaces/AAAA... # default delivery destination for cron jobs
|
||||
GOOGLE_CHAT_MAX_MESSAGES=1 # Pub/Sub FlowControl; 1 serializes commands per session
|
||||
GOOGLE_CHAT_MAX_BYTES=16777216 # 16 MiB — cap on in-flight message bytes
|
||||
```
|
||||
|
||||
The project ID also falls back to `GOOGLE_CLOUD_PROJECT`, and the SA path falls
|
||||
back to `GOOGLE_APPLICATION_CREDENTIALS` — use whichever convention you prefer.
|
||||
|
||||
Install Hermes with the optional dependencies:
|
||||
|
||||
```bash
|
||||
pip install 'hermes-agent[google_chat]'
|
||||
```
|
||||
|
||||
Start the gateway:
|
||||
|
||||
```bash
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
You should see a log line like:
|
||||
|
||||
```
|
||||
[GoogleChat] Connected; project=my-chat-bot-123, subscription=<redacted>,
|
||||
bot_user_id=users/XXXX, flow_control(msgs=1, bytes=16777216)
|
||||
```
|
||||
|
||||
Send "hola" in the test DM. The bot posts a "Hermes is thinking…" marker, then
|
||||
edits that same message in place with the real response — no "message deleted"
|
||||
tombstones.
|
||||
|
||||
---
|
||||
|
||||
## Formatting and capabilities
|
||||
|
||||
Google Chat renders a limited markdown subset:
|
||||
|
||||
| Supported | Not supported |
|
||||
|-----------|---------------|
|
||||
| `*bold*`, `_italic_`, `~strike~`, `` `code` `` | Headings, lists |
|
||||
| Inline images via URL | Interactive Card v2 buttons (v1 of this gateway) |
|
||||
| Native file attachments (after `/setup-files` — see Step 10) | Native voice notes / circular video notes |
|
||||
|
||||
The agent's system prompt includes a Google Chat–specific hint so it knows these
|
||||
limits and avoids formatting that won't render.
|
||||
|
||||
Message size limit: 4000 characters per message. Longer agent responses are
|
||||
automatically split across multiple messages.
|
||||
|
||||
Thread support: when a user replies inside a thread, Hermes detects the
|
||||
`thread.name` and posts its reply in the same thread, so each thread gets a
|
||||
separate Hermes session.
|
||||
|
||||
---
|
||||
|
||||
## Step 10: Native attachment delivery (optional)
|
||||
|
||||
Out of the box the bot can post text, inline images via URL, and download cards
|
||||
for audio/video/documents. To deliver **native** Chat attachments — the same
|
||||
file widget you get when a human drags-and-drops a file — each user authorizes
|
||||
the bot once via a per-user OAuth flow.
|
||||
|
||||
### Why a separate flow
|
||||
|
||||
Google Chat's `media.upload` endpoint hard-rejects service-account auth:
|
||||
|
||||
> This method doesn't support app authentication with a service account.
|
||||
> Authenticate with a user account.
|
||||
|
||||
There's no IAM role or scope that fixes this. The endpoint only accepts user
|
||||
credentials. So the bot has to act *as a user* whenever it uploads a file —
|
||||
specifically, as the user who asked for the file.
|
||||
|
||||
### One-time host setup
|
||||
|
||||
1. Go to **APIs & Services → Credentials** in the same GCP project.
|
||||
2. **Create credentials → OAuth client ID → Desktop app**.
|
||||
3. Download the JSON. Move it onto the host that runs Hermes.
|
||||
4. On the host, register the client with Hermes:
|
||||
|
||||
```bash
|
||||
python -m gateway.platforms.google_chat_user_oauth \
|
||||
--client-secret /path/to/client_secret.json
|
||||
```
|
||||
|
||||
That writes `~/.hermes/google_chat_user_client_secret.json`. This is shared
|
||||
infrastructure — it identifies the OAuth *app*, not any individual user. One
|
||||
file per host is enough no matter how many users authorize later.
|
||||
|
||||
### Per-user authorization (in chat)
|
||||
|
||||
Each user runs the flow once, in their own DM with the bot:
|
||||
|
||||
1. They send `/setup-files` to the bot. It replies with status and the next
|
||||
step.
|
||||
2. They send `/setup-files start`. The bot replies with an OAuth URL.
|
||||
3. They open the URL, click **Allow**, and watch the browser fail to load
|
||||
`http://localhost:1/?...&code=...`. That failure is expected — the auth
|
||||
code is in the URL bar.
|
||||
4. They copy the failed URL (or just the `code=...` value) and paste it back
|
||||
into chat as `/setup-files <PASTED_URL>`. The bot exchanges it for a
|
||||
refresh token.
|
||||
|
||||
The token lands at `~/.hermes/google_chat_user_tokens/<sanitized_email>.json`.
|
||||
Subsequent file requests in that user's DM use *their* token, so the bot
|
||||
uploads as them and the message lands in their space.
|
||||
|
||||
To revoke later: `/setup-files revoke` deletes only that user's token. Other
|
||||
users' tokens are untouched.
|
||||
|
||||
### Scope
|
||||
|
||||
The flow requests exactly one scope: `chat.messages.create`. That covers both
|
||||
`media.upload` and the `messages.create` that references the uploaded
|
||||
`attachmentDataRef`. No Drive, no broader Chat scopes — this is least-privilege
|
||||
on purpose.
|
||||
|
||||
### Multi-user behavior
|
||||
|
||||
When the asker has no per-user token yet, the bot falls back to a legacy
|
||||
single-user token at `~/.hermes/google_chat_user_token.json` (if present from
|
||||
a pre-multi-user install). When neither is available, the bot posts a clear
|
||||
text notice telling the asker to run `/setup-files`.
|
||||
|
||||
A user revoking only clears their own slot. A 401/403 from one user's token
|
||||
evicts only that user's cache. Users don't disrupt each other.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Bot stays silent after sending "hola."**
|
||||
|
||||
1. Check the Pub/Sub subscription has undelivered messages in the console.
|
||||
If it does, Hermes isn't authenticated — verify `GOOGLE_CHAT_SERVICE_ACCOUNT_JSON`
|
||||
and that the SA is listed as `Pub/Sub Subscriber` on the subscription.
|
||||
2. If the subscription has zero messages, Google Chat isn't publishing.
|
||||
Double-check the IAM binding on the **topic**:
|
||||
`chat-api-push@system.gserviceaccount.com` must have `Pub/Sub Publisher`.
|
||||
3. Check `hermes gateway` logs for `[GoogleChat] Connected`. If you see
|
||||
`[GoogleChat] Config validation failed`, the error message tells you which
|
||||
env var to fix.
|
||||
|
||||
**Bot replies but an error message appears instead of the agent's answer.**
|
||||
|
||||
Check logs for `[GoogleChat] Pub/Sub stream died` — if these repeat, your SA
|
||||
credentials may have been rotated or the subscription deleted. After 10 attempts
|
||||
the adapter marks itself fatal.
|
||||
|
||||
**"403 Forbidden" on every outbound message.**
|
||||
|
||||
The bot was removed from the space, or you revoked it in the Chat API console.
|
||||
Re-install it in the space (the next `ADDED_TO_SPACE` event will re-enable
|
||||
messaging automatically).
|
||||
|
||||
**Too many "Rate limit hit" warnings.**
|
||||
|
||||
The Chat API's default quotas allow 60 messages per space per minute. If your
|
||||
agent produces long streaming responses that exceed that, the adapter retries
|
||||
with exponential backoff — but you'll still see user-visible latency. Consider
|
||||
concise responses or raising the quota in the GCP console.
|
||||
|
||||
**Bot keeps posting the "/setup-files" notice instead of files.**
|
||||
|
||||
The asker has no per-user OAuth token and there's no legacy fallback. Run
|
||||
`/setup-files` in their DM and follow Step 10. After the exchange completes
|
||||
the next file request uploads natively without a gateway restart.
|
||||
|
||||
**`/setup-files start` says "No client credentials stored on the host."**
|
||||
|
||||
The one-time host setup wasn't done. From a terminal on the host that runs
|
||||
Hermes:
|
||||
|
||||
```bash
|
||||
python -m gateway.platforms.google_chat_user_oauth \
|
||||
--client-secret /path/to/client_secret.json
|
||||
```
|
||||
|
||||
Then send `/setup-files start` again.
|
||||
|
||||
**`/setup-files <PASTED_URL>` says "Token exchange failed."**
|
||||
|
||||
The auth code is single-use and short-lived (typically a few minutes). Send
|
||||
`/setup-files start` to get a fresh URL and retry.
|
||||
|
||||
---
|
||||
|
||||
## Security notes
|
||||
|
||||
- **Service Account scope**: the adapter requests `chat.bot` and `pubsub` scopes.
|
||||
IAM should be the actual enforcement — grant your SA the minimum
|
||||
(`roles/pubsub.subscriber` + `roles/pubsub.viewer` on the subscription), not
|
||||
project-level or org-level Pub/Sub roles.
|
||||
- **Attachment download protection**: Hermes will only attach the SA bearer
|
||||
token to URLs whose host matches a short allowlist of Google-owned domains
|
||||
(`googleapis.com`, `drive.google.com`, `lh[3-6].googleusercontent.com`, and
|
||||
a few others). Any other host is rejected before the HTTP request, to
|
||||
protect against SSRF scenarios where a crafted event could redirect the
|
||||
bearer token to the GCE metadata service.
|
||||
- **Redaction**: Service Account emails, subscription paths, and topic paths
|
||||
are stripped from log output by `agent/redact.py`. The debug envelope dump
|
||||
(`GOOGLE_CHAT_DEBUG_RAW=1`) routes through the same redaction filter and
|
||||
logs at DEBUG level.
|
||||
- **Compliance**: if you plan to connect this bot to a regulated workspace
|
||||
(anything with a data-residency or AI-governance policy), get that approval
|
||||
before the first install.
|
||||
- **User OAuth scope**: the per-user attachment flow requests *only*
|
||||
`chat.messages.create` — the minimum that covers `media.upload` plus the
|
||||
follow-up `messages.create`. Tokens are persisted as plain JSON at
|
||||
`~/.hermes/google_chat_user_tokens/<sanitized_email>.json` (filesystem
|
||||
permissions are the protection — same model as the SA key file). Each
|
||||
token is owned by exactly one user; revoke is scoped to that user.
|
||||
|
|
@ -17,6 +17,7 @@ For the full voice feature set — including CLI microphone mode, spoken replies
|
|||
| Telegram | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
|
||||
| Discord | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Slack | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Google Chat | — | ✅ | ✅ | ✅ | — | ✅ | — |
|
||||
| WhatsApp | — | ✅ | ✅ | — | — | ✅ | ✅ |
|
||||
| Signal | — | ✅ | ✅ | — | — | ✅ | ✅ |
|
||||
| SMS | — | — | — | — | — | — | — |
|
||||
|
|
@ -46,6 +47,7 @@ flowchart TB
|
|||
dc[Discord]
|
||||
wa[WhatsApp]
|
||||
sl[Slack]
|
||||
gc[Google Chat]
|
||||
sig[Signal]
|
||||
sms[SMS]
|
||||
em[Email]
|
||||
|
|
@ -74,6 +76,7 @@ flowchart TB
|
|||
dc --> store
|
||||
wa --> store
|
||||
sl --> store
|
||||
gc --> store
|
||||
sig --> store
|
||||
sms --> store
|
||||
em --> store
|
||||
|
|
@ -383,6 +386,7 @@ Each platform has its own toolset:
|
|||
| Discord | `hermes-discord` | Full tools including terminal |
|
||||
| WhatsApp | `hermes-whatsapp` | Full tools including terminal |
|
||||
| Slack | `hermes-slack` | Full tools including terminal |
|
||||
| Google Chat | `hermes-google-chat` | Full tools including terminal |
|
||||
| Signal | `hermes-signal` | Full tools including terminal |
|
||||
| SMS | `hermes-sms` | Full tools including terminal |
|
||||
| Email | `hermes-email` | Full tools including terminal |
|
||||
|
|
@ -406,6 +410,7 @@ Each platform has its own toolset:
|
|||
- [Telegram Setup](telegram.md)
|
||||
- [Discord Setup](discord.md)
|
||||
- [Slack Setup](slack.md)
|
||||
- [Google Chat Setup](google_chat.md)
|
||||
- [WhatsApp Setup](whatsapp.md)
|
||||
- [Signal Setup](signal.md)
|
||||
- [SMS Setup (Twilio)](sms.md)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue