1348 lines
54 KiB
Python
1348 lines
54 KiB
Python
"""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,
|
||
detect_image_dimensions,
|
||
detect_mime_type,
|
||
message_attachments,
|
||
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
|
||
# Bounded cache of recent decrypted events per conversation — used to build
|
||
# native threaded replies (encrypt_reply needs the decrypted target event).
|
||
REPLY_CACHE_MAX = 200
|
||
# Cap inbound attachments processed per message.
|
||
MAX_INBOUND_ATTACHMENTS = 5
|
||
|
||
# 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 _cursor_path() -> Path:
|
||
return _state_dir() / "cursors.json"
|
||
|
||
|
||
def _load_cursors() -> Dict[str, str]:
|
||
"""Per-conversation cursors (last processed event id), persisted to disk.
|
||
|
||
The cursor is the source of truth for "what have we already handled":
|
||
it survives restarts (so messages received while the gateway was down
|
||
are processed, not swallowed) and never evicts (so a pruned dedup
|
||
entry can't cause a re-reply to an old message).
|
||
"""
|
||
try:
|
||
data = json.loads(_cursor_path().read_text(encoding="utf-8"))
|
||
except (OSError, ValueError):
|
||
return {}
|
||
if not isinstance(data, dict):
|
||
return {}
|
||
return {str(k): str(v) for k, v in data.items() if str(v)}
|
||
|
||
|
||
def _event_id_newer(a: str, b: str) -> bool:
|
||
"""True when event id ``a`` is newer than ``b`` (ids are numeric,
|
||
time-ordered; fall back to string comparison just in case)."""
|
||
try:
|
||
return int(a) > int(b)
|
||
except (TypeError, ValueError):
|
||
return str(a) > str(b)
|
||
|
||
|
||
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. Cursors persist across restarts
|
||
# (~/.hermes/xchat/cursors.json) — they are the primary duplicate /
|
||
# backlog guard; the in-memory dedup set is only a same-session
|
||
# safety net (echo suppression of our own sends).
|
||
self._conversations: Set[str] = set(self._pinned_conversations)
|
||
self._cursors: Dict[str, str] = _load_cursors()
|
||
self._seen_event_ids: Dict[str, float] = {}
|
||
self._conversation_keys: Dict[str, Dict[str, bytes]] = {}
|
||
# Latest verified key version per conversation — media decrypt must
|
||
# use the key for the EVENT's version, media encrypt the latest.
|
||
self._latest_key_version: Dict[str, str] = {}
|
||
# Recent decrypted events per conversation, keyed by event id — lets
|
||
# send(reply_to=...) build a native threaded reply.
|
||
self._event_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
||
# Read receipts (privacy default: off).
|
||
env_read = os.getenv("XCHAT_SEND_READ_RECEIPTS")
|
||
if env_read is not None:
|
||
self._send_read_receipts = env_read.strip().lower() in {"1", "true", "yes"}
|
||
else:
|
||
self._send_read_receipts = bool(extra.get("send_read_receipts", False))
|
||
|
||
# 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
|
||
cursor = self._cursors.get(conv_id, "")
|
||
|
||
# Page until we reach the cursor (or the feed ends) so a burst of
|
||
# more than one page between polls is never dropped. Newest-first
|
||
# on the wire; hard page cap keeps a pathological feed bounded.
|
||
# KeyChange events arrive SEPARATELY in meta.conversation_key_events
|
||
# — they must be decrypted (batch path) before the messages that
|
||
# were encrypted under them, or no conversation key is available.
|
||
collected: List[Dict[str, Any]] = []
|
||
key_events_b64: List[str] = []
|
||
token: Optional[str] = None
|
||
reached_cursor = False
|
||
for _ in range(10):
|
||
page = await self._api.get_events(
|
||
conv_id, max_results=50, pagination_token=token
|
||
)
|
||
raw = page.get("data") or []
|
||
meta = page.get("meta") or {}
|
||
for kev in meta.get("conversation_key_events") or []:
|
||
if kev and kev not in key_events_b64:
|
||
key_events_b64.append(kev)
|
||
if not raw:
|
||
break
|
||
for item in raw:
|
||
eid = str(item.get("id") or "")
|
||
if cursor and eid and not _event_id_newer(eid, cursor):
|
||
reached_cursor = True
|
||
break
|
||
collected.append(item)
|
||
token = meta.get("next_token")
|
||
if reached_cursor or not token:
|
||
break
|
||
if not collected and not key_events_b64:
|
||
return
|
||
|
||
# Process oldest-first.
|
||
collected.reverse()
|
||
await self._register_signing_keys(collected)
|
||
|
||
# Verify + cache any conversation-key changes FIRST (after signing
|
||
# keys are registered — an unverifiable KeyChange is dropped by the
|
||
# SDK), so this poll's messages can decrypt under rotated keys.
|
||
if key_events_b64:
|
||
self._absorb_key_batch(conv_id, key_events_b64)
|
||
if not collected:
|
||
return
|
||
|
||
if not cursor:
|
||
# First sight of this conversation EVER (no persisted cursor):
|
||
# batch-decrypt to seed the SDK's verified-key cache, but do NOT
|
||
# reply to the historical backlog. The cursor persists, so a
|
||
# gateway restart does not re-enter this branch — messages that
|
||
# arrived while we were down are processed normally above.
|
||
events_b64 = [e["encoded_event"] for e in collected if e.get("encoded_event")]
|
||
if events_b64:
|
||
self._absorb_key_batch(conv_id, events_b64, label="backlog")
|
||
newest = str(collected[-1].get("id") or "")
|
||
if newest:
|
||
self._set_cursor(conv_id, newest)
|
||
return
|
||
|
||
for item in collected:
|
||
event_id = str(item.get("id") or "")
|
||
if not event_id:
|
||
continue
|
||
if event_id in self._seen_event_ids:
|
||
# Same-session echo suppression (our own sends).
|
||
self._set_cursor(conv_id, event_id)
|
||
continue
|
||
self._seen_event_ids[event_id] = time.time()
|
||
self._prune_dedup()
|
||
|
||
event_b64 = item.get("encoded_event")
|
||
if not event_b64:
|
||
self._set_cursor(conv_id, event_id)
|
||
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)
|
||
self._set_cursor(conv_id, event_id)
|
||
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.
|
||
self._absorb_key_batch(conv_id, [event_b64], label="key-change")
|
||
self._set_cursor(conv_id, event_id)
|
||
continue
|
||
if etype not in ("Message", "MessageEdit"):
|
||
# Reactions, read receipts, etc.
|
||
self._set_cursor(conv_id, event_id)
|
||
continue
|
||
|
||
sender_id = str(event.get("sender_id") or item.get("sender_id") or "")
|
||
if sender_id == self._bot_user_id:
|
||
self._set_cursor(conv_id, event_id)
|
||
continue # echo of our own reply
|
||
|
||
# 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)
|
||
self._cache_event(canonical_conv, event_id, event)
|
||
|
||
# Encrypted media attachments: download + decrypt + cache locally
|
||
# so vision / the agent can read them.
|
||
media_urls, media_types = await self._fetch_attachments(
|
||
conv_id, event, item
|
||
)
|
||
|
||
text = message_text(event) or ""
|
||
if not text and not media_urls:
|
||
self._set_cursor(conv_id, event_id)
|
||
continue
|
||
|
||
await self._dispatch_inbound(
|
||
conv_id=canonical_conv,
|
||
sender_id=sender_id,
|
||
text=text,
|
||
message_id=event_id,
|
||
raw=item,
|
||
media_urls=media_urls,
|
||
media_types=media_types,
|
||
reply_to=self._reply_context(canonical_conv, event),
|
||
)
|
||
self._set_cursor(conv_id, event_id)
|
||
await self._maybe_mark_read(conv_id, item)
|
||
|
||
def _absorb_key_batch(
|
||
self, conv_id: str, events_b64: List[str], *, label: str = "key-events"
|
||
) -> None:
|
||
"""Batch-decrypt events to extract + cache verified conversation keys."""
|
||
assert self._crypto is not None
|
||
try:
|
||
batch = self._crypto.decrypt_batch(events_b64)
|
||
except Exception as e:
|
||
logger.warning("[xchat] %s decrypt failed conv=%s: %s", label, conv_id, e)
|
||
return
|
||
keys = (batch.get("conversation_keys") or {}).get("keys") or {}
|
||
if keys:
|
||
self._conversation_keys.setdefault(conv_id, {}).update(keys)
|
||
latest = batch.get("latest_key_version")
|
||
if latest:
|
||
self._latest_key_version[conv_id] = str(latest)
|
||
|
||
def _cache_event(self, conv_id: str, event_id: str, event: Dict[str, Any]) -> None:
|
||
"""Keep a bounded cache of decrypted events for native threaded replies."""
|
||
cache = self._event_cache.setdefault(conv_id, {})
|
||
cache[event_id] = event
|
||
while len(cache) > REPLY_CACHE_MAX:
|
||
cache.pop(next(iter(cache)))
|
||
|
||
def _reply_context(
|
||
self, conv_id: str, event: Dict[str, Any]
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Reply metadata for the inbound event (id/text/author), if present."""
|
||
reply = event.get("reply_to") or event.get("replied_to_event")
|
||
if not reply:
|
||
return None
|
||
reply = reply if isinstance(reply, dict) else {}
|
||
rid = str(reply.get("id") or reply.get("sequence_id") or "") or None
|
||
return {
|
||
"message_id": rid,
|
||
"text": reply.get("text") or message_text(reply),
|
||
"author_id": str(reply.get("sender_id") or "") or None,
|
||
}
|
||
|
||
async def _fetch_attachments(
|
||
self, conv_id: str, event: Dict[str, Any], item: Dict[str, Any]
|
||
) -> tuple[List[str], List[str]]:
|
||
"""Download + decrypt inbound media attachments to the local cache.
|
||
|
||
Uses the conversation key for the EVENT's key version — after a
|
||
rotation the latest key cannot decrypt older media.
|
||
"""
|
||
atts = message_attachments(event)
|
||
if not atts:
|
||
return [], []
|
||
assert self._api is not None and self._crypto is not None
|
||
from gateway.platforms.base import (
|
||
cache_audio_from_bytes,
|
||
cache_document_from_bytes,
|
||
cache_image_from_bytes,
|
||
cache_video_from_bytes,
|
||
validate_inbound_media_size,
|
||
)
|
||
|
||
keys = self._conversation_keys.get(conv_id) or {}
|
||
event_key_version = str(event.get("key_version") or "")
|
||
conv_key = keys.get(event_key_version) or (
|
||
keys.get(self._latest_key_version.get(conv_id, "")) if keys else None
|
||
)
|
||
if conv_key is None and keys:
|
||
# Last resort: any cached key (single-key conversations).
|
||
conv_key = next(iter(keys.values()))
|
||
if conv_key is None:
|
||
logger.warning(
|
||
"[xchat] attachment skipped conv=%s: no conversation key cached", conv_id
|
||
)
|
||
return [], []
|
||
|
||
media_urls: List[str] = []
|
||
media_types: List[str] = []
|
||
for att in atts[:MAX_INBOUND_ATTACHMENTS]:
|
||
hash_key = str(att.get("media_hash_key") or "")
|
||
if not hash_key:
|
||
continue
|
||
try:
|
||
blob = await self._api.media_download(conv_id, hash_key)
|
||
# Raises ValueError when over the inbound media cap.
|
||
validate_inbound_media_size(len(blob), media_type="attachment")
|
||
plaintext = self._crypto.decrypt_media(blob, conv_key)
|
||
except Exception as e:
|
||
logger.warning(
|
||
"[xchat] attachment fetch/decrypt failed conv=%s key=%s: %s",
|
||
conv_id, hash_key[:12], e,
|
||
)
|
||
continue
|
||
mime = detect_mime_type(plaintext) or "application/octet-stream"
|
||
try:
|
||
if mime.startswith("image/"):
|
||
ext = "." + (mime.split("/", 1)[1] or "jpg").replace("jpeg", "jpg")
|
||
path = cache_image_from_bytes(plaintext, ext=ext)
|
||
elif mime.startswith("audio/"):
|
||
path = cache_audio_from_bytes(plaintext)
|
||
elif mime.startswith("video/"):
|
||
path = cache_video_from_bytes(plaintext)
|
||
else:
|
||
filename = str(att.get("filename") or f"xchat-{hash_key[:10]}.bin")
|
||
path = cache_document_from_bytes(plaintext, filename)
|
||
except Exception as e:
|
||
logger.warning("[xchat] attachment cache failed conv=%s: %s", conv_id, e)
|
||
continue
|
||
media_urls.append(path)
|
||
media_types.append(mime)
|
||
return media_urls, media_types
|
||
|
||
async def _maybe_mark_read(self, conv_id: str, item: Dict[str, Any]) -> None:
|
||
"""Best-effort read receipt (opt-in via XCHAT_SEND_READ_RECEIPTS)."""
|
||
if not self._send_read_receipts or self._api is None:
|
||
return
|
||
seq = str(item.get("sequence_id") or item.get("id") or "")
|
||
if not seq:
|
||
return
|
||
try:
|
||
await self._api.mark_read(conv_id, seq)
|
||
except Exception:
|
||
logger.debug("[xchat] mark-read failed conv=%s", conv_id, exc_info=True)
|
||
|
||
def _set_cursor(self, conv_id: str, event_id: str) -> None:
|
||
"""Advance + persist the per-conversation cursor (monotonic)."""
|
||
current = self._cursors.get(conv_id, "")
|
||
if current and not _event_id_newer(event_id, current):
|
||
return
|
||
self._cursors[conv_id] = event_id
|
||
try:
|
||
path = _cursor_path()
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = path.with_suffix(".json.tmp")
|
||
tmp.write_text(json.dumps(self._cursors, indent=0) + "\n", encoding="utf-8")
|
||
tmp.replace(path)
|
||
except OSError:
|
||
logger.debug("[xchat] cursor persist failed", exc_info=True)
|
||
|
||
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],
|
||
media_urls: Optional[List[str]] = None,
|
||
media_types: Optional[List[str]] = None,
|
||
reply_to: Optional[Dict[str, Any]] = None,
|
||
) -> 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 and not media_urls:
|
||
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,
|
||
)
|
||
mtype = MessageType.TEXT
|
||
if media_urls:
|
||
first = (media_types or [""])[0]
|
||
if first.startswith("image/"):
|
||
mtype = MessageType.PHOTO
|
||
elif first.startswith("audio/"):
|
||
mtype = MessageType.VOICE
|
||
elif first.startswith("video/"):
|
||
mtype = MessageType.VIDEO
|
||
else:
|
||
mtype = MessageType.DOCUMENT
|
||
reply_to = reply_to or {}
|
||
event = MessageEvent(
|
||
text=text,
|
||
message_type=mtype,
|
||
source=source,
|
||
raw_message=raw,
|
||
message_id=message_id,
|
||
user_id=sender_id,
|
||
media_urls=list(media_urls or []),
|
||
media_types=list(media_types or []),
|
||
reply_to_message_id=reply_to.get("message_id"),
|
||
reply_to_text=reply_to.get("text"),
|
||
reply_to_author_id=reply_to.get("author_id"),
|
||
reply_to_is_own_message=(
|
||
str(reply_to.get("author_id") or "") == self._bot_user_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._encrypt_outbound(chat_id, content, reply_to=reply_to)
|
||
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 handshake — see
|
||
# initiate_conversation() (used by the standalone sender).
|
||
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. To message a brand-new user, use "
|
||
"`hermes send xchat:<user-id>` (it performs the key "
|
||
"handshake automatically)."
|
||
),
|
||
)
|
||
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)
|
||
|
||
def _encrypt_outbound(
|
||
self,
|
||
chat_id: str,
|
||
content: str,
|
||
*,
|
||
reply_to: Optional[str] = None,
|
||
attachments: Optional[List[Dict[str, Any]]] = None,
|
||
) -> Dict[str, str]:
|
||
"""Encrypt text (or media caption) — native threaded reply when the
|
||
replied-to event is in the decrypted-event cache."""
|
||
assert self._crypto is not None
|
||
if reply_to:
|
||
target = (self._event_cache.get(chat_id) or {}).get(str(reply_to))
|
||
if target is not None:
|
||
try:
|
||
return self._crypto.encrypt_reply(
|
||
chat_id, content, target, attachments=attachments
|
||
)
|
||
except Exception:
|
||
logger.debug(
|
||
"[xchat] encrypt_reply failed conv=%s — plain send", chat_id,
|
||
exc_info=True,
|
||
)
|
||
return self._crypto.encrypt_text(chat_id, content, attachments=attachments)
|
||
|
||
def _conversation_key_for_send(self, chat_id: str) -> Optional[bytes]:
|
||
"""Latest cached raw conversation key (for media stream encryption)."""
|
||
keys = self._conversation_keys.get(chat_id) or {}
|
||
if not keys:
|
||
return None
|
||
latest = self._latest_key_version.get(chat_id)
|
||
if latest and latest in keys:
|
||
return keys[latest]
|
||
# Highest numeric version wins when latest is unknown.
|
||
try:
|
||
return keys[max(keys, key=lambda v: int(v))]
|
||
except (ValueError, TypeError):
|
||
return next(iter(keys.values()))
|
||
|
||
async def _send_media_file(
|
||
self,
|
||
chat_id: str,
|
||
file_path: str,
|
||
caption: Optional[str],
|
||
reply_to: Optional[str] = None,
|
||
) -> SendResult:
|
||
"""Encrypt + upload a file, then send a message carrying the attachment."""
|
||
if self._api is None or self._crypto is None:
|
||
return SendResult(success=False, error="xchat adapter not connected")
|
||
conv_key = self._conversation_key_for_send(chat_id)
|
||
if conv_key is None:
|
||
return SendResult(
|
||
success=False,
|
||
error="No conversation key cached — cannot encrypt media yet.",
|
||
)
|
||
try:
|
||
plaintext = Path(file_path).read_bytes()
|
||
except OSError as e:
|
||
return SendResult(success=False, error=f"cannot read media file: {e}")
|
||
try:
|
||
blob = self._crypto.encrypt_media(plaintext, conv_key)
|
||
media_hash_key = await self._api.media_upload(chat_id, blob)
|
||
except (XChatApiError, Exception) as e:
|
||
logger.warning("[xchat] media upload failed conv=%s: %s", chat_id, e)
|
||
return SendResult(success=False, error=f"media upload failed: {e}")
|
||
|
||
att: Dict[str, Any] = {
|
||
"attachment_type": "media",
|
||
"media_hash_key": media_hash_key,
|
||
"filesize_bytes": len(plaintext),
|
||
"filename": Path(file_path).name,
|
||
}
|
||
dims = detect_image_dimensions(plaintext)
|
||
att["width"], att["height"] = dims if dims else (0, 0)
|
||
try:
|
||
body = self._encrypt_outbound(
|
||
chat_id, caption or "", reply_to=reply_to, attachments=[att]
|
||
)
|
||
out = await self._api.send_message(chat_id, body)
|
||
except ValueError:
|
||
return SendResult(success=False, error="No verified conversation key.")
|
||
except XChatApiError as e:
|
||
return SendResult(success=False, error=str(e))
|
||
data = out.get("data") or {}
|
||
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=str(data.get("message_id") or body.get("message_id") or ""),
|
||
)
|
||
|
||
async def send_image(
|
||
self,
|
||
chat_id: str,
|
||
image_url: str,
|
||
caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
path = image_url
|
||
if path.startswith("file://"):
|
||
from urllib.parse import unquote, urlparse
|
||
|
||
path = unquote(urlparse(path).path)
|
||
if not os.path.isfile(path):
|
||
# Remote URL — fall back to the base implementation (sends URL text).
|
||
return await super().send_image(chat_id, image_url, caption, reply_to, metadata)
|
||
return await self._send_media_file(chat_id, path, caption, reply_to)
|
||
|
||
async def send_image_file(
|
||
self,
|
||
chat_id: str,
|
||
image_path: str,
|
||
caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
**kwargs,
|
||
) -> SendResult:
|
||
return await self._send_media_file(chat_id, image_path, caption, reply_to)
|
||
|
||
async def send_voice(
|
||
self,
|
||
chat_id: str,
|
||
audio_path: str,
|
||
caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
**kwargs,
|
||
) -> SendResult:
|
||
return await self._send_media_file(chat_id, audio_path, caption, reply_to)
|
||
|
||
async def send_video(
|
||
self,
|
||
chat_id: str,
|
||
video_path: str,
|
||
caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
**kwargs,
|
||
) -> SendResult:
|
||
return await self._send_media_file(chat_id, video_path, caption, reply_to)
|
||
|
||
async def send_document(
|
||
self,
|
||
chat_id: str,
|
||
file_path: str,
|
||
caption: Optional[str] = None,
|
||
file_name: Optional[str] = None,
|
||
reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
**kwargs,
|
||
) -> SendResult:
|
||
return await self._send_media_file(chat_id, file_path, caption, reply_to)
|
||
|
||
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"),
|
||
("XCHAT_SEND_READ_RECEIPTS", "send_read_receipts"),
|
||
):
|
||
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 (or performs the
|
||
conversation-key handshake for a brand-new 1:1 given a bare user id),
|
||
encrypts, sends, and closes. ``media_files`` are encrypted with the
|
||
conversation key, uploaded via the 3-step chat-media flow, and attached
|
||
to the message. ``thread_id`` is accepted for signature parity — X Chat
|
||
has no thread primitive.
|
||
"""
|
||
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).
|
||
# KeyChange verification needs the participants' signing keys in the
|
||
# SDK store FIRST — decrypt_batch can't verify (and therefore can't
|
||
# seed the conversation key) without them. KeyChange events arrive
|
||
# separately in meta.conversation_key_events.
|
||
page = await api.get_events(chat_id, max_results=50)
|
||
raw_events = page.get("data") or []
|
||
meta = page.get("meta") or {}
|
||
key_events = [k for k in (meta.get("conversation_key_events") or []) if k]
|
||
events_b64 = key_events + [
|
||
e["encoded_event"] for e in raw_events if e.get("encoded_event")
|
||
]
|
||
canonical = chat_id
|
||
raw_key: Optional[bytes] = None
|
||
raw_key_version: Optional[str] = None
|
||
if events_b64:
|
||
sender_ids = {
|
||
str(e.get("sender_id"))
|
||
for e in raw_events
|
||
if e.get("sender_id") and str(e.get("sender_id")) != user_id
|
||
}
|
||
signing_keys: List[Dict[str, str]] = []
|
||
for sender_id in sender_ids:
|
||
try:
|
||
for pk in await api.get_public_keys(sender_id):
|
||
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 "",
|
||
}
|
||
)
|
||
except Exception:
|
||
logger.debug("[xchat] standalone public-key fetch failed sender=%s", sender_id)
|
||
if signing_keys:
|
||
crypto.set_signing_keys(signing_keys)
|
||
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
|
||
conv_keys = (batch.get("conversation_keys") or {}).get("keys") or {}
|
||
latest = batch.get("latest_key_version")
|
||
if conv_keys:
|
||
if latest and str(latest) in conv_keys:
|
||
raw_key_version = str(latest)
|
||
else:
|
||
try:
|
||
raw_key_version = max(conv_keys, key=lambda v: int(v))
|
||
except (ValueError, TypeError):
|
||
raw_key_version = next(iter(conv_keys))
|
||
raw_key = conv_keys[raw_key_version]
|
||
except Exception as e:
|
||
logger.debug("[xchat] standalone backlog decrypt: %s", e)
|
||
|
||
explicit_key: Optional[bytes] = None
|
||
explicit_key_version: Optional[str] = None
|
||
|
||
# Encrypt + upload media attachments (needs the RAW conversation key).
|
||
attachments: List[Dict[str, Any]] = []
|
||
media_errors: List[str] = []
|
||
for mf in media_files or []:
|
||
# send_message_tool passes (path, is_voice) tuples; accept bare
|
||
# strings/paths too for direct callers.
|
||
if isinstance(mf, (tuple, list)) and mf:
|
||
mpath = str(mf[0])
|
||
else:
|
||
mpath = str(getattr(mf, "path", None) or mf)
|
||
if raw_key is None:
|
||
media_errors.append(f"{Path(mpath).name}: no raw conversation key")
|
||
continue
|
||
try:
|
||
plaintext = Path(mpath).read_bytes()
|
||
blob = crypto.encrypt_media(plaintext, raw_key)
|
||
media_hash_key = await api.media_upload(canonical, blob)
|
||
except (OSError, XChatApiError, Exception) as e:
|
||
media_errors.append(f"{Path(mpath).name}: {e}")
|
||
continue
|
||
att: Dict[str, Any] = {
|
||
"attachment_type": "media",
|
||
"media_hash_key": media_hash_key,
|
||
"filesize_bytes": len(plaintext),
|
||
"filename": Path(mpath).name,
|
||
}
|
||
dims = detect_image_dimensions(plaintext)
|
||
att["width"], att["height"] = dims if dims else (0, 0)
|
||
attachments.append(att)
|
||
if media_errors:
|
||
logger.warning("[xchat] standalone media skipped: %s", "; ".join(media_errors))
|
||
|
||
try:
|
||
body = crypto.encrypt_text(
|
||
canonical, message, attachments=attachments or None
|
||
)
|
||
except ValueError:
|
||
# No verified conversation key. For a bare recipient user id
|
||
# (brand-new 1:1), perform the conversation-key handshake:
|
||
# verify both parties' key bindings, wrap a fresh key for each,
|
||
# and POST it — then encrypt under the raw key directly.
|
||
if not _is_bare_user_id(chat_id):
|
||
return {
|
||
"error": (
|
||
"xchat: no verified conversation key — the target must have "
|
||
"an existing conversation with the bot, or pass the "
|
||
"recipient's bare user id to start a new one"
|
||
)
|
||
}
|
||
try:
|
||
init = await _initiate_conversation(api, crypto, user_id, chat_id)
|
||
except (XChatApiError, ValueError) as e:
|
||
return {"error": f"xchat: conversation-key handshake failed: {e}"}
|
||
canonical = init["conversation_id"] or chat_id
|
||
explicit_key = init["conversation_key"]
|
||
explicit_key_version = init["conversation_key_version"]
|
||
body = crypto.encrypt_text(
|
||
canonical,
|
||
message,
|
||
conversation_key=explicit_key,
|
||
conversation_key_version=explicit_key_version,
|
||
)
|
||
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 _is_bare_user_id(chat_id: str) -> bool:
|
||
"""True for a bare numeric X user id (a 1:1 target with no conversation yet)."""
|
||
return str(chat_id).isdigit()
|
||
|
||
|
||
async def _initiate_conversation(
|
||
api: "XChatApi", crypto: "XChatCrypto", bot_user_id: str, recipient_id: str
|
||
) -> Dict[str, Any]:
|
||
"""Conversation-key handshake for a brand-new 1:1 conversation.
|
||
|
||
Fetches both parties' public keys, verifies each record's
|
||
identity↔signing binding (a substituted identity key must never receive
|
||
the conversation key), wraps a fresh conversation key for every
|
||
participant, and POSTs it to the add-conversation-keys endpoint.
|
||
|
||
Returns ``{"conversation_id", "conversation_key", "conversation_key_version"}``.
|
||
"""
|
||
participants: List[Dict[str, str]] = []
|
||
for uid in (bot_user_id, recipient_id):
|
||
records = await api.get_public_keys(uid)
|
||
if not records:
|
||
raise ValueError(f"user {uid} has no registered X Chat public keys")
|
||
rec = records[0]
|
||
if not crypto.verify_key_binding(
|
||
str(rec.get("public_key") or ""),
|
||
str(rec.get("signing_public_key") or ""),
|
||
str(rec.get("identity_public_key_signature") or ""),
|
||
):
|
||
raise ValueError(f"public-key binding verification failed for user {uid}")
|
||
participants.append(
|
||
{
|
||
"user_id": uid,
|
||
"public_key": str(rec.get("public_key") or ""),
|
||
"key_version": str(rec.get("public_key_version") or "1"),
|
||
}
|
||
)
|
||
|
||
prepared = crypto.prepare_conversation_key_change(participants)
|
||
resp = await api.add_conversation_keys(recipient_id, prepared["body"])
|
||
data = resp.get("data") or {}
|
||
return {
|
||
"conversation_id": str(data.get("conversation_id") or ""),
|
||
"conversation_key": prepared["conversation_key"],
|
||
"conversation_key_version": prepared["conversation_key_version"],
|
||
}
|
||
|
||
|
||
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. You can send files, images, "
|
||
"voice notes, and videos as encrypted attachments via MEDIA:<path> "
|
||
"tags; inbound attachments are decrypted locally and available "
|
||
"to your vision/file tools."
|
||
),
|
||
)
|
||
|
||
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,
|
||
)
|