feat(gateway): add X Chat (encrypted X DMs) platform plugin

Connects the Hermes gateway to X's end-to-end encrypted direct messages
via the official X Chat API. All plaintext stays local: inbound
encoded_event blobs are decrypted with the Chat XDK (chatxdk) and
outbound replies are encrypted + signed before they reach X.

- plugins/platforms/xchat/: adapter (polling inbound, encrypted send,
  typing, group mention gating, allowlist/pairing, cron standalone
  sender), async httpx API client with OAuth2 refresh-token rotation,
  Chat XDK crypto wrapper, and a resume-safe 'hermes xchat setup'
  CLI (token -> user id -> keygen -> rate-limit-aware key registration)
- tools/lazy_deps.py + pyproject.toml: chatxdk lazy-install entry
  (platform.xchat) + xchat extra for packagers
- hermes_cli/main.py: resolve a deferred bundled platform's CLI
  subcommand when invoked as 'hermes <platform>' — also fixes
  'hermes photon' being unreachable since the lazy-load perf change
  (#54448)
- docs: messaging guide, env-var reference, sidebar, platform tables
- tests: 24 offline unit tests (dispatch, dedup, backlog seeding,
  KeyChange handling, mention gating, registry parity, crypto wrapper)
This commit is contained in:
Teknium 2026-07-21 14:08:37 -07:00
parent 32a9f2acbc
commit 1819dc4b1f
No known key found for this signature in database
16 changed files with 2335 additions and 3 deletions

View file

@ -14002,6 +14002,24 @@ def main():
seen_plugin_commands.add(cmd_info["name"])
discover_plugins()
# Bundled platform plugins register LAZILY (perf, #54448): their
# modules — and therefore their register_cli_command() calls —
# only run when the platform_registry is asked for them. When the
# user invokes `hermes <platform> ...` (e.g. `hermes photon`,
# `hermes xchat`), resolve that one deferred loader now so the
# plugin's CLI subcommand exists in the parser. Cheap: imports a
# single platform module, and only on an unknown-subcommand path.
_first_pos = _first_positional_argv()
if _first_pos and _first_pos not in seen_plugin_commands:
try:
from gateway.platform_registry import platform_registry
if platform_registry.is_registered(_first_pos):
platform_registry.get(_first_pos) # fires the loader
except Exception:
pass
for cmd_info in get_plugin_manager()._cli_commands.values():
if cmd_info["name"] in seen_plugin_commands:
continue

View file

@ -0,0 +1,4 @@
"""X Chat (end-to-end encrypted X DMs) platform plugin entry point."""
from .adapter import register
__all__ = ["register"]

View file

@ -0,0 +1,770 @@
"""X Chat platform adapter (Hermes plugin).
Connects the Hermes gateway to X's end-to-end encrypted direct messages
via the official X Chat API. All plaintext stays local: inbound
``encoded_event`` blobs are decrypted with the Chat XDK (``chatxdk``) and
outbound replies are encrypted + signed before they ever reach X.
Transport model
---------------
Inbound is a polling loop over ``GET /2/chat/conversations/{id}/events``
(the same shape as X's own bot example). Conversations are auto-discovered
via ``GET /2/chat/conversations`` (or pinned with
``XCHAT_CONVERSATION_IDS``); each is polled every ``XCHAT_POLL_INTERVAL``
seconds with exponential backoff on errors. Outbound goes through
``POST /2/chat/conversations/{id}/messages``.
Identity / key state (written by ``hermes xchat setup``):
* ``XCHAT_ACCESS_TOKEN`` OAuth2 user token (dm.read, dm.write, users.read, tweet.read)
* ``XCHAT_USER_ID`` the bot account's numeric user id
* ``XCHAT_SIGNING_KEY_VERSION`` registered public-key version
* private-key blob at ``~/.hermes/xchat/private_keys.b64`` (mode 600), or
``XCHAT_PRIVATE_KEYS_B64`` env override
The E2EE session is one ``chat_xdk.Chat`` instance with ``set_identity`` +
``set_cache_keys(True)``: KeyChange events route through the batch decrypt
path to feed the verified-key cache, so encrypt calls need no explicit
conversation key.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
)
from .api import HTTPX_AVAILABLE, XChatApi, XChatApiError, XChatRateLimited
from .crypto import XChatCrypto, message_text
logger = logging.getLogger(__name__)
# X DMs cap out around 10k chars; stay under it so chunking kicks in first.
MAX_MESSAGE_LENGTH = 9500
DEFAULT_POLL_INTERVAL = 10.0
DISCOVERY_INTERVAL = 300.0 # re-list conversations every 5 minutes
ERROR_BACKOFF = [5, 15, 30, 60, 120]
DEDUP_MAX_SIZE = 5000
# Group-chat mention wake words — same defaults as the other Hermes channels
# so group gating behaves identically everywhere.
_DEFAULT_MENTION_PATTERNS = [
r"(?<![\w@])@?hermes\s+agent\b[,:\-]?",
r"(?<![\w@])@?hermes\b[,:\-]?",
]
def _state_dir() -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / "xchat"
def _read_key_blob() -> str:
"""Private-key blob: env override first, then the setup-written file."""
env_blob = os.getenv("XCHAT_PRIVATE_KEYS_B64", "").strip()
if env_blob:
return env_blob
blob_path = _state_dir() / "private_keys.b64"
try:
return blob_path.read_text(encoding="utf-8").strip()
except OSError:
return ""
def check_requirements() -> bool:
"""True when the adapter is minimally configured (token + key material).
Deliberately does NOT import chatxdk the native SDK lazy-installs at
connect time; a pre-flight check must stay cheap.
"""
if not HTTPX_AVAILABLE:
return False
if not os.getenv("XCHAT_ACCESS_TOKEN", "").strip():
return False
return bool(_read_key_blob())
def validate_config(config) -> bool:
extra = getattr(config, "extra", {}) or {}
token = extra.get("access_token") or os.getenv("XCHAT_ACCESS_TOKEN", "")
return bool(token)
def is_connected(config) -> bool:
extra = getattr(config, "extra", {}) or {}
token = os.getenv("XCHAT_ACCESS_TOKEN") or extra.get("access_token", "")
return bool(token)
def _compile_mention_patterns(raw: Any) -> List[re.Pattern]:
"""Accept list / JSON string / comma- or newline-separated string / None."""
patterns: List[str]
if raw is None or raw == "":
patterns = _DEFAULT_MENTION_PATTERNS
elif isinstance(raw, list):
patterns = [str(p) for p in raw if str(p).strip()]
else:
text = str(raw).strip()
if text.startswith("["):
try:
patterns = [str(p) for p in json.loads(text)]
except (ValueError, TypeError):
patterns = [text]
else:
parts = re.split(r"[\n,]+", text)
patterns = [p.strip() for p in parts if p.strip()]
if not patterns:
patterns = _DEFAULT_MENTION_PATTERNS
compiled = []
for p in patterns:
try:
compiled.append(re.compile(p, re.IGNORECASE))
except re.error:
logger.warning("[xchat] invalid mention pattern skipped: %r", p)
return compiled or [re.compile(p, re.IGNORECASE) for p in _DEFAULT_MENTION_PATTERNS]
class XChatAdapter(BasePlatformAdapter):
"""X Chat (encrypted X DMs) adapter."""
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
def __init__(self, config: PlatformConfig):
platform = Platform("xchat")
super().__init__(config=config, platform=platform)
extra = config.extra or {}
self._access_token: str = (
extra.get("access_token") or os.getenv("XCHAT_ACCESS_TOKEN", "")
).strip()
self._refresh_token: str = (
extra.get("refresh_token") or os.getenv("XCHAT_REFRESH_TOKEN", "")
).strip()
self._client_id: str = (
extra.get("client_id") or os.getenv("XCHAT_CLIENT_ID", "")
).strip()
self._client_secret: str = (
extra.get("client_secret") or os.getenv("XCHAT_CLIENT_SECRET", "")
).strip()
self._bot_user_id: str = str(
extra.get("user_id") or os.getenv("XCHAT_USER_ID", "")
).strip()
self._signing_key_version: str = str(
extra.get("signing_key_version")
or os.getenv("XCHAT_SIGNING_KEY_VERSION", "1")
).strip() or "1"
try:
self._poll_interval = float(
extra.get("poll_interval") or os.getenv("XCHAT_POLL_INTERVAL", "") or DEFAULT_POLL_INTERVAL
)
except (TypeError, ValueError):
self._poll_interval = DEFAULT_POLL_INTERVAL
self._poll_interval = max(2.0, self._poll_interval)
# Pinned conversations (skip discovery when set).
conv_raw = extra.get("conversation_ids") or os.getenv("XCHAT_CONVERSATION_IDS", "")
if isinstance(conv_raw, list):
self._pinned_conversations = [str(c).strip() for c in conv_raw if str(c).strip()]
else:
self._pinned_conversations = [
c.strip() for c in str(conv_raw).split(",") if c.strip()
]
# Group mention gating.
env_require = os.getenv("XCHAT_REQUIRE_MENTION")
if env_require is not None:
self.require_mention = env_require.strip().lower() in {"1", "true", "yes"}
else:
self.require_mention = bool(extra.get("require_mention", False))
self._mention_patterns = _compile_mention_patterns(
extra.get("mention_patterns") or os.getenv("XCHAT_MENTION_PATTERNS")
)
# Runtime state
self._api: Optional[XChatApi] = None
self._crypto: Optional[XChatCrypto] = None
self._poll_task: Optional[asyncio.Task] = None
self._running = False
self._lock_acquired = False
# Per-conversation cursors + dedup
self._conversations: Set[str] = set(self._pinned_conversations)
self._backlog_loaded: Set[str] = set()
self._seen_event_ids: Dict[str, float] = {}
self._conversation_keys: Dict[str, Dict[str, bytes]] = {}
self._last_event_id: Dict[str, str] = {}
# Signing-key roster (accumulated; the SDK store is replaced wholesale)
self._signing_keys: List[Dict[str, str]] = []
self._known_senders: Set[str] = set()
logger.info(
"[xchat] adapter initialized: user_id=%s poll=%.0fs pinned=%d refresh=%s",
self._bot_user_id or "?",
self._poll_interval,
len(self._pinned_conversations),
"yes" if (self._refresh_token and self._client_id) else "no",
)
# -- Connection lifecycle -------------------------------------------------
async def connect(self, *, is_reconnect: bool = False) -> bool:
if not HTTPX_AVAILABLE:
logger.warning("[xchat] httpx not installed")
return False
if not self._access_token:
logger.warning("[xchat] XCHAT_ACCESS_TOKEN not configured — run `hermes xchat setup`")
return False
key_blob = _read_key_blob()
if not key_blob:
logger.warning(
"[xchat] no private-key blob found (~/.hermes/xchat/private_keys.b64 "
"or XCHAT_PRIVATE_KEYS_B64) — run `hermes xchat setup`"
)
return False
# One credential = one gateway. Prevents two profiles polling (and
# double-replying) on the same bot account.
try:
from gateway.status import acquire_scoped_lock
ok, holder = acquire_scoped_lock("xchat", self._access_token[:16])
if not ok:
logger.error("[xchat] credential already in use by another gateway: %s", holder)
return False
self._lock_acquired = True
except Exception:
logger.debug("[xchat] scoped lock unavailable; continuing", exc_info=True)
self._api = XChatApi(
self._access_token,
refresh_token=self._refresh_token,
client_id=self._client_id,
client_secret=self._client_secret,
on_token_refresh=self._persist_rotated_tokens,
)
# Derive the bot's own user id when not configured.
if not self._bot_user_id:
try:
me = await self._api.get_my_user()
self._bot_user_id = str(me.get("id") or "")
except XChatApiError as e:
logger.error("[xchat] failed to resolve bot user id: %s", e)
await self._teardown()
return False
if not self._bot_user_id:
logger.error("[xchat] could not determine bot user id")
await self._teardown()
return False
# Unlock the E2EE session. chatxdk lazy-installs here on first use.
try:
crypto = XChatCrypto()
crypto.load_keys(key_blob, self._signing_key_version)
crypto.set_identity(self._bot_user_id)
crypto.set_cache_keys(True)
self._crypto = crypto
except Exception as e:
logger.error("[xchat] failed to initialize Chat XDK session: %s", e)
await self._teardown()
return False
self._running = True
self._poll_task = asyncio.create_task(self._run_poll_loop())
self._mark_connected()
logger.info("[xchat] connected as user %s", self._bot_user_id)
return True
async def disconnect(self) -> None:
self._running = False
if self._poll_task is not None:
self._poll_task.cancel()
try:
await self._poll_task
except (asyncio.CancelledError, Exception):
pass
self._poll_task = None
await self._teardown()
logger.info("[xchat] disconnected")
async def _teardown(self) -> None:
if self._api is not None:
await self._api.aclose()
self._api = None
if self._lock_acquired:
try:
from gateway.status import release_scoped_lock
release_scoped_lock("xchat", self._access_token[:16])
except Exception:
pass
self._lock_acquired = False
async def _persist_rotated_tokens(self, access_token: str, refresh_token: str) -> None:
"""X rotates the refresh token on every renewal — persist both to .env."""
self._access_token = access_token
self._refresh_token = refresh_token
try:
from hermes_cli.config import save_env_value
save_env_value("XCHAT_ACCESS_TOKEN", access_token)
if refresh_token:
save_env_value("XCHAT_REFRESH_TOKEN", refresh_token)
except Exception:
logger.warning("[xchat] failed to persist rotated OAuth tokens", exc_info=True)
# -- Polling loop -----------------------------------------------------------
async def _run_poll_loop(self) -> None:
backoff_idx = 0
last_discovery = 0.0
while self._running:
try:
now = time.monotonic()
if not self._pinned_conversations and (
now - last_discovery >= DISCOVERY_INTERVAL or not self._conversations
):
await self._discover_conversations()
last_discovery = now
for conv_id in list(self._conversations):
if not self._running:
return
await self._poll_conversation(conv_id)
backoff_idx = 0
await asyncio.sleep(self._poll_interval)
except asyncio.CancelledError:
return
except XChatRateLimited as e:
wait = max(30.0, (e.reset_epoch - time.time()) if e.reset_epoch else 60.0)
wait = min(wait, 900.0)
logger.warning("[xchat] rate limited — sleeping %.0fs", wait)
await asyncio.sleep(wait)
except Exception as e:
delay = ERROR_BACKOFF[min(backoff_idx, len(ERROR_BACKOFF) - 1)]
backoff_idx += 1
logger.warning("[xchat] poll error (retry in %ds): %s", delay, e)
await asyncio.sleep(delay)
async def _discover_conversations(self) -> None:
assert self._api is not None
token: Optional[str] = None
found: Set[str] = set()
for _ in range(10): # hard page cap
page = await self._api.get_conversations(max_results=100, pagination_token=token)
for conv in page.get("data") or []:
cid = str(conv.get("conversation_id") or conv.get("id") or "").strip()
if cid:
found.add(cid)
token = (page.get("meta") or {}).get("next_token")
if not token:
break
new = found - self._conversations
if new:
logger.info("[xchat] discovered %d new conversation(s)", len(new))
self._conversations |= found
async def _poll_conversation(self, conv_id: str) -> None:
assert self._api is not None and self._crypto is not None
page = await self._api.get_events(conv_id, max_results=50)
raw = page.get("data") or []
if not raw:
return
# Events arrive newest-first; process oldest-first.
raw = list(reversed(raw))
await self._register_signing_keys(raw)
if conv_id not in self._backlog_loaded:
# First sight of this conversation: batch-decrypt to seed the
# SDK's verified-key cache, but do NOT reply to the backlog.
events_b64 = [e["encoded_event"] for e in raw if e.get("encoded_event")]
if events_b64:
try:
batch = self._crypto.decrypt_batch(events_b64)
keys = (batch.get("conversation_keys") or {}).get("keys") or {}
self._conversation_keys.setdefault(conv_id, {}).update(keys)
except Exception as e:
logger.warning("[xchat] backlog decrypt failed conv=%s: %s", conv_id, e)
for item in raw:
eid = str(item.get("id") or "")
if eid:
self._seen_event_ids[eid] = time.time()
self._backlog_loaded.add(conv_id)
return
for item in raw:
event_id = str(item.get("id") or "")
if not event_id or event_id in self._seen_event_ids:
continue
self._seen_event_ids[event_id] = time.time()
self._prune_dedup()
event_b64 = item.get("encoded_event")
if not event_b64:
continue
try:
event = self._crypto.decrypt_one(
event_b64, self._conversation_keys.get(conv_id) or None
)
except Exception as e:
logger.warning("[xchat] decrypt failed conv=%s event=%s: %s", conv_id, event_id, e)
continue
etype = event.get("type")
if etype == "KeyChange":
# Key rotation: route through the batch path — it verifies the
# change and feeds the SDK's verified-key cache.
try:
rotated = self._crypto.decrypt_batch([event_b64])
keys = (rotated.get("conversation_keys") or {}).get("keys") or {}
self._conversation_keys.setdefault(conv_id, {}).update(keys)
except Exception as e:
logger.warning("[xchat] key-change processing failed conv=%s: %s", conv_id, e)
continue
if etype != "Message":
continue
sender_id = str(event.get("sender_id") or item.get("sender_id") or "")
if sender_id == self._bot_user_id:
continue # echo of our own reply
text = message_text(event)
if not text:
continue
# The signature covers the canonical conversation id embedded in
# the event — prefer it for replies.
canonical_conv = str(event.get("conversation_id") or conv_id)
await self._dispatch_inbound(
conv_id=canonical_conv,
sender_id=sender_id,
text=text,
message_id=event_id,
raw=item,
)
def _prune_dedup(self) -> None:
if len(self._seen_event_ids) <= DEDUP_MAX_SIZE:
return
# Drop the oldest half.
items = sorted(self._seen_event_ids.items(), key=lambda kv: kv[1])
for eid, _ in items[: len(items) // 2]:
self._seen_event_ids.pop(eid, None)
async def _register_signing_keys(self, events: List[Dict[str, Any]]) -> None:
"""Fetch new senders' public keys into the SDK's signing-key store."""
assert self._api is not None and self._crypto is not None
senders = {
str(e.get("sender_id"))
for e in events
if e.get("sender_id") and str(e.get("sender_id")) != self._bot_user_id
} - self._known_senders
for sender_id in senders:
try:
for pk in await self._api.get_public_keys(sender_id):
self._signing_keys.append(
{
"user_id": sender_id,
"public_key_version": str(pk.get("public_key_version") or ""),
"public_key": pk.get("signing_public_key") or "",
"identity_public_key": pk.get("public_key") or "",
"identity_public_key_signature": pk.get("identity_public_key_signature") or "",
}
)
self._known_senders.add(sender_id)
except Exception:
logger.warning("[xchat] public-key fetch failed sender=%s", sender_id)
if senders and self._signing_keys:
# The SDK store is replaced wholesale — push the full roster.
self._crypto.set_signing_keys(self._signing_keys)
# -- Inbound dispatch --------------------------------------------------------
def _message_matches_mention_patterns(self, text: str) -> bool:
return any(p.search(text) for p in self._mention_patterns)
def _clean_mention_text(self, text: str) -> str:
"""Strip ONLY a leading wake-word match — never mid-prompt words."""
stripped = text.lstrip()
for p in self._mention_patterns:
m = p.match(stripped)
if m:
return stripped[m.end():].lstrip()
return text
async def _dispatch_inbound(
self,
*,
conv_id: str,
sender_id: str,
text: str,
message_id: str,
raw: Dict[str, Any],
) -> None:
is_group = conv_id.startswith("g")
chat_type = "group" if is_group else "dm"
if is_group and self.require_mention:
if not self._message_matches_mention_patterns(text):
return
text = self._clean_mention_text(text)
if not text:
return
source = self.build_source(
chat_id=conv_id,
chat_name=None,
chat_type=chat_type,
user_id=sender_id,
user_name=None,
message_id=message_id,
)
event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
raw_message=raw,
message_id=message_id,
)
await self.handle_message(event)
# -- Outbound ------------------------------------------------------------------
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
if self._api is None or self._crypto is None:
return SendResult(success=False, error="xchat adapter not connected")
if len(content) > MAX_MESSAGE_LENGTH:
content = content[:MAX_MESSAGE_LENGTH]
try:
body = self._crypto.encrypt_text(chat_id, content)
except ValueError:
# No verified conversation key cached yet. For a 1:1, the key
# cache seeds from the conversation backlog; a brand-new
# conversation the bot initiates needs a key-change first —
# out of scope for reply flows (the poll loop always seeds
# keys before we ever reply).
return SendResult(
success=False,
error=(
"No verified conversation key for this conversation yet. "
"The key cache seeds from inbound events — reply flows "
"always have it; initiating brand-new conversations is "
"not supported yet."
),
)
except Exception as e:
return SendResult(success=False, error=f"encrypt failed: {e}")
try:
out = await self._api.send_message(chat_id, body)
except XChatApiError as e:
logger.warning("[xchat] send failed conv=%s: %s", chat_id, e)
return SendResult(success=False, error=str(e))
data = out.get("data") or {}
msg_id = str(data.get("message_id") or body.get("message_id") or "")
# Suppress the echo when it comes back around the poll loop.
for eid_key in ("event_id", "id"):
eid = data.get(eid_key)
if eid:
self._seen_event_ids[str(eid)] = time.time()
return SendResult(success=True, message_id=msg_id)
async def send_typing(self, chat_id: str, metadata=None) -> None:
if self._api is None:
return
try:
await self._api.send_typing(chat_id)
except Exception:
pass # best-effort
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
chat_type = "group" if str(chat_id).startswith("g") else "dm"
return {"name": str(chat_id), "type": chat_type, "chat_id": str(chat_id)}
# ---------------------------------------------------------------------------
# Plugin registration
def _env_enablement() -> Optional[dict]:
"""Seed ``PlatformConfig.extra`` from env vars during gateway config load."""
token = os.getenv("XCHAT_ACCESS_TOKEN", "").strip()
if not token:
return None
seed: dict = {"access_token": token}
for env, key in (
("XCHAT_REFRESH_TOKEN", "refresh_token"),
("XCHAT_CLIENT_ID", "client_id"),
("XCHAT_CLIENT_SECRET", "client_secret"),
("XCHAT_USER_ID", "user_id"),
("XCHAT_SIGNING_KEY_VERSION", "signing_key_version"),
("XCHAT_CONVERSATION_IDS", "conversation_ids"),
("XCHAT_POLL_INTERVAL", "poll_interval"),
):
val = os.getenv(env, "").strip()
if val:
seed[key] = val
home = os.getenv("XCHAT_HOME_CHANNEL", "").strip()
if home:
seed["home_channel"] = {
"chat_id": home,
"name": os.getenv("XCHAT_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[Any]] = None,
force_document: bool = False,
) -> Dict[str, Any]:
"""Out-of-process encrypted send for cron / send_message_tool.
Opens an ephemeral API client + Chat XDK session, seeds the
conversation key from the conversation's event backlog, encrypts,
sends, and closes. ``thread_id`` / ``media_files`` are accepted for
signature parity X Chat has no thread primitive and media requires
the full streaming-encrypt flow (not wired yet).
"""
if not HTTPX_AVAILABLE:
return {"error": "xchat standalone send: httpx not installed"}
extra = getattr(pconfig, "extra", {}) or {}
access_token = (extra.get("access_token") or os.getenv("XCHAT_ACCESS_TOKEN", "")).strip()
if not access_token:
return {"error": "xchat standalone send: XCHAT_ACCESS_TOKEN not configured"}
key_blob = _read_key_blob()
if not key_blob:
return {"error": "xchat standalone send: private-key blob missing — run `hermes xchat setup`"}
user_id = str(extra.get("user_id") or os.getenv("XCHAT_USER_ID", "")).strip()
key_version = str(
extra.get("signing_key_version") or os.getenv("XCHAT_SIGNING_KEY_VERSION", "1")
).strip() or "1"
api = XChatApi(
access_token,
refresh_token=(extra.get("refresh_token") or os.getenv("XCHAT_REFRESH_TOKEN", "")).strip(),
client_id=(extra.get("client_id") or os.getenv("XCHAT_CLIENT_ID", "")).strip(),
client_secret=(extra.get("client_secret") or os.getenv("XCHAT_CLIENT_SECRET", "")).strip(),
)
try:
if not user_id:
me = await api.get_my_user()
user_id = str(me.get("id") or "")
if not user_id:
return {"error": "xchat standalone send: could not resolve bot user id"}
crypto = XChatCrypto()
crypto.load_keys(key_blob, key_version)
crypto.set_identity(user_id)
crypto.set_cache_keys(True)
# Seed the conversation key from the backlog (KeyChange events).
page = await api.get_events(chat_id, max_results=50)
events_b64 = [e["encoded_event"] for e in (page.get("data") or []) if e.get("encoded_event")]
canonical = chat_id
if events_b64:
try:
batch = crypto.decrypt_batch(events_b64)
for m in batch.get("messages") or []:
conv = (m.get("event") or {}).get("conversation_id")
if conv:
canonical = str(conv)
break
except Exception as e:
logger.debug("[xchat] standalone backlog decrypt: %s", e)
try:
body = crypto.encrypt_text(canonical, message)
except ValueError:
return {
"error": (
"xchat: no verified conversation key — the target must have "
"an existing conversation with the bot"
)
}
out = await api.send_message(canonical, body)
data = out.get("data") or {}
return {
"success": True,
"platform": "xchat",
"chat_id": canonical,
"message_id": str(data.get("message_id") or body.get("message_id") or ""),
}
except XChatApiError as e:
return {"error": f"xchat standalone send failed: {e}"}
except Exception as e:
return {"error": f"xchat standalone send failed: {e}"}
finally:
await api.aclose()
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin loader at startup."""
from . import cli as _cli
ctx.register_platform(
name="xchat",
label="X Chat (encrypted DMs)",
adapter_factory=lambda cfg: XChatAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=["XCHAT_ACCESS_TOKEN"],
install_hint=(
"Run: hermes xchat setup (stores the OAuth2 user token, registers "
"the bot's E2EE keys, saves the private-key blob)."
),
setup_fn=_cli.gateway_setup,
env_enablement_fn=_env_enablement,
cron_deliver_env_var="XCHAT_HOME_CHANNEL",
standalone_sender_fn=_standalone_send,
allowed_users_env="XCHAT_ALLOWED_USERS",
allow_all_env="XCHAT_ALLOW_ALL_USERS",
max_message_length=MAX_MESSAGE_LENGTH,
emoji="𝕏",
pii_safe=False,
allow_update_command=True,
platform_hint=(
"You are communicating via X Chat — X's end-to-end encrypted "
"direct messages. Treat replies like regular chat messages: "
"short and conversational. Markdown is NOT rendered — use plain "
"text. User identifiers are numeric X user ids; conversation ids "
"starting with 'g' are group chats."
),
)
ctx.register_cli_command(
name="xchat",
help="Set up and manage the X Chat (encrypted X DMs) integration",
setup_fn=_cli.register_cli,
handler_fn=_cli.dispatch,
)

View file

@ -0,0 +1,278 @@
"""Async X API client for the X Chat platform adapter.
A thin httpx-based client for the handful of X API v2 endpoints the adapter
needs. The official ``xdk`` Python client is synchronous (requests-based),
which doesn't fit the async gateway — the Chat endpoints are plain
OAuth2-bearer REST, so direct calls are wire-identical. Only the E2EE layer
needs a real SDK (``chatxdk``, see ``crypto.py``).
Also owns OAuth2 token refresh: X user access tokens expire (~2h). When a
refresh token + client id are configured, :meth:`XChatApi.ensure_token`
renews the access token through ``POST /2/oauth2/token`` and persists the
rotated pair via a caller-supplied callback (X rotates refresh tokens on
every use).
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any, Awaitable, Callable, Optional
try:
import httpx
HTTPX_AVAILABLE = True
except ImportError: # pragma: no cover - httpx is a core Hermes dependency
HTTPX_AVAILABLE = False
httpx = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
BASE_URL = "https://api.x.com"
# Fields we always request on the events endpoint — the decrypt path needs
# encoded_event; sender_id/conversation_id drive session routing.
_EVENT_FIELDS = (
"conversation_id,created_at_msec,encoded_event,id,sender_id"
)
# Refresh the access token this many seconds before its reported expiry.
_TOKEN_REFRESH_SLACK = 300
class XChatApiError(Exception):
"""Raised for non-2xx responses from the X API."""
def __init__(self, status: int, detail: str) -> None:
self.status = status
self.detail = detail
super().__init__(f"X API HTTP {status}: {detail}")
class XChatRateLimited(XChatApiError):
"""HTTP 429 — carries the reset epoch when the API reports one."""
def __init__(self, detail: str, reset_epoch: Optional[int]) -> None:
super().__init__(429, detail)
self.reset_epoch = reset_epoch
class XChatApi:
"""Async client bound to one bot account's OAuth2 user token."""
def __init__(
self,
access_token: str,
*,
refresh_token: str = "",
client_id: str = "",
client_secret: str = "",
token_expires_at: float = 0.0,
on_token_refresh: Optional[Callable[[str, str], Awaitable[None]]] = None,
base_url: str = BASE_URL,
client: Optional["httpx.AsyncClient"] = None,
) -> None:
self._access_token = access_token
self._refresh_token = refresh_token
self._client_id = client_id
self._client_secret = client_secret
# 0 = unknown expiry; refresh only reactively on 401.
self._token_expires_at = token_expires_at
self._on_token_refresh = on_token_refresh
self._base_url = base_url.rstrip("/")
self._client = client
self._refresh_lock = asyncio.Lock()
# -- lifecycle -----------------------------------------------------------
def _http(self) -> "httpx.AsyncClient":
if self._client is None:
self._client = httpx.AsyncClient(timeout=30.0)
return self._client
async def aclose(self) -> None:
if self._client is not None:
try:
await self._client.aclose()
except Exception:
pass
self._client = None
# -- auth ----------------------------------------------------------------
@property
def can_refresh(self) -> bool:
return bool(self._refresh_token and self._client_id)
async def ensure_token(self) -> None:
"""Proactively refresh the access token when close to expiry."""
if not self.can_refresh or not self._token_expires_at:
return
if time.time() < self._token_expires_at - _TOKEN_REFRESH_SLACK:
return
await self._refresh_access_token()
async def _refresh_access_token(self) -> None:
"""POST /2/oauth2/token (refresh_token grant). Rotates both tokens."""
async with self._refresh_lock:
# Another task may have refreshed while we waited on the lock.
if self._token_expires_at and time.time() < self._token_expires_at - _TOKEN_REFRESH_SLACK:
return
data = {
"grant_type": "refresh_token",
"refresh_token": self._refresh_token,
"client_id": self._client_id,
}
auth = None
if self._client_secret:
auth = (self._client_id, self._client_secret)
resp = await self._http().post(
f"{self._base_url}/2/oauth2/token", data=data, auth=auth
)
if resp.status_code >= 300:
raise XChatApiError(resp.status_code, resp.text[:300])
tok = resp.json()
self._access_token = tok.get("access_token") or self._access_token
# X rotates refresh tokens on every use — always adopt the new one.
new_refresh = tok.get("refresh_token")
if new_refresh:
self._refresh_token = new_refresh
expires_in = tok.get("expires_in")
if expires_in:
self._token_expires_at = time.time() + float(expires_in)
logger.info("[xchat] OAuth2 access token refreshed")
if self._on_token_refresh is not None:
try:
await self._on_token_refresh(self._access_token, self._refresh_token)
except Exception:
logger.warning("[xchat] token persist callback failed", exc_info=True)
# -- request core ----------------------------------------------------------
async def _request(
self,
method: str,
path: str,
*,
params: Optional[dict[str, Any]] = None,
json_body: Optional[dict[str, Any]] = None,
_retried_auth: bool = False,
) -> dict[str, Any]:
await self.ensure_token()
headers = {"Authorization": f"Bearer {self._access_token}"}
resp = await self._http().request(
method,
f"{self._base_url}{path}",
params=params,
json=json_body,
headers=headers,
)
if resp.status_code == 401 and self.can_refresh and not _retried_auth:
# Reactive refresh — covers the no-known-expiry case.
await self._refresh_access_token()
return await self._request(
method, path, params=params, json_body=json_body, _retried_auth=True
)
if resp.status_code == 429:
reset = resp.headers.get("x-user-limit-24hour-reset") or resp.headers.get(
"x-rate-limit-reset"
)
raise XChatRateLimited(
resp.text[:300], int(reset) if reset and reset.isdigit() else None
)
if resp.status_code >= 300:
raise XChatApiError(resp.status_code, resp.text[:300])
if not resp.content:
return {}
try:
return resp.json()
except ValueError:
return {}
# -- identity ------------------------------------------------------------
async def get_my_user(self) -> dict[str, Any]:
"""GET /2/users/me — the bot account's own id/username."""
out = await self._request("GET", "/2/users/me")
return out.get("data") or {}
async def get_public_keys(self, user_id: str) -> list[dict[str, Any]]:
"""GET /2/users/{id}/public_keys — a user's registered E2EE keys."""
out = await self._request("GET", f"/2/users/{user_id}/public_keys")
data = out.get("data") or []
return data if isinstance(data, list) else [data]
async def add_public_key(self, user_id: str, body: dict[str, Any]) -> dict[str, Any]:
"""POST /2/users/{id}/public_keys — register the bot's public keys.
Rate limited to a handful of writes per 24h; raises
:class:`XChatRateLimited` on 429 so callers stop instead of burning
the daily budget.
"""
return await self._request("POST", f"/2/users/{user_id}/public_keys", json_body=body)
# -- conversations ---------------------------------------------------------
@staticmethod
def _conv_path_id(conversation_id: str) -> str:
# Events embed the colon form; URL paths take the hyphen form.
return str(conversation_id).replace(":", "-")
async def get_conversations(
self, *, max_results: int = 100, pagination_token: Optional[str] = None
) -> dict[str, Any]:
"""GET /2/chat/conversations — list the bot's conversations."""
params: dict[str, Any] = {"max_results": max_results}
if pagination_token:
params["pagination_token"] = pagination_token
return await self._request("GET", "/2/chat/conversations", params=params)
async def get_events(
self,
conversation_id: str,
*,
max_results: int = 50,
pagination_token: Optional[str] = None,
) -> dict[str, Any]:
"""GET /2/chat/conversations/{id}/events — raw (encrypted) events."""
params: dict[str, Any] = {
"max_results": max_results,
"chat_message_event.fields": _EVENT_FIELDS,
}
if pagination_token:
params["pagination_token"] = pagination_token
return await self._request(
"GET",
f"/2/chat/conversations/{self._conv_path_id(conversation_id)}/events",
params=params,
)
async def send_message(self, conversation_id: str, body: dict[str, str]) -> dict[str, Any]:
"""POST /2/chat/conversations/{id}/messages — send encrypted ciphertext.
``body`` is the dict produced by ``XChatCrypto.encrypt_text``. For a
1:1 conversation ``conversation_id`` may be the recipient's bare user
id; the server derives the canonical conversation id.
"""
return await self._request(
"POST",
f"/2/chat/conversations/{self._conv_path_id(conversation_id)}/messages",
json_body=body,
)
async def send_typing(self, conversation_id: str) -> None:
"""POST /2/chat/conversations/{id}/typing — best-effort typing indicator."""
await self._request(
"POST",
f"/2/chat/conversations/{self._conv_path_id(conversation_id)}/typing",
)
async def add_conversation_keys(
self, conversation_id: str, body: dict[str, Any]
) -> dict[str, Any]:
"""POST /2/chat/conversations/{id}/keys — initialize/rotate a conversation key."""
return await self._request(
"POST",
f"/2/chat/conversations/{self._conv_path_id(conversation_id)}/keys",
json_body=body,
)

View file

@ -0,0 +1,335 @@
"""``hermes xchat ...`` CLI subcommands — registered by the plugin via
``ctx.register_cli_command()``.
Subcommands:
setup full first-time setup (token + user id + key generation/registration)
register (re)register the E2EE public keys only
status show token / key / registration state
Key registration is a rare, rate-limited write (only a few per 24h per
account). ``setup`` is safe to re-run: the private-key blob and the
registration payload are persisted to ``~/.hermes/xchat/`` BEFORE any
network call, so an interrupted run resumes the same identity instead of
minting a new one and burning the daily budget.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from hermes_cli.colors import Colors, color
def _state_dir() -> Path:
from hermes_constants import get_hermes_home
d = get_hermes_home() / "xchat"
d.mkdir(parents=True, exist_ok=True)
return d
def _blob_path() -> Path:
return _state_dir() / "private_keys.b64"
def _marker_path() -> Path:
return _state_dir() / "registration.json"
def _read_marker() -> dict:
try:
return json.loads(_marker_path().read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _write_marker(marker: dict) -> None:
_marker_path().write_text(json.dumps(marker, indent=2) + "\n", encoding="utf-8")
# ---------------------------------------------------------------------------
# argparse wiring
def register_cli(parser: argparse.ArgumentParser) -> None:
"""Wire up `hermes xchat ...` subcommands."""
subs = parser.add_subparsers(dest="xchat_command", required=False)
p_setup = subs.add_parser(
"setup",
help="First-time setup (OAuth token + E2EE key generation/registration)",
)
p_setup.add_argument(
"--force",
action="store_true",
help="Generate a NEW identity even if one is already registered (dangerous)",
)
subs.add_parser("register", help="(Re)register the E2EE public keys with the X API")
subs.add_parser("status", help="Show token / key / registration state")
parser.set_defaults(func=dispatch)
def dispatch(args: argparse.Namespace) -> int:
sub = getattr(args, "xchat_command", None)
if sub in (None, "status"):
return cmd_status()
if sub == "setup":
return cmd_setup(force=getattr(args, "force", False))
if sub == "register":
return cmd_register(force=False)
print(color(f"Unknown xchat subcommand: {sub}", Colors.RED))
return 1
def gateway_setup() -> None:
"""Zero-arg hook for the unified `hermes gateway setup` wizard."""
cmd_setup(force=False)
# ---------------------------------------------------------------------------
# Helpers
def _get_env(key: str) -> str:
val = os.getenv(key, "").strip()
if val:
return val
# Fall back to the persisted .env (the CLI may run before env load).
try:
from hermes_cli.config import get_env_value
return (get_env_value(key) or "").strip()
except Exception:
return ""
def _save_env(key: str, value: str) -> None:
from hermes_cli.config import save_env_value
save_env_value(key, value)
os.environ[key] = value
def _run(coro):
return asyncio.run(coro)
async def _fetch_user_id(api) -> str:
me = await api.get_my_user()
return str(me.get("id") or "")
# ---------------------------------------------------------------------------
# Commands
def cmd_status() -> int:
token = _get_env("XCHAT_ACCESS_TOKEN")
user_id = _get_env("XCHAT_USER_ID")
marker = _read_marker()
blob = _blob_path()
print(color("X Chat integration status", Colors.BOLD))
print(f" access token: {'✓ set' if token else '✗ missing'}")
print(f" refresh token: {'✓ set' if _get_env('XCHAT_REFRESH_TOKEN') else ' not set (no auto-renew)'}")
print(f" bot user id: {user_id or ' not set'}")
print(f" key blob: {'' + str(blob) if blob.exists() else '✗ missing'}")
if marker.get("registered"):
print(f" registration: ✓ version {marker.get('version')} ({marker.get('registered_at', '?')})")
else:
print(" registration: ✗ not registered — run `hermes xchat setup`")
return 0
def cmd_setup(*, force: bool) -> int:
print(color("X Chat setup — end-to-end encrypted X DMs", Colors.BOLD))
print(
"You need an X developer app with OAuth 2.0 user-context enabled and a\n"
"user access token carrying: dm.read dm.write users.read tweet.read\n"
"(offline.access too if you want refresh tokens).\n"
"Docs: https://docs.x.com/xchat/getting-started\n"
)
# 1. Token
token = _get_env("XCHAT_ACCESS_TOKEN")
if token:
print("Found existing XCHAT_ACCESS_TOKEN.")
else:
import getpass
token = getpass.getpass("Paste the OAuth2 user access token: ").strip()
if not token:
print(color("No token provided — aborting.", Colors.RED))
return 1
_save_env("XCHAT_ACCESS_TOKEN", token)
refresh = _get_env("XCHAT_REFRESH_TOKEN")
if not refresh:
import getpass
refresh = getpass.getpass(
"Paste the OAuth2 refresh token (optional, Enter to skip): "
).strip()
if refresh:
_save_env("XCHAT_REFRESH_TOKEN", refresh)
client_id = input("X app OAuth2 client id (needed for refresh): ").strip()
if client_id:
_save_env("XCHAT_CLIENT_ID", client_id)
# 2. Bot user id
from .api import XChatApi, XChatApiError
api = XChatApi(token)
user_id = _get_env("XCHAT_USER_ID")
if not user_id:
try:
user_id = _run(_fetch_user_id(api))
except XChatApiError as e:
print(color(f"Could not resolve the bot's user id: {e}", Colors.RED))
print("Check the token's scopes (users.read) and validity.")
return 1
finally:
_run(api.aclose())
api = None
if not user_id:
print(color("Could not resolve the bot's user id.", Colors.RED))
return 1
_save_env("XCHAT_USER_ID", user_id)
print(f"Bot user id: {user_id}")
else:
_run(api.aclose())
api = None
# 3. Keys + registration
rc = cmd_register(force=force)
if rc != 0:
return rc
print()
print(color("Setup complete.", Colors.GREEN))
print("Enable the platform and start the gateway:")
print(" hermes gateway start")
return 0
def cmd_register(*, force: bool) -> int:
"""Generate + register the E2EE keys. Re-runnable / resume-safe."""
token = _get_env("XCHAT_ACCESS_TOKEN")
if not token:
print(color("XCHAT_ACCESS_TOKEN not set — run `hermes xchat setup` first.", Colors.RED))
return 1
user_id = _get_env("XCHAT_USER_ID")
from .api import XChatApi, XChatApiError, XChatRateLimited
from .crypto import XChatCrypto
marker = _read_marker()
if marker.get("registered") and not force:
print(
f"Already registered (key version {marker.get('version')}). "
"Use `hermes xchat setup --force` to mint a NEW identity."
)
return 0
async def _register() -> int:
nonlocal user_id
api = XChatApi(
token,
refresh_token=_get_env("XCHAT_REFRESH_TOKEN"),
client_id=_get_env("XCHAT_CLIENT_ID"),
client_secret=_get_env("XCHAT_CLIENT_SECRET"),
)
try:
if not user_id:
user_id = await _fetch_user_id(api)
if not user_id:
print(color("Could not resolve the bot's user id.", Colors.RED))
return 1
_save_env("XCHAT_USER_ID", user_id)
crypto = XChatCrypto()
blob_path = _blob_path()
resuming = blob_path.exists() and marker.get("body") and not force
if resuming:
crypto.load_keys(blob_path.read_text(encoding="utf-8").strip())
body = marker["body"]
version = str(marker.get("version") or "1")
print(f"Resuming the saved identity ({blob_path}).")
else:
payload = crypto.generate_and_register_payload()
body = payload["registration"]
version = payload["version"]
blob_path.write_text(payload["private_keys_b64"] + "\n", encoding="utf-8")
try:
blob_path.chmod(0o600)
except OSError:
pass
_write_marker(
{"registered": False, "user_id": user_id, "version": version, "body": body}
)
print(f"Generated a new identity; private keys saved to {blob_path} (mode 600).")
our_public_key = body["public_key"]["public_key"]
# Reconcile: adopt an already-registered key instead of re-POSTing
# (a prior POST may have applied server-side after erroring).
try:
existing = await api.get_public_keys(user_id)
except XChatApiError:
existing = []
already = next(
(k for k in existing if k.get("public_key") == our_public_key), None
)
if already:
version = str(already.get("public_key_version") or version)
print(f"Public key already registered (version {version}); skipping POST.")
else:
print(f"Registering public key version {version}")
try:
resp = await api.add_public_key(user_id, body)
except XChatRateLimited as limited:
when = (
datetime.fromtimestamp(limited.reset_epoch, tz=timezone.utc).isoformat()
if limited.reset_epoch
else "the next window"
)
print(
color(
"Registration is rate limited (429). The daily budget is "
f"exhausted; wait until {when} and re-run — the saved "
"identity resumes, so no budget is wasted.",
Colors.RED,
)
)
return 1
data = resp.get("data") or {}
if isinstance(data, list):
data = data[0] if data else {}
version = str(data.get("public_key_version") or version)
_save_env("XCHAT_SIGNING_KEY_VERSION", version)
_write_marker(
{
"registered": True,
"user_id": user_id,
"version": version,
"body": body,
"registered_at": datetime.now(timezone.utc).isoformat(),
}
)
print(color(f"Key registration complete (version {version}).", Colors.GREEN))
return 0
finally:
await api.aclose()
try:
return _run(_register())
except XChatApiError as e:
print(color(f"Registration failed: {e}", Colors.RED))
return 1

View file

@ -0,0 +1,169 @@
"""Crypto core for the X Chat platform adapter.
A thin, network-free wrapper around the ``chat_xdk`` binding. Everything
that touches the Chat XDK lives here so it can be unit-tested with a fake
``Chat`` object and so the adapter/API layers stay import-light. The SDK is
lazy-installed at first use via ``tools.lazy_deps`` (feature key
``platform.xchat``).
Responsibilities:
* key management -> :meth:`XChatCrypto.load_keys` /
:meth:`XChatCrypto.generate_and_register_payload`
* session identity -> :meth:`XChatCrypto.set_identity`
* signing-key roster -> :meth:`XChatCrypto.set_signing_keys`
* message encryption -> :meth:`XChatCrypto.encrypt_text`
* event decryption -> :meth:`XChatCrypto.decrypt_batch` (decrypt_events)
and :meth:`XChatCrypto.decrypt_one` (decrypt_event)
The decrypted-event dict shape follows the Chat XDK: ``{"type": "Message",
"id": ..., "sender_id": ..., "conversation_id": ..., "content": {"text":
...}}`` for messages, ``{"type": "KeyChange", ...}`` for conversation-key
rotations.
"""
from __future__ import annotations
import base64
import logging
from typing import Any, Optional
logger = logging.getLogger(__name__)
def _as_dict(obj: Any) -> dict[str, Any]:
"""Decrypted events come back as native objects; normalise to a dict."""
if isinstance(obj, dict):
return obj
if hasattr(obj, "model_dump"):
return obj.model_dump()
try:
return dict(obj)
except Exception:
return {}
def _load_chat_class():
"""Import (lazy-installing if needed) and return ``chat_xdk.Chat``."""
try:
from chat_xdk import Chat # type: ignore[import-not-found]
return Chat
except ImportError:
pass
# Lazy-install path — same pattern as the telegram/matrix platform plugins.
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("platform.xchat", prompt=False)
from chat_xdk import Chat # type: ignore[import-not-found]
return Chat
class XChatCrypto:
"""Wraps a single unlocked ``chat_xdk.Chat`` instance for one bot identity."""
def __init__(self, chat: Any = None) -> None:
# ``chat`` injection keeps unit tests free of the native SDK.
self.chat = chat if chat is not None else _load_chat_class()()
self.signing_key_version: str = "1"
self._identity_set = False
# -- Key management -----------------------------------------------------
def load_keys(self, private_keys_b64: str, signing_key_version: str = "1") -> None:
"""Import an existing private-key blob (from ``export_keys``) and adopt it.
``private_keys_b64`` is the base64 blob produced during registration
(``hermes xchat setup``). Raises on a malformed blob.
"""
blob = base64.b64decode(private_keys_b64.strip())
self.chat.import_keys(blob, version=signing_key_version)
self.signing_key_version = str(signing_key_version)
def set_identity(self, user_id: str) -> None:
"""Set the session identity — every later encrypt call signs as this user."""
self.chat.set_identity(str(user_id), self.signing_key_version)
self._identity_set = True
def set_cache_keys(self, enabled: bool = True) -> None:
"""Opt in to the SDK's verified conversation-key cache."""
self.chat.set_cache_keys(enabled)
def set_signing_keys(self, signing_keys: list[dict[str, str]]) -> None:
"""Replace the SDK's participant signing-key store (full roster each call)."""
self.chat.set_signing_keys(signing_keys)
def generate_and_register_payload(self) -> dict[str, Any]:
"""Generate fresh keypairs for a brand-new bot identity.
Returns the registration body for ``POST /2/users/{id}/public_keys``
plus the exported private-key blob (base64) to persist locally.
Used by ``hermes xchat setup`` only the adapter never generates keys.
"""
reg = self.chat.generate_keypairs()
version = str(reg.version) if getattr(reg, "version", None) is not None else "1"
body = {
"public_key": {
"public_key": reg.public_key.public_key,
"signing_public_key": reg.public_key.signing_public_key,
"identity_public_key_signature": reg.public_key.identity_public_key_signature,
"signing_public_key_signature": reg.public_key.signing_public_key_signature,
"registration_method": reg.public_key.registration_method,
},
"version": version,
"generate_version": bool(getattr(reg, "generate_version", False)),
}
exported = self.chat.export_keys()
blob_b64 = base64.b64encode(bytes(exported)).decode("ascii") if exported else ""
return {"registration": body, "version": version, "private_keys_b64": blob_b64}
# -- Decryption ----------------------------------------------------------
def decrypt_batch(self, events_b64: list[str]) -> dict[str, Any]:
"""Batch path — initial backlog load and KeyChange processing.
``decrypt_events`` extracts conversation keys from any KeyChange
events in the batch (feeding the SDK's key cache when enabled), then
decrypts every message. Signing keys come from the
``set_signing_keys`` store.
"""
result = self.chat.decrypt_events(events_b64, None)
messages = [
{"event": _as_dict(m.get("event") if isinstance(m, dict) else m)}
for m in (result.get("messages") or [])
]
return {
"messages": messages,
"conversation_keys": result.get("conversation_keys") or {},
"errors": result.get("errors") or {},
}
def decrypt_one(
self, event_b64: str, conversation_keys: Optional[dict[str, bytes]] = None
) -> dict[str, Any]:
"""Single-event path — per-poll decryption with cached conversation keys."""
return _as_dict(self.chat.decrypt_event(event_b64, conversation_keys, None))
# -- Encryption ----------------------------------------------------------
def encrypt_text(self, conversation_id: str, text: str) -> dict[str, str]:
"""Encrypt + sign ``text``, returning the X API send-message body.
The conversation key is resolved from the SDK's verified-key cache
(``set_cache_keys``); the sender comes from ``set_identity``. Raises
``ValueError`` when no verified key is cached for the conversation.
"""
payload = self.chat.encrypt_message(str(conversation_id), text)
return {
"message_id": payload.message_id,
"encoded_message_create_event": payload.encrypted_content,
"encoded_message_event_signature": payload.encoded_event_signature,
}
def message_text(event: dict[str, Any]) -> Optional[str]:
"""Pull the plain text out of a decrypted Message event, or None."""
if event.get("type") != "Message":
return None
content = event.get("content") or {}
if isinstance(content, dict):
return content.get("text")
return None

View file

@ -0,0 +1,80 @@
name: xchat-platform
label: X Chat (encrypted DMs)
kind: platform
version: 0.1.0
description: >
X Chat gateway adapter for Hermes Agent.
Connects the agent to X's end-to-end encrypted direct messages via the
official X Chat API. Message bodies are encrypted/decrypted locally with
the Chat XDK (chatxdk) — X only ever routes ciphertext. Inbound messages
arrive through a polling loop over the conversation-events endpoint;
outbound replies are encrypted, signed, and POSTed back.
One-time onboarding runs through `hermes xchat setup`: it stores the
OAuth2 user token, derives the bot's user id, generates + registers the
E2EE keypairs, and persists the private-key blob under
``~/.hermes/xchat/`` (mode 600).
author: NousResearch
requires_env:
- name: XCHAT_ACCESS_TOKEN
description: "OAuth2 user access token with dm.read, dm.write, users.read, tweet.read scopes (set by `hermes xchat setup`)"
prompt: "X OAuth2 user access token"
url: "https://developer.x.com/en/portal/dashboard"
password: true
optional_env:
- name: XCHAT_REFRESH_TOKEN
description: "OAuth2 refresh token — lets the adapter renew the ~2h access token automatically (requires XCHAT_CLIENT_ID)"
prompt: "X OAuth2 refresh token (or empty)"
password: true
- name: XCHAT_CLIENT_ID
description: "OAuth2 app client id — required for automatic token refresh"
prompt: "X app OAuth2 client id (or empty)"
password: false
- name: XCHAT_CLIENT_SECRET
description: "OAuth2 app client secret — only for confidential clients"
prompt: "X app OAuth2 client secret (or empty)"
password: true
- name: XCHAT_USER_ID
description: "The bot account's numeric X user id (derived automatically by `hermes xchat setup`)"
prompt: "Bot X user id (or empty to derive)"
password: false
- name: XCHAT_SIGNING_KEY_VERSION
description: "Registered public-key version for signing (written by `hermes xchat setup`)"
prompt: "Signing key version (default 1)"
password: false
- name: XCHAT_PRIVATE_KEYS_B64
description: "Base64 private-key blob from the Chat XDK export — overrides the blob file under ~/.hermes/xchat/"
prompt: "Private-key blob (or empty to use the blob file)"
password: true
- name: XCHAT_ALLOWED_USERS
description: "Comma-separated numeric X user ids allowed to talk to the bot"
prompt: "Allowed user ids (comma-separated)"
password: false
- name: XCHAT_ALLOW_ALL_USERS
description: "Allow any sender to trigger the bot (dev only — disables allowlist)"
prompt: "Allow all users? (true/false)"
password: false
- name: XCHAT_CONVERSATION_IDS
description: "Comma-separated conversation ids to poll (omit to auto-discover all conversations)"
prompt: "Pinned conversation ids (or empty)"
password: false
- name: XCHAT_POLL_INTERVAL
description: "Seconds between event polls per cycle (default 10)"
prompt: "Poll interval seconds (default 10)"
password: false
- name: XCHAT_REQUIRE_MENTION
description: "Ignore group-chat messages unless they match a mention wake word (true/false, default false)"
prompt: "Require a mention in group chats?"
password: false
- name: XCHAT_MENTION_PATTERNS
description: "Mention wake-word regexes for group chats (JSON list or comma/newline-separated; defaults to Hermes wake words)"
prompt: "Group mention patterns"
password: false
- name: XCHAT_HOME_CHANNEL
description: "Default X Chat target for cron / notification delivery: conversation id or bare numeric user id"
prompt: "Home conversation/user id (or empty)"
password: false
- name: XCHAT_HOME_CHANNEL_NAME
description: "Human label for the home channel (defaults to the id)"
prompt: "Home channel display name (or empty)"
password: false

View file

@ -166,6 +166,10 @@ messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1",
cron = [] # croniter is now a core dependency; this extra kept for back-compat
slack = ["slack-bolt==1.29.0", "slack-sdk==3.43.0", "aiohttp==3.14.1"]
matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0", "aiohttp==3.14.1"] # aiohttp 3.14.1: CVE-2026-34993(RCE)/47265 + 34513/34518/34519/34520/34525 (mautrix/aiohttp-socks only cap aiohttp<4 / >=3.10, so pin the patched floor directly)
# X Chat (encrypted X DMs) adapter — the official Chat XDK E2EE binding.
# Lazy-installed at first use via tools/lazy_deps.py (platform.xchat);
# declared here so packagers (Nix, Homebrew) can ship it explicitly.
xchat = ["chatxdk==0.4.1"]
# WeCom callback-mode adapter — parses untrusted XML POST bodies from
# WeCom-controlled callback endpoints, so we use defusedxml (drop-in
# replacement for stdlib xml.etree.ElementTree) to block billion-laughs

View file

@ -0,0 +1,528 @@
"""Unit tests for the X Chat platform plugin.
All tests run offline: the X API layer is replaced with fakes and the Chat
XDK crypto core is replaced with a stub no chatxdk native module, no
network, no gateway process.
"""
from __future__ import annotations
import asyncio
import time
from typing import Any, Dict, List, Optional
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from plugins.platforms.xchat import adapter as xchat_adapter
from plugins.platforms.xchat.adapter import (
XChatAdapter,
_compile_mention_patterns,
_env_enablement,
check_requirements,
)
from plugins.platforms.xchat.crypto import XChatCrypto, message_text
# ---------------------------------------------------------------------------
# Helpers / fakes
class FakeCrypto:
"""Stands in for XChatCrypto — records calls, no native SDK."""
def __init__(self) -> None:
self.encrypted: List[tuple] = []
self.batch_calls: List[List[str]] = []
self.signing_keys: List[Dict[str, str]] = []
self.decrypt_map: Dict[str, Dict[str, Any]] = {}
self.fail_encrypt: Optional[Exception] = None
def decrypt_one(self, event_b64, conversation_keys=None):
return self.decrypt_map[event_b64]
def decrypt_batch(self, events_b64):
self.batch_calls.append(list(events_b64))
return {"messages": [], "conversation_keys": {"keys": {"1": b"k"}}, "errors": {}}
def encrypt_text(self, conversation_id, text):
if self.fail_encrypt is not None:
raise self.fail_encrypt
self.encrypted.append((conversation_id, text))
return {
"message_id": "mid-1",
"encoded_message_create_event": "ZW5j",
"encoded_message_event_signature": "c2ln",
}
def set_signing_keys(self, keys):
self.signing_keys = list(keys)
class FakeApi:
"""Stands in for XChatApi — canned responses, records sends."""
def __init__(self) -> None:
self.sent: List[tuple] = []
self.typing: List[str] = []
self.public_keys: Dict[str, List[Dict[str, Any]]] = {}
self.events_pages: Dict[str, Dict[str, Any]] = {}
self.conversations: List[str] = []
async def get_my_user(self):
return {"id": "999"}
async def get_public_keys(self, user_id):
return self.public_keys.get(user_id, [])
async def get_conversations(self, *, max_results=100, pagination_token=None):
return {
"data": [{"conversation_id": c} for c in self.conversations],
"meta": {},
}
async def get_events(self, conversation_id, *, max_results=50, pagination_token=None):
return self.events_pages.get(conversation_id, {"data": []})
async def send_message(self, conversation_id, body):
self.sent.append((conversation_id, body))
return {"data": {"message_id": body.get("message_id", ""), "event_id": "evt-echo"}}
async def send_typing(self, conversation_id):
self.typing.append(conversation_id)
async def aclose(self):
pass
def _make_adapter(monkeypatch: pytest.MonkeyPatch, **extra) -> XChatAdapter:
monkeypatch.setenv("XCHAT_ACCESS_TOKEN", "test-token")
monkeypatch.setenv("XCHAT_USER_ID", "999")
cfg = PlatformConfig(enabled=True, token="", extra=dict(extra))
return XChatAdapter(cfg)
def _wire(adapter: XChatAdapter) -> tuple[FakeApi, FakeCrypto]:
api, crypto = FakeApi(), FakeCrypto()
adapter._api = api
adapter._crypto = crypto
adapter._bot_user_id = "999"
return api, crypto
def _capture(adapter: XChatAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]:
captured: List[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
return captured
# ---------------------------------------------------------------------------
# check_fn / config
def test_check_requirements_needs_token_and_blob(monkeypatch, tmp_path):
monkeypatch.delenv("XCHAT_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("XCHAT_PRIVATE_KEYS_B64", raising=False)
assert check_requirements() is False
monkeypatch.setenv("XCHAT_ACCESS_TOKEN", "tok")
assert check_requirements() is False # no key blob
monkeypatch.setenv("XCHAT_PRIVATE_KEYS_B64", "YmxvYg==")
assert check_requirements() is True
def test_env_enablement_seeds_extra(monkeypatch):
monkeypatch.delenv("XCHAT_ACCESS_TOKEN", raising=False)
assert _env_enablement() is None
monkeypatch.setenv("XCHAT_ACCESS_TOKEN", "tok")
monkeypatch.setenv("XCHAT_USER_ID", "42")
monkeypatch.setenv("XCHAT_HOME_CHANNEL", "123-456")
seed = _env_enablement()
assert seed is not None
assert seed["access_token"] == "tok"
assert seed["user_id"] == "42"
assert seed["home_channel"]["chat_id"] == "123-456"
def test_adapter_reads_config_extra_over_defaults(monkeypatch):
adapter = _make_adapter(
monkeypatch,
poll_interval="30",
conversation_ids="111-222, g333",
)
assert adapter._poll_interval == 30.0
assert adapter._pinned_conversations == ["111-222", "g333"]
assert adapter.platform == Platform("xchat")
def test_poll_interval_floor(monkeypatch):
adapter = _make_adapter(monkeypatch, poll_interval="0.1")
assert adapter._poll_interval == 2.0
# ---------------------------------------------------------------------------
# Mention gating
def test_mention_patterns_json_and_csv():
pats = _compile_mention_patterns('["^bot\\\\b"]')
assert pats[0].pattern == "^bot\\b"
pats = _compile_mention_patterns("alpha, beta")
assert len(pats) == 2
# None → defaults
assert _compile_mention_patterns(None)
@pytest.mark.asyncio
async def test_group_mention_gate(monkeypatch):
monkeypatch.setenv("XCHAT_REQUIRE_MENTION", "true")
adapter = _make_adapter(monkeypatch)
_wire(adapter)
captured = _capture(adapter, monkeypatch)
# Group message without wake word → dropped
await adapter._dispatch_inbound(
conv_id="g123", sender_id="5", text="just chatting", message_id="e1", raw={}
)
assert captured == []
# Group message with wake word → dispatched, wake word stripped
await adapter._dispatch_inbound(
conv_id="g123", sender_id="5", text="hermes what time is it", message_id="e2", raw={}
)
assert len(captured) == 1
assert captured[0].text == "what time is it"
# DMs are never gated
await adapter._dispatch_inbound(
conv_id="111-222", sender_id="5", text="no wake word", message_id="e3", raw={}
)
assert len(captured) == 2
@pytest.mark.asyncio
async def test_dispatch_sets_chat_type(monkeypatch):
adapter = _make_adapter(monkeypatch)
_wire(adapter)
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(
conv_id="g42", sender_id="7", text="hi", message_id="e1", raw={}
)
await adapter._dispatch_inbound(
conv_id="111-999", sender_id="7", text="hi", message_id="e2", raw={}
)
assert captured[0].source.chat_type == "group"
assert captured[1].source.chat_type == "dm"
assert captured[0].message_type == MessageType.TEXT
assert captured[1].source.user_id == "7"
# ---------------------------------------------------------------------------
# Poll loop mechanics
@pytest.mark.asyncio
async def test_backlog_seeds_keys_without_replying(monkeypatch):
adapter = _make_adapter(monkeypatch)
api, crypto = _wire(adapter)
captured = _capture(adapter, monkeypatch)
api.events_pages["111-999"] = {
"data": [
{"id": "e1", "encoded_event": "AAA", "sender_id": "111"},
{"id": "e2", "encoded_event": "BBB", "sender_id": "111"},
]
}
await adapter._poll_conversation("111-999")
# Backlog: batch-decrypted for keys, nothing dispatched, ids marked seen.
assert crypto.batch_calls == [["BBB", "AAA"]] # newest-first reversed
assert captured == []
assert "e1" in adapter._seen_event_ids and "e2" in adapter._seen_event_ids
assert "111-999" in adapter._backlog_loaded
@pytest.mark.asyncio
async def test_new_message_dispatched_after_backlog(monkeypatch):
adapter = _make_adapter(monkeypatch)
api, crypto = _wire(adapter)
captured = _capture(adapter, monkeypatch)
adapter._backlog_loaded.add("111-999")
crypto.decrypt_map["CCC"] = {
"type": "Message",
"id": "e3",
"sender_id": "111",
"conversation_id": "111:999",
"content": {"text": "hello agent"},
}
api.events_pages["111-999"] = {
"data": [{"id": "e3", "encoded_event": "CCC", "sender_id": "111"}]
}
await adapter._poll_conversation("111-999")
assert len(captured) == 1
ev = captured[0]
assert ev.text == "hello agent"
# Reply target uses the canonical id embedded in the signed event.
assert ev.source.chat_id == "111:999"
# Second poll with the same event id → dedup, no double dispatch.
await adapter._poll_conversation("111-999")
assert len(captured) == 1
@pytest.mark.asyncio
async def test_own_messages_filtered(monkeypatch):
adapter = _make_adapter(monkeypatch)
api, crypto = _wire(adapter)
captured = _capture(adapter, monkeypatch)
adapter._backlog_loaded.add("111-999")
crypto.decrypt_map["DDD"] = {
"type": "Message",
"id": "e4",
"sender_id": "999", # the bot itself
"content": {"text": "echo of our own reply"},
}
api.events_pages["111-999"] = {
"data": [{"id": "e4", "encoded_event": "DDD", "sender_id": "999"}]
}
await adapter._poll_conversation("111-999")
assert captured == []
@pytest.mark.asyncio
async def test_keychange_routes_through_batch(monkeypatch):
adapter = _make_adapter(monkeypatch)
api, crypto = _wire(adapter)
captured = _capture(adapter, monkeypatch)
adapter._backlog_loaded.add("111-999")
crypto.decrypt_map["KEY"] = {"type": "KeyChange", "id": "e5"}
api.events_pages["111-999"] = {
"data": [{"id": "e5", "encoded_event": "KEY", "sender_id": "111"}]
}
await adapter._poll_conversation("111-999")
assert crypto.batch_calls == [["KEY"]]
assert captured == []
assert adapter._conversation_keys["111-999"] == {"1": b"k"}
@pytest.mark.asyncio
async def test_signing_keys_fetched_once_per_sender(monkeypatch):
adapter = _make_adapter(monkeypatch)
api, crypto = _wire(adapter)
api.public_keys["111"] = [
{
"public_key_version": "3",
"signing_public_key": "SPK",
"public_key": "IPK",
"identity_public_key_signature": "SIG",
}
]
events = [{"id": "e1", "sender_id": "111"}]
await adapter._register_signing_keys(events)
await adapter._register_signing_keys(events) # second call — cached
assert len(crypto.signing_keys) == 1
entry = crypto.signing_keys[0]
assert entry["user_id"] == "111"
assert entry["public_key"] == "SPK"
assert entry["identity_public_key"] == "IPK"
@pytest.mark.asyncio
async def test_discovery_adds_conversations(monkeypatch):
adapter = _make_adapter(monkeypatch)
api, _ = _wire(adapter)
api.conversations = ["111-222", "g333"]
await adapter._discover_conversations()
assert adapter._conversations == {"111-222", "g333"}
def test_dedup_prune_bounds_memory(monkeypatch):
adapter = _make_adapter(monkeypatch)
now = time.time()
for i in range(xchat_adapter.DEDUP_MAX_SIZE + 100):
adapter._seen_event_ids[f"e{i}"] = now + i
adapter._prune_dedup()
assert len(adapter._seen_event_ids) <= xchat_adapter.DEDUP_MAX_SIZE
# Newest entries survive the prune.
assert f"e{xchat_adapter.DEDUP_MAX_SIZE + 99}" in adapter._seen_event_ids
# ---------------------------------------------------------------------------
# Outbound
@pytest.mark.asyncio
async def test_send_encrypts_and_posts(monkeypatch):
adapter = _make_adapter(monkeypatch)
api, crypto = _wire(adapter)
result = await adapter.send("111:999", "hi there")
assert result.success
assert result.message_id == "mid-1"
assert crypto.encrypted == [("111:999", "hi there")]
conv, body = api.sent[0]
assert conv == "111:999"
assert body["encoded_message_create_event"] == "ZW5j"
# Echo suppression: returned event id marked as seen.
assert "evt-echo" in adapter._seen_event_ids
@pytest.mark.asyncio
async def test_send_without_conversation_key(monkeypatch):
adapter = _make_adapter(monkeypatch)
_, crypto = _wire(adapter)
crypto.fail_encrypt = ValueError("no key")
result = await adapter.send("111:999", "hi")
assert not result.success
assert "conversation key" in (result.error or "")
@pytest.mark.asyncio
async def test_send_disconnected(monkeypatch):
adapter = _make_adapter(monkeypatch)
result = await adapter.send("111:999", "hi")
assert not result.success
@pytest.mark.asyncio
async def test_get_chat_info_types(monkeypatch):
adapter = _make_adapter(monkeypatch)
assert (await adapter.get_chat_info("g123"))["type"] == "group"
assert (await adapter.get_chat_info("111-222"))["type"] == "dm"
# ---------------------------------------------------------------------------
# Crypto wrapper (fake Chat object — no native SDK)
class _FakePayload:
message_id = "m1"
encrypted_content = "ENC"
encoded_event_signature = "SIG"
class _FakeChat:
def __init__(self) -> None:
self.identity = None
self.cache = None
self.imported = None
def import_keys(self, blob, version=None):
self.imported = (blob, version)
def set_identity(self, user_id, version):
self.identity = (user_id, version)
def set_cache_keys(self, enabled):
self.cache = enabled
def set_signing_keys(self, keys):
self.signing = keys
def encrypt_message(self, conversation_id, text):
return _FakePayload()
def decrypt_event(self, event_b64, conversation_keys, signing_keys):
return {"type": "Message", "content": {"text": "plain"}}
def decrypt_events(self, events, signing_keys):
return {"messages": [{"event": {"type": "Message"}}], "conversation_keys": {}, "errors": {}}
def test_crypto_load_keys_and_identity():
crypto = XChatCrypto(chat=_FakeChat())
crypto.load_keys("YmxvYg==", "7") # b64("blob")
assert crypto.chat.imported == (b"blob", "7")
assert crypto.signing_key_version == "7"
crypto.set_identity("42")
assert crypto.chat.identity == ("42", "7")
def test_crypto_encrypt_shapes_send_body():
crypto = XChatCrypto(chat=_FakeChat())
body = crypto.encrypt_text("1:2", "hello")
assert body == {
"message_id": "m1",
"encoded_message_create_event": "ENC",
"encoded_message_event_signature": "SIG",
}
def test_message_text_extraction():
assert message_text({"type": "Message", "content": {"text": "x"}}) == "x"
assert message_text({"type": "KeyChange"}) is None
assert message_text({"type": "Message", "content": {}}) is None
# ---------------------------------------------------------------------------
# Registry integration
def test_platform_registry_entry_parity():
"""Every parity knob must be populated on the registered entry."""
from gateway.platform_registry import PlatformEntry
captured: Dict[str, Any] = {}
class Ctx:
class manifest:
name = "xchat-platform"
def register_platform(self, **kwargs):
captured.update(kwargs)
def register_cli_command(self, **kwargs):
captured["cli"] = kwargs
xchat_adapter.register(Ctx())
assert captured["name"] == "xchat"
assert captured["allowed_users_env"] == "XCHAT_ALLOWED_USERS"
assert captured["allow_all_env"] == "XCHAT_ALLOW_ALL_USERS"
assert captured["cron_deliver_env_var"] == "XCHAT_HOME_CHANNEL"
assert callable(captured["standalone_sender_fn"])
assert callable(captured["setup_fn"])
assert callable(captured["env_enablement_fn"])
assert captured["platform_hint"]
assert captured["max_message_length"] > 0
assert captured["cli"]["name"] == "xchat"
# The kwargs must construct a valid PlatformEntry.
entry_kwargs = {k: v for k, v in captured.items() if k != "cli"}
entry = PlatformEntry(**entry_kwargs)
assert entry.name == "xchat"
# ---------------------------------------------------------------------------
# Standalone send (config-error paths — no network)
@pytest.mark.asyncio
async def test_standalone_send_requires_token(monkeypatch):
monkeypatch.delenv("XCHAT_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("XCHAT_PRIVATE_KEYS_B64", raising=False)
cfg = PlatformConfig(enabled=True, extra={})
out = await xchat_adapter._standalone_send(cfg, "111", "msg")
assert "XCHAT_ACCESS_TOKEN" in out["error"]
@pytest.mark.asyncio
async def test_standalone_send_requires_key_blob(monkeypatch):
monkeypatch.setenv("XCHAT_ACCESS_TOKEN", "tok")
monkeypatch.delenv("XCHAT_PRIVATE_KEYS_B64", raising=False)
cfg = PlatformConfig(enabled=True, extra={})
out = await xchat_adapter._standalone_send(cfg, "111", "msg")
assert "private-key blob" in out["error"]

View file

@ -200,6 +200,10 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
# installed on demand like every other messaging platform; also exposed
# as the `teams` extra in pyproject for packagers / explicit installs.
"platform.teams": ("microsoft-teams-apps==2.0.13.4", "aiohttp==3.14.1"), # aiohttp 3.14.1: CVE-2026-34993(RCE)/47265 + 34513/34518/34519/34520/34525
# X Chat (encrypted X DMs) adapter — chatxdk is the official Chat XDK
# (native E2EE binding: keygen, encrypt/decrypt, sign/verify). The REST
# layer uses core httpx directly, so no xdk client dependency.
"platform.xchat": ("chatxdk==0.4.1",),
# ─── Terminal backends ─────────────────────────────────────────────────
"terminal.modal": ("modal==1.3.4",),

18
uv.lock generated
View file

@ -673,6 +673,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "chatxdk"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/2d/38e13cfdf9d0dcee7e13f41015aa4b2648a0f42e0142be12790aa48bdf3e/chatxdk-0.4.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a89d6735417d4c44fbf4d7d7799e58b4cde658536e71eaf37371c9648ef10792", size = 3288306, upload-time = "2026-07-20T23:22:27.254Z" },
{ url = "https://files.pythonhosted.org/packages/bf/80/81b3bad83a4c986f322d6d591993658c4734a5986453e3186ba85280cf21/chatxdk-0.4.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:704c753d474a7c3d3b4dffae0d1a4be3ba78ca911a0b0cc65e85afb3dade37b0", size = 3123290, upload-time = "2026-07-20T23:22:29.043Z" },
{ url = "https://files.pythonhosted.org/packages/87/68/c0fa19bd67593feb4ffbd965a461bebf685381eeab0ab18551499e740280/chatxdk-0.4.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ed165dffe8c6336d1bbbf6423034e3303c6645c9be72154afae624fb3148e03", size = 3440648, upload-time = "2026-07-20T23:22:30.87Z" },
{ url = "https://files.pythonhosted.org/packages/9c/3a/975e25b900aa5c1a5d27387f72ac43a5930bc6bbea96f01efc343dcd31f2/chatxdk-0.4.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dba2c5248adaaeafada52980ec739f8068488564a1861d1ca2be0d7a96006a1c", size = 3476274, upload-time = "2026-07-20T23:22:32.728Z" },
{ url = "https://files.pythonhosted.org/packages/1e/bb/41a3bf9ddd8875a0d3f7445c90324a6039d65bfdd8ddb6de5c29b2139a87/chatxdk-0.4.1-cp310-abi3-win_amd64.whl", hash = "sha256:2b351012cba965e1c2d253381a3ad0e7a6b4858404fc30e811fae70d78b18af3", size = 2959643, upload-time = "2026-07-20T23:22:34.473Z" },
]
[[package]]
name = "click"
version = "8.3.1"
@ -1727,6 +1739,9 @@ web = [
wecom = [
{ name = "defusedxml" },
]
xchat = [
{ name = "chatxdk" },
]
youtube = [
{ name = "youtube-transcript-api" },
]
@ -1749,6 +1764,7 @@ requires-dist = [
{ name = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" },
{ name = "brotlicffi", marker = "extra == 'messaging'", specifier = "==1.2.0.1" },
{ name = "certifi", specifier = "==2026.5.20" },
{ name = "chatxdk", marker = "extra == 'xchat'", specifier = "==0.4.1" },
{ name = "concurrent-log-handler", marker = "sys_platform == 'win32'", specifier = "==0.9.29" },
{ name = "croniter", specifier = "==6.0.0" },
{ name = "cryptography", specifier = "==46.0.7" },
@ -1855,7 +1871,7 @@ requires-dist = [
{ name = "websockets", specifier = "==15.0.1" },
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" },
]
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "xchat", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
[[package]]
name = "hf-xet"

View file

@ -91,7 +91,7 @@ Speech-to-text supports six providers: local faster-whisper (free, runs on-devic
Hermes runs as a gateway bot on 27+ messaging platforms, all configured through the same `gateway` subsystem:
- **[Telegram](/user-guide/messaging/telegram)**, **[Discord](/user-guide/messaging/discord)**, **[Slack](/user-guide/messaging/slack)**, **[WhatsApp](/user-guide/messaging/whatsapp)**, **[Signal](/user-guide/messaging/signal)**, **[Matrix](/user-guide/messaging/matrix)**, **[Mattermost](/user-guide/messaging/mattermost)**, **[Email](/user-guide/messaging/email)**, **[SMS](/user-guide/messaging/sms)**, **[DingTalk](/user-guide/messaging/dingtalk)**, **[Feishu/Lark](/user-guide/messaging/feishu)**, **[WeCom](/user-guide/messaging/wecom)**, **[WeCom Callback](/user-guide/messaging/wecom-callback)**, **[Weixin](/user-guide/messaging/weixin)**, **[BlueBubbles](/user-guide/messaging/bluebubbles)**, **[QQ Bot](/user-guide/messaging/qqbot)**, **[Yuanbao](/user-guide/messaging/yuanbao)**, **[Home Assistant](/user-guide/messaging/homeassistant)**, **[Microsoft Teams](/user-guide/messaging/teams)**, **[Microsoft Teams Meetings](/user-guide/messaging/teams-meetings)**, **[Microsoft Graph Webhook](/user-guide/messaging/msgraph-webhook)**, **[Google Chat](/user-guide/messaging/google_chat)**, **[LINE](/user-guide/messaging/line)**, **[ntfy](/user-guide/messaging/ntfy)**, **[SimpleX](/user-guide/messaging/simplex)**, **[Open WebUI](/user-guide/messaging/open-webui)**, **[Webhooks](/user-guide/messaging/webhooks)**
- **[Telegram](/user-guide/messaging/telegram)**, **[Discord](/user-guide/messaging/discord)**, **[Slack](/user-guide/messaging/slack)**, **[WhatsApp](/user-guide/messaging/whatsapp)**, **[Signal](/user-guide/messaging/signal)**, **[Matrix](/user-guide/messaging/matrix)**, **[Mattermost](/user-guide/messaging/mattermost)**, **[Email](/user-guide/messaging/email)**, **[SMS](/user-guide/messaging/sms)**, **[DingTalk](/user-guide/messaging/dingtalk)**, **[Feishu/Lark](/user-guide/messaging/feishu)**, **[WeCom](/user-guide/messaging/wecom)**, **[WeCom Callback](/user-guide/messaging/wecom-callback)**, **[Weixin](/user-guide/messaging/weixin)**, **[BlueBubbles](/user-guide/messaging/bluebubbles)**, **[QQ Bot](/user-guide/messaging/qqbot)**, **[Yuanbao](/user-guide/messaging/yuanbao)**, **[Home Assistant](/user-guide/messaging/homeassistant)**, **[Microsoft Teams](/user-guide/messaging/teams)**, **[Microsoft Teams Meetings](/user-guide/messaging/teams-meetings)**, **[Microsoft Graph Webhook](/user-guide/messaging/msgraph-webhook)**, **[Google Chat](/user-guide/messaging/google_chat)**, **[LINE](/user-guide/messaging/line)**, **[ntfy](/user-guide/messaging/ntfy)**, **[SimpleX](/user-guide/messaging/simplex)**, **[X Chat](/user-guide/messaging/xchat)**, **[Open WebUI](/user-guide/messaging/open-webui)**, **[Webhooks](/user-guide/messaging/webhooks)**
See the [Messaging Gateway overview](/user-guide/messaging) for the platform comparison table and setup guide.

View file

@ -621,6 +621,28 @@ Connect Hermes to a [SimpleX Chat](https://simplex.chat/) network via a local `s
| `SIMPLEX_HOME_CHANNEL` | Default contact/group ID for cron / notification delivery. |
| `SIMPLEX_HOME_CHANNEL_NAME` | Human label for the home channel (defaults to the ID). |
### X Chat
Connect Hermes to [X Chat](https://docs.x.com/xchat/introduction) — X's end-to-end encrypted direct messages. See [the X Chat messaging guide](/user-guide/messaging/xchat).
| Variable | Description |
|----------|-------------|
| `XCHAT_ACCESS_TOKEN` | OAuth2 user access token with `dm.read`, `dm.write`, `users.read`, `tweet.read` scopes (set by `hermes xchat setup`). |
| `XCHAT_REFRESH_TOKEN` | OAuth2 refresh token — enables automatic renewal of the ~2h access token (rotated on every refresh and re-persisted). |
| `XCHAT_CLIENT_ID` | X app OAuth2 client id — required for automatic token refresh. |
| `XCHAT_CLIENT_SECRET` | X app OAuth2 client secret (confidential clients only). |
| `XCHAT_USER_ID` | The bot account's numeric X user id (derived automatically by `hermes xchat setup`). |
| `XCHAT_SIGNING_KEY_VERSION` | Registered public-key version for message signing (written by `hermes xchat setup`). |
| `XCHAT_PRIVATE_KEYS_B64` | Base64 private-key blob override — takes precedence over `~/.hermes/xchat/private_keys.b64`. |
| `XCHAT_ALLOWED_USERS` | Comma-separated numeric X user ids allowed to talk to the bot. |
| `XCHAT_ALLOW_ALL_USERS` | Allow any sender to trigger the bot (dev only — disables allowlist). |
| `XCHAT_CONVERSATION_IDS` | Comma-separated conversation ids to poll (omit to auto-discover all conversations). |
| `XCHAT_POLL_INTERVAL` | Seconds between event polls (default `10`, floor `2`). |
| `XCHAT_REQUIRE_MENTION` | Ignore group-conversation messages unless they match a mention wake word (`true`/`false`, default `false`). |
| `XCHAT_MENTION_PATTERNS` | Mention wake-word regexes for group chats (JSON list or comma/newline-separated; defaults to the Hermes wake words). |
| `XCHAT_HOME_CHANNEL` | Default conversation/user id for cron / notification delivery. |
| `XCHAT_HOME_CHANNEL_NAME` | Human label for the home channel (defaults to the id). |
### Photon
Connect Hermes to [Photon](https://photon.codes/) / Spectrum (iMessage and other Spectrum platforms) via the Node sidecar. See [the Photon messaging guide](/user-guide/messaging/photon).

View file

@ -6,7 +6,7 @@ description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal,
# Messaging Gateway
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, LINE, ntfy, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, X Chat, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, LINE, ntfy, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/guides/use-voice-mode-with-hermes).
@ -25,6 +25,7 @@ Bots need both a model provider and tool providers (TTS, web). A [Nous Portal](/
| WhatsApp | — | ✅ | ✅ | — | — | ✅ | ✅ |
| Signal | — | ✅ | ✅ | — | — | ✅ | ✅ |
| SMS | — | — | — | — | — | — | — |
| X Chat | — | — | — | — | — | ✅ | — |
| Email | — | ✅ | ✅ | ✅ | — | — | — |
| Home Assistant | — | — | — | — | — | — | — |
| Mattermost | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
@ -710,6 +711,7 @@ Defaults to `false`. Only platforms whose adapter implements `delete_message` ho
- [WhatsApp Business Cloud API Setup](whatsapp-cloud.md)
- [Signal Setup](signal.md)
- [SMS Setup (Twilio)](sms.md)
- [X Chat Setup (encrypted X DMs)](xchat.md)
- [Email Setup](email.md)
- [Home Assistant Integration](homeassistant.md)
- [Mattermost Setup](mattermost.md)

View file

@ -0,0 +1,101 @@
# X Chat (encrypted X DMs)
[X Chat](https://docs.x.com/xchat/introduction) is X's end-to-end encrypted direct-message system. The Hermes adapter connects your agent to a bot X account's DMs: message bodies are encrypted and decrypted **locally** with the official Chat XDK — X only ever routes ciphertext, and every message is signed so recipients can verify the sender.
> Run `hermes xchat setup` for a guided walk-through, or pick **X Chat** in `hermes gateway setup`.
## Prerequisites
- An **X developer account** with an app configured for **OAuth 2.0 user context** ([Developer Console](https://developer.x.com/en/portal/dashboard)). X Chat endpoints require API access on your developer plan.
- A **user access token** for the bot account with scopes: `dm.read`, `dm.write`, `users.read`, `tweet.read` (add `offline.access` to receive a refresh token so Hermes can auto-renew the ~2-hour access token).
- Python 3.10+ (the `chatxdk` E2EE binding is lazy-installed at first use).
## Setup
```bash
hermes xchat setup
```
The wizard:
1. Stores the OAuth2 access token (and optional refresh token + client id) in `~/.hermes/.env`.
2. Derives the bot account's numeric user id via `GET /2/users/me`.
3. Generates the E2EE identity + signing keypairs with the Chat XDK, saves the private-key blob to `~/.hermes/xchat/private_keys.b64` (mode 600), and registers the public keys with the X API.
Key registration is **rate limited to a few writes per 24 hours** per account. The setup is resume-safe: the key blob and registration payload are persisted *before* any network call, so an interrupted or rate-limited run resumes the same identity instead of minting a new one.
Check state anytime:
```bash
hermes xchat status
```
## Environment variables
| Variable | Required | Description |
|---|---|---|
| `XCHAT_ACCESS_TOKEN` | Yes | OAuth2 user access token (dm.read, dm.write, users.read, tweet.read) |
| `XCHAT_REFRESH_TOKEN` | Recommended | Refresh token — enables automatic access-token renewal (rotated on every refresh and re-persisted) |
| `XCHAT_CLIENT_ID` | With refresh | X app OAuth2 client id (required for token refresh) |
| `XCHAT_CLIENT_SECRET` | Optional | Only for confidential clients |
| `XCHAT_USER_ID` | Auto | Bot account's numeric user id (derived by setup) |
| `XCHAT_SIGNING_KEY_VERSION` | Auto | Registered public-key version (written by setup) |
| `XCHAT_PRIVATE_KEYS_B64` | Optional | Key blob override — takes precedence over the blob file |
| `XCHAT_ALLOWED_USERS` | Recommended | Comma-separated numeric X user ids allowed to talk to the bot |
| `XCHAT_ALLOW_ALL_USERS` | Optional | `true` allows every sender (dev only) |
| `XCHAT_CONVERSATION_IDS` | Optional | Pin specific conversation ids to poll; omit to auto-discover |
| `XCHAT_POLL_INTERVAL` | Optional | Seconds between event polls (default `10`, floor `2`) |
| `XCHAT_REQUIRE_MENTION` | Optional | In group conversations, only respond when a wake word matches (default `false`) |
| `XCHAT_MENTION_PATTERNS` | Optional | Custom wake-word regexes (JSON list or comma-separated) |
| `XCHAT_HOME_CHANNEL` | Optional | Default conversation/user id for cron delivery |
| `XCHAT_HOME_CHANNEL_NAME` | Optional | Human label for the home channel |
## How it works
- **Inbound** — the adapter polls each conversation's events endpoint. On first sight of a conversation it batch-decrypts the backlog (`decrypt_events`) to seed the SDK's verified conversation-key cache **without replying to old messages**, then decrypts new events individually. `KeyChange` events (conversation-key rotations) are verified and folded into the key cache automatically.
- **Outbound** — replies are encrypted and signed locally (`encrypt_message` with the session identity), then POSTed as ciphertext.
- **Senders** — each new sender's public keys are fetched once and pushed into the XDK's signing-key store so their message signatures verify.
- **Identity** — user ids are numeric X user ids; conversation ids look like `123-456` (1:1) or `g123…` (group).
## Authorization
By default all senders are denied. Either:
1. Set `XCHAT_ALLOWED_USERS` to a comma-separated list of numeric X user ids, or
2. Use **DM pairing** — an unknown sender gets a pairing code; approve with `hermes pairing approve xchat <CODE>`.
## Group conversations
Group chats (`g…` conversation ids) work out of the box. To keep the bot quiet unless addressed:
```
XCHAT_REQUIRE_MENTION=true
```
The default wake words are `hermes` / `hermes agent`; override with `XCHAT_MENTION_PATTERNS`. DMs are never gated.
## Using X Chat with cron jobs
```python
cronjob(
action="create",
schedule="every 1h",
deliver="xchat", # uses XCHAT_HOME_CHANNEL
prompt="Check for alerts and summarise."
)
```
Or target a conversation directly:
```bash
hermes send xchat:<conversation-id> "Done!"
```
Standalone delivery opens an ephemeral E2EE session, seeds the conversation key from the conversation's backlog, encrypts, and sends — no running gateway required.
## Limitations
- **Text only for now.** Encrypted media upload/download (the streaming-encrypt flow + `media_hash_key` endpoints) is not wired yet; inbound attachments surface as text-free events and are skipped.
- **Reply flows only.** The bot answers conversations that exist; initiating a brand-new conversation (which requires a conversation-key handshake) is not supported yet.
- **Polling latency.** Inbound uses REST polling (default 10s). Webhook / activity-stream delivery may come later.
- **Access tier.** X Chat API availability depends on your X developer plan.

View file

@ -641,6 +641,7 @@ const sidebars: SidebarsConfig = {
'user-guide/messaging/signal',
'user-guide/messaging/email',
'user-guide/messaging/sms',
'user-guide/messaging/xchat',
],
},
{