mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
32a9f2acbc
commit
1819dc4b1f
16 changed files with 2335 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
4
plugins/platforms/xchat/__init__.py
Normal file
4
plugins/platforms/xchat/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""X Chat (end-to-end encrypted X DMs) platform plugin entry point."""
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
770
plugins/platforms/xchat/adapter.py
Normal file
770
plugins/platforms/xchat/adapter.py
Normal 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,
|
||||
)
|
||||
278
plugins/platforms/xchat/api.py
Normal file
278
plugins/platforms/xchat/api.py
Normal 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,
|
||||
)
|
||||
335
plugins/platforms/xchat/cli.py
Normal file
335
plugins/platforms/xchat/cli.py
Normal 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
|
||||
169
plugins/platforms/xchat/crypto.py
Normal file
169
plugins/platforms/xchat/crypto.py
Normal 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
|
||||
80
plugins/platforms/xchat/plugin.yaml
Normal file
80
plugins/platforms/xchat/plugin.yaml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
528
tests/plugins/platforms/xchat/test_xchat_adapter.py
Normal file
528
tests/plugins/platforms/xchat/test_xchat_adapter.py
Normal 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"]
|
||||
|
|
@ -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
18
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
101
website/docs/user-guide/messaging/xchat.md
Normal file
101
website/docs/user-guide/messaging/xchat.md
Normal 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.
|
||||
|
|
@ -641,6 +641,7 @@ const sidebars: SidebarsConfig = {
|
|||
'user-guide/messaging/signal',
|
||||
'user-guide/messaging/email',
|
||||
'user-guide/messaging/sms',
|
||||
'user-guide/messaging/xchat',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue