Hermes-agent

This commit is contained in:
Zakaria
2026-06-14 14:30:48 -04:00
commit dac4b88b94
5058 changed files with 1884848 additions and 0 deletions
View File
+436
View File
@@ -0,0 +1,436 @@
"""Shared fixtures for gateway e2e tests (Telegram, Discord).
These tests exercise the full async message flow:
adapter.handle_message(event)
→ background task
→ GatewayRunner._handle_message (command dispatch)
→ adapter.send() (captured by mock)
No LLM, no real platform connections.
"""
import asyncio
import sys
import uuid
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, SendResult
from gateway.session import SessionEntry, SessionSource, build_session_key
E2E_MESSAGE_SETTLE_DELAY = 0.3
# Platform library mocks
# Ensure telegram module is available (mock it if not installed)
def _ensure_telegram_mock():
"""Install mock telegram modules so TelegramAdapter can be imported."""
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
return # Real library installed
telegram_mod = MagicMock()
telegram_mod.Update = MagicMock()
telegram_mod.Update.ALL_TYPES = []
telegram_mod.Bot = MagicMock
telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
telegram_mod.ext.Application = MagicMock()
telegram_mod.ext.Application.builder = MagicMock
telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
telegram_mod.ext.MessageHandler = MagicMock
telegram_mod.ext.CommandHandler = MagicMock
telegram_mod.ext.filters = MagicMock()
telegram_mod.request.HTTPXRequest = MagicMock
for name in (
"telegram",
"telegram.constants",
"telegram.ext",
"telegram.ext.filters",
"telegram.request",
):
sys.modules.setdefault(name, telegram_mod)
# Ensure discord module is available (mock it if not installed)
def _ensure_discord_mock():
"""Install mock discord modules so DiscordAdapter can be imported."""
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
return # Real library installed
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
discord_mod.DMChannel = type("DMChannel", (), {})
discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.Forbidden = type("Forbidden", (Exception,), {})
discord_mod.MessageType = SimpleNamespace(default=0, reply=19)
discord_mod.Object = lambda *, id: SimpleNamespace(id=id)
discord_mod.Interaction = object
discord_mod.app_commands = SimpleNamespace(
describe=lambda **kwargs: (lambda fn: fn),
choices=lambda **kwargs: (lambda fn: fn),
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
)
discord_mod.opus.is_loaded.return_value = True
ext_mod = MagicMock()
commands_mod = MagicMock()
commands_mod.Bot = MagicMock
ext_mod.commands = commands_mod
sys.modules.setdefault("discord", discord_mod)
sys.modules.setdefault("discord.ext", ext_mod)
sys.modules.setdefault("discord.ext.commands", commands_mod)
sys.modules.setdefault("discord.opus", discord_mod.opus)
def _ensure_slack_mock():
"""Install mock slack modules so SlackAdapter can be imported."""
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
return # Real library installed
slack_bolt = MagicMock()
slack_bolt.async_app.AsyncApp = MagicMock
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
slack_sdk = MagicMock()
slack_sdk.web.async_client.AsyncWebClient = MagicMock
for name, mod in [
("slack_bolt", slack_bolt),
("slack_bolt.async_app", slack_bolt.async_app),
("slack_bolt.adapter", slack_bolt.adapter),
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
("slack_sdk", slack_sdk),
("slack_sdk.web", slack_sdk.web),
("slack_sdk.web.async_client", slack_sdk.web.async_client),
]:
sys.modules.setdefault(name, mod)
_ensure_telegram_mock()
_ensure_discord_mock()
_ensure_slack_mock()
import discord # noqa: E402 — mocked above
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
import gateway.platforms.slack as _slack_mod # noqa: E402
_slack_mod.SLACK_AVAILABLE = True
from gateway.platforms.slack import SlackAdapter # noqa: E402
# Platform-generic factories
def make_source(platform: Platform, chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1", chat_type: str = "dm") -> SessionSource:
return SessionSource(
platform=platform,
chat_id=chat_id,
user_id=user_id,
user_name="e2e_tester",
chat_type=chat_type,
)
def make_session_entry(platform: Platform, source: SessionSource = None) -> SessionEntry:
source = source or make_source(platform)
return SessionEntry(
session_key=build_session_key(source),
session_id=f"sess-{uuid.uuid4().hex[:8]}",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=platform,
chat_type="dm",
)
def make_event(
platform: Platform,
text: str = "/help",
chat_id: str = "e2e-chat-1",
user_id: str = "e2e-user-1",
chat_type: str = "dm",
) -> MessageEvent:
return MessageEvent(
text=text,
source=make_source(platform, chat_id, user_id, chat_type),
message_id=f"msg-{uuid.uuid4().hex[:8]}",
)
def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "GatewayRunner":
"""Create a GatewayRunner with mocked internals for e2e testing.
Skips __init__ to avoid filesystem/network side effects.
"""
from gateway.run import GatewayRunner
if session_entry is None:
session_entry = make_session_entry(platform)
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={platform: PlatformConfig(enabled=True, token="e2e-test-token")}
)
runner.adapters = {}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = session_entry
runner.session_store.load_transcript.return_value = []
runner.session_store.has_any_sessions.return_value = True
runner.session_store.append_to_transcript = MagicMock()
runner.session_store.rewrite_transcript = MagicMock()
runner.session_store.update_session = MagicMock()
runner.session_store.reset_session = MagicMock()
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._shutdown_event = asyncio.Event()
runner._exit_reason = None
runner._exit_code = None
runner._background_tasks = set()
runner._draining = False
runner._restart_requested = False
runner._restart_task_started = False
runner._restart_detached = False
runner._restart_via_service = False
from gateway.restart import DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
runner._restart_drain_timeout = DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
runner._stop_task = None
runner._busy_input_mode = "interrupt"
runner._running_agents_ts = {}
runner._pending_model_notes = {}
runner._update_prompt_pending = {}
runner._voice_mode = {}
runner._session_db = None
runner._reasoning_config = None
runner._provider_routing = {}
runner._fallback_model = None
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._handle_message_with_agent = AsyncMock(return_value="agent-handled-default")
runner._should_send_voice_reply = lambda *_a, **_kw: False
runner._send_voice_reply = AsyncMock()
runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None
runner._emit_gateway_run_progress = AsyncMock()
# Disable destructive slash confirm gate so /new executes immediately
runner._read_user_config = lambda: {"approvals": {"destructive_slash_confirm": False}}
runner.pairing_store = MagicMock()
runner.pairing_store._is_rate_limited = MagicMock(return_value=False)
runner.pairing_store.generate_code = MagicMock(return_value="ABC123")
return runner
def make_adapter(platform: Platform, runner=None):
"""Create a platform adapter wired to *runner*, with send methods mocked."""
if runner is None:
runner = make_runner(platform)
config = PlatformConfig(enabled=True, token="e2e-test-token")
if platform == Platform.DISCORD:
from gateway.platforms.helpers import ThreadParticipationTracker
with patch.object(ThreadParticipationTracker, "_load", return_value=set()):
adapter = DiscordAdapter(config)
platform_key = Platform.DISCORD
elif platform == Platform.SLACK:
adapter = SlackAdapter(config)
platform_key = Platform.SLACK
else:
adapter = TelegramAdapter(config)
platform_key = Platform.TELEGRAM
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="e2e-resp-1"))
adapter.send_typing = AsyncMock()
adapter.set_message_handler(runner._handle_message)
runner.adapters[platform_key] = adapter
return adapter
async def send_and_capture(adapter, text: str, platform: Platform, **event_kwargs) -> AsyncMock:
"""Send a message through the full e2e flow and return the send mock."""
event = make_event(platform, text, **event_kwargs)
adapter.send.reset_mock()
await adapter.handle_message(event)
await asyncio.sleep(0.3)
return adapter.send
# Parametrized fixtures for platform-generic tests
@pytest.fixture(params=[Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK], ids=["telegram", "discord", "slack"])
def platform(request):
return request.param
@pytest.fixture()
def source(platform):
return make_source(platform)
@pytest.fixture()
def session_entry(platform, source):
return make_session_entry(platform, source)
@pytest.fixture()
def runner(platform, session_entry):
return make_runner(platform, session_entry)
@pytest.fixture()
def adapter(platform, runner):
return make_adapter(platform, runner)
# ═══════════════════════════════════════════════════════════════════════════
# Discord helpers and fixtures
# ═══════════════════════════════════════════════════════════════════════════
BOT_USER_ID = 99999
BOT_USER_NAME = "HermesBot"
CHANNEL_ID = 22222
GUILD_ID = 44444
THREAD_ID = 33333
MESSAGE_ID_COUNTER = 0
def _next_message_id() -> int:
global MESSAGE_ID_COUNTER
MESSAGE_ID_COUNTER += 1
return 70000 + MESSAGE_ID_COUNTER
def make_fake_bot_user():
return SimpleNamespace(
id=BOT_USER_ID, name=BOT_USER_NAME,
display_name=BOT_USER_NAME, bot=True,
)
def make_fake_guild(guild_id: int = GUILD_ID, name: str = "Test Server"):
return SimpleNamespace(id=guild_id, name=name)
def make_fake_text_channel(channel_id: int = CHANNEL_ID, name: str = "general", guild=None):
return SimpleNamespace(
id=channel_id, name=name,
guild=guild or make_fake_guild(),
topic=None, type=0,
)
def make_fake_dm_channel(channel_id: int = 55555):
ch = MagicMock(spec=[])
ch.id = channel_id
ch.name = "DM"
ch.topic = None
ch.__class__ = discord.DMChannel
return ch
def make_fake_thread(thread_id: int = THREAD_ID, name: str = "test-thread", parent=None):
th = MagicMock(spec=[])
th.id = thread_id
th.name = name
th.parent = parent or make_fake_text_channel()
th.parent_id = th.parent.id
th.guild = th.parent.guild
th.topic = None
th.type = 11
th.__class__ = discord.Thread
return th
def make_discord_message(
*, content: str = "hello", author=None, channel=None, mentions=None,
attachments=None, message_id: int = None,
):
if message_id is None:
message_id = _next_message_id()
if author is None:
author = SimpleNamespace(
id=11111, name="testuser", display_name="testuser", bot=False,
)
if channel is None:
channel = make_fake_text_channel()
if mentions is None:
mentions = []
if attachments is None:
attachments = []
return SimpleNamespace(
id=message_id, content=content, author=author, channel=channel,
guild=getattr(channel, "guild", None),
mentions=mentions, attachments=attachments,
type=getattr(discord, "MessageType", SimpleNamespace()).default,
reference=None, created_at=datetime.now(timezone.utc),
create_thread=AsyncMock(),
)
def get_response_text(adapter) -> str | None:
"""Extract the response text from adapter.send() call args, or None if not called."""
if not adapter.send.called:
return None
return adapter.send.call_args[1].get("content") or adapter.send.call_args[0][1]
def _make_discord_adapter_wired(runner=None):
"""Create a DiscordAdapter wired to a GatewayRunner for e2e tests."""
if runner is None:
runner = make_runner(Platform.DISCORD)
config = PlatformConfig(enabled=True, token="e2e-test-token")
from gateway.platforms.helpers import ThreadParticipationTracker
with patch.object(ThreadParticipationTracker, "_load", return_value=set()):
adapter = DiscordAdapter(config)
bot_user = make_fake_bot_user()
adapter._client = SimpleNamespace(
user=bot_user,
get_channel=lambda _id: None,
fetch_channel=AsyncMock(),
)
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="e2e-resp-1"))
adapter.send_typing = AsyncMock()
adapter.set_message_handler(runner._handle_message)
runner.adapters[Platform.DISCORD] = adapter
return adapter, runner
@pytest.fixture()
def discord_setup():
return _make_discord_adapter_wired()
@pytest.fixture()
def discord_adapter(discord_setup):
return discord_setup[0]
@pytest.fixture()
def discord_runner(discord_setup):
return discord_setup[1]
@pytest.fixture()
def bot_user():
return make_fake_bot_user()
@@ -0,0 +1,49 @@
# Matrix cross-signing bootstrap — E2E test
Self-contained end-to-end test for the auto-bootstrap behavior added in
`gateway/platforms/matrix.py`. Spins up a real Continuwuity homeserver
in Docker, registers a fresh bot, runs the patched bootstrap path
against it, and asserts:
1. Cross-signing keys get published with **unpadded** base64 keyids
(the bug this PR fixes — padded keyids are silently rejected by
matrix-rust-sdk in Element).
2. On a second startup with the same crypto store, bootstrap is
skipped.
3. When `MATRIX_RECOVERY_KEY` is set, the existing recovery-key path
takes precedence and no fresh bootstrap happens.
## Run
```bash
# from repo root
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml up -d
python tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml down -v
```
The `down -v` step removes the persistent volume so the next run gets
a fresh homeserver — important because Continuwuity's one-time admin
registration token is only valid before the first user is created.
## Port
The compose binds Continuwuity to `127.0.0.1:26167` by default. Override
with `HOMESERVER_HOST_PORT=NNNNN docker compose up -d` if that port is
busy locally.
## What the test exercises
The test mirrors the bootstrap snippet from
`gateway/platforms/matrix.py` (the "if MATRIX_RECOVERY_KEY else
get_own_cross_signing_public_keys / generate_recovery_key" branch)
inline so it runs without importing the entire hermes gateway and its
many dependencies. **If the source diverges from what's in
`_connect_with_bootstrap`, this test must be updated to match.** A
small price for not requiring the full hermes-agent runtime in CI.
## Skipped when
- `mautrix` Python package is not installed
- The homeserver isn't reachable at `$E2E_MATRIX_HS` (default
`http://127.0.0.1:26167`)
@@ -0,0 +1,21 @@
services:
homeserver:
image: ghcr.io/continuwuity/continuwuity:latest
environment:
CONTINUWUITY_SERVER_NAME: localhost
CONTINUWUITY_DATABASE_PATH: /var/lib/conduwuit/conduwuit.db
CONTINUWUITY_PORT: "6167"
CONTINUWUITY_ADDRESS: "0.0.0.0"
CONTINUWUITY_ALLOW_REGISTRATION: "true"
CONTINUWUITY_REGISTRATION_TOKEN: testreg
CONTINUWUITY_ALLOW_FEDERATION: "false"
CONTINUWUITY_TRUSTED_SERVERS: "[]"
CONTINUWUITY_LOG: "warn,conduwuit=info"
CONTINUWUITY_ALLOW_CHECK_FOR_UPDATES: "false"
ports:
- "127.0.0.1:${HOMESERVER_HOST_PORT:-26167}:6167"
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/6167 && echo -e 'GET /_matrix/client/versions HTTP/1.0\\r\\n\\r\\n' >&3 && head -1 <&3 | grep -q '200 OK' || exit 1"]
interval: 2s
timeout: 3s
retries: 30
@@ -0,0 +1,331 @@
"""End-to-end test for Matrix cross-signing auto-bootstrap.
Spins a real Continuwuity homeserver in docker, registers a fresh bot,
runs the patched ``MatrixAdapter.connect()`` against it, and asserts:
1. cross-signing keys get published with **unpadded** base64 keyids
(the bug this PR fixes — padded keyids are silently rejected by
matrix-rust-sdk in Element);
2. on a second startup with the same crypto store, bootstrap is
skipped (``get_own_cross_signing_public_keys`` finds the keys);
3. the bot's current device is signed by the new SSK, so Element
considers the device "verified by its owner".
Self-contained: ``docker compose up -d`` brings up Continuwuity on
127.0.0.1:26167; this script registers a fresh bot using the
homeserver's one-time admin registration token (printed once at first
boot, parsed from the container logs); then drives the gateway code.
Run from repo root::
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml up -d
python tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml down -v
Skipped automatically if mautrix isn't installed or the homeserver
isn't reachable.
"""
from __future__ import annotations
import json
import logging
import os
import re
import secrets
import shutil
import subprocess
import sys
import tempfile
import unittest
import urllib.error
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(REPO_ROOT))
HS = os.environ.get("E2E_MATRIX_HS", "http://127.0.0.1:26167")
COMPOSE_DIR = Path(__file__).parent
CONTAINER_NAME = "matrix_xsign_bootstrap-homeserver-1"
def _hs_reachable() -> bool:
try:
urllib.request.urlopen(f"{HS}/_matrix/client/versions", timeout=2).read()
return True
except Exception:
return False
def _first_time_token() -> str | None:
"""Continuwuity prints a one-time registration token on first boot.
The configured CONTINUWUITY_REGISTRATION_TOKEN does NOT activate
until an account exists, so we have to pull this token out of the
docker logs to bootstrap the very first user.
"""
try:
out = subprocess.run(
["docker", "logs", CONTAINER_NAME],
capture_output=True, text=True, check=True,
).stdout + subprocess.run(
["docker", "logs", CONTAINER_NAME],
capture_output=True, text=True, check=True,
).stderr
except Exception:
return None
cleaned = re.sub(r"\x1b\[[0-9;]*m", "", out)
m = re.search(r"registration token ([A-Za-z0-9]+)", cleaned)
return m.group(1) if m else None
def _post_json(url: str, body: dict, headers: dict | None = None) -> tuple[int, dict]:
req = urllib.request.Request(
url, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json", **(headers or {})},
method="POST",
)
try:
r = urllib.request.urlopen(req)
return r.status, json.load(r)
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode())
CONFIG_REG_TOKEN = "testreg" # matches docker-compose.yml
def _register_bot(*, prefer_token: str = CONFIG_REG_TOKEN, fallback_token: str | None = None) -> dict:
"""Register a fresh bot. Tries the configured token first; falls back to
the homeserver's one-time admin token (only valid until the first user
is created)."""
user = "bot" + secrets.token_hex(3)
password = secrets.token_urlsafe(20)
last_err = None
for tok in (prefer_token, fallback_token):
if tok is None:
continue
st, b = _post_json(f"{HS}/_matrix/client/v3/register", {})
if st != 401 or "session" not in b:
last_err = (st, b); continue
session = b["session"]
st, b = _post_json(f"{HS}/_matrix/client/v3/register", {
"auth": {"type": "m.login.registration_token", "token": tok, "session": session},
"username": user, "password": password,
"initial_device_display_name": "e2e-bootstrap-test",
})
if st == 200:
return b
last_err = (st, b)
raise AssertionError(f"register failed for both tokens: {last_err}")
def _query_keys(token: str, mxid: str) -> dict:
return _post_json(
f"{HS}/_matrix/client/v3/keys/query",
{"device_keys": {mxid: []}},
headers={"Authorization": f"Bearer {token}"},
)[1]
@unittest.skipUnless(_hs_reachable(), f"homeserver not reachable at {HS}")
class XsignBootstrapE2E(unittest.IsolatedAsyncioTestCase):
"""Drive the patched MatrixAdapter.connect() against real continuwuity."""
@classmethod
def setUpClass(cls):
try:
import mautrix # noqa: F401
except ImportError:
raise unittest.SkipTest("mautrix not installed")
cls.first_tok = _first_time_token()
# If no user has ever been created, the configured `testreg` token
# won't activate yet — burn the one-time admin token first to
# bootstrap the homeserver into a usable state.
if cls.first_tok:
try:
_register_bot(prefer_token=cls.first_tok, fallback_token=None)
except AssertionError:
pass # Already burnt previously; testreg should now work.
async def _connect_with_bootstrap(self, creds: dict, store_dir: Path) -> tuple[list[str], str | None]:
"""Drive matrix.py's bootstrap branch directly.
We import the gateway module and execute the same OlmMachine init +
bootstrap sequence, capturing log lines so we can assert what fired.
Returns (log_lines, recovery_key_or_None).
"""
from mautrix.api import HTTPAPI
from mautrix.client import Client
from mautrix.client.state_store.memory import MemoryStateStore
from mautrix.crypto import OlmMachine, PgCryptoStore
from mautrix.types import TrustState
from mautrix.util.async_db import Database
# The actual bootstrap snippet from gateway/platforms/matrix.py
# (copied so we can run it without importing the full hermes
# gateway and its many deps). If the source code drifts from this,
# the test should be updated to match.
log_lines: list[str] = []
captured_recovery_key: str | None = None
class _Capture(logging.Handler):
def emit(self, record):
log_lines.append(self.format(record))
logger = logging.getLogger("e2e.bootstrap")
logger.setLevel(logging.DEBUG)
handler = _Capture()
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
logger.addHandler(handler)
api = HTTPAPI(base_url=creds["homeserver"], token=creds["access_token"])
client = Client(
mxid=creds["user_id"], api=api,
device_id=creds["device_id"], state_store=MemoryStateStore(),
)
client.api.token = creds["access_token"]
store_dir.mkdir(parents=True, exist_ok=True)
db_path = store_dir / "crypto.db"
crypto_db = Database.create(f"sqlite:///{db_path}", upgrade_table=PgCryptoStore.upgrade_table)
await crypto_db.start()
crypto_store = PgCryptoStore(account_id=creds["user_id"], pickle_key="e2e-test", db=crypto_db)
await crypto_store.open()
olm = OlmMachine(client, crypto_store, MemoryStateStore())
olm.share_keys_min_trust = TrustState.UNVERIFIED
olm.send_keys_min_trust = TrustState.UNVERIFIED
await olm.load()
# --- The patched bootstrap block, mirrored from matrix.py ---
recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip()
if recovery_key:
try:
await olm.verify_with_recovery_key(recovery_key)
logger.info("Matrix: cross-signing verified via recovery key")
except Exception as exc:
logger.warning("Matrix: recovery key verification failed: %s", exc)
else:
try:
own_xsign = await olm.get_own_cross_signing_public_keys()
except Exception as exc:
own_xsign = None
logger.warning("Matrix: cross-signing key lookup failed: %s", exc)
if own_xsign is None:
try:
new_recovery_key = await olm.generate_recovery_key()
captured_recovery_key = new_recovery_key
logger.warning(
"Matrix: bootstrapped cross-signing for %s. "
"SAVE THIS RECOVERY KEY: %s",
client.mxid, new_recovery_key,
)
except Exception as exc:
logger.warning("Matrix: cross-signing bootstrap failed: %s", exc)
# --- /end patched block ---
# Clean teardown — without this the asyncio loop never exits.
await crypto_db.stop()
await api.session.close()
return log_lines, captured_recovery_key
async def asyncSetUp(self):
self.creds = _register_bot(prefer_token=CONFIG_REG_TOKEN, fallback_token=self.first_tok)
self.creds["homeserver"] = HS
self.tmp = Path(tempfile.mkdtemp(prefix="e2e-xsign-"))
# mautrix.generate_recovery_key requires account.shared, which means
# we must share device keys (one-time keys) first. Do that via a
# short bootstrap to publish device keys.
await self._publish_device_keys(self.creds, self.tmp)
async def _publish_device_keys(self, creds, store_dir):
"""Tiny helper: open OlmMachine, share device keys, close."""
from mautrix.api import HTTPAPI
from mautrix.client import Client
from mautrix.client.state_store.memory import MemoryStateStore
from mautrix.crypto import OlmMachine, PgCryptoStore
from mautrix.util.async_db import Database
api = HTTPAPI(base_url=creds["homeserver"], token=creds["access_token"])
client = Client(mxid=creds["user_id"], api=api, device_id=creds["device_id"],
state_store=MemoryStateStore())
store_dir.mkdir(parents=True, exist_ok=True)
crypto_db = Database.create(f"sqlite:///{store_dir / 'crypto.db'}",
upgrade_table=PgCryptoStore.upgrade_table)
await crypto_db.start()
crypto_store = PgCryptoStore(account_id=creds["user_id"], pickle_key="e2e-test", db=crypto_db)
await crypto_store.open()
olm = OlmMachine(client, crypto_store, MemoryStateStore())
await olm.load()
await olm.share_keys() # publishes device keys (precondition for generate_recovery_key)
await crypto_db.stop()
await api.session.close()
async def asyncTearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
async def test_bootstrap_publishes_unpadded_keys(self):
"""Fresh bot → bootstrap fires, keys published unpadded, device signed."""
log_lines, rec_key = await self._connect_with_bootstrap(self.creds, self.tmp)
# 1. Bootstrap must have produced a recovery key
self.assertIsNotNone(rec_key, "expected recovery key from bootstrap")
self.assertTrue(any("bootstrapped cross-signing" in l for l in log_lines),
f"expected bootstrap log line, got: {log_lines}")
# 2. Homeserver should now serve a master + ssk for the bot
d = _query_keys(self.creds["access_token"], self.creds["user_id"])
self.assertIn(self.creds["user_id"], d.get("master_keys", {}),
"no master_keys after bootstrap")
self.assertIn(self.creds["user_id"], d.get("self_signing_keys", {}),
"no self_signing_keys after bootstrap")
# 3. The keyids must be UNPADDED (this is the bug this PR exists to fix)
master_kid = next(iter(d["master_keys"][self.creds["user_id"]]["keys"]))
ssk_kid = next(iter(d["self_signing_keys"][self.creds["user_id"]]["keys"]))
self.assertFalse(master_kid.endswith("="),
f"master keyid is padded: {master_kid!r}")
self.assertFalse(ssk_kid.endswith("="),
f"ssk keyid is padded: {ssk_kid!r}")
# 4. The current device must be signed by the new SSK
dev = d["device_keys"][self.creds["user_id"]][self.creds["device_id"]]
sig_kids = list(dev["signatures"][self.creds["user_id"]].keys())
self.assertIn(ssk_kid, sig_kids,
f"device {self.creds['device_id']} not signed by new SSK; "
f"signatures: {sig_kids}")
async def test_second_startup_skips_bootstrap(self):
"""Second startup with same crypto store → no second recovery key."""
# First connect bootstraps.
_, rec1 = await self._connect_with_bootstrap(self.creds, self.tmp)
self.assertIsNotNone(rec1, "first connect should have bootstrapped")
# Second connect on same crypto store should NOT re-bootstrap.
log2, rec2 = await self._connect_with_bootstrap(self.creds, self.tmp)
self.assertIsNone(rec2, f"second connect re-bootstrapped! logs: {log2}")
self.assertFalse(any("bootstrapped cross-signing" in l for l in log2),
f"second connect re-bootstrapped! logs: {log2}")
async def test_recovery_key_path_takes_precedence(self):
"""If MATRIX_RECOVERY_KEY is set, no fresh bootstrap happens."""
# First, bootstrap to get a real recovery key.
_, rec_key = await self._connect_with_bootstrap(self.creds, self.tmp)
self.assertIsNotNone(rec_key)
# Fresh store directory + recovery key set in env: must take the
# verify_with_recovery_key path, NOT bootstrap a new identity.
fresh_store = Path(tempfile.mkdtemp(prefix="e2e-xsign-fresh-"))
try:
await self._publish_device_keys(self.creds, fresh_store)
os.environ["MATRIX_RECOVERY_KEY"] = rec_key
try:
log, rec2 = await self._connect_with_bootstrap(self.creds, fresh_store)
self.assertIsNone(rec2, "bootstrap fired despite MATRIX_RECOVERY_KEY being set")
self.assertTrue(
any("verified via recovery key" in l for l in log),
f"expected recovery-key verify log, got: {log}",
)
finally:
del os.environ["MATRIX_RECOVERY_KEY"]
finally:
shutil.rmtree(fresh_store, ignore_errors=True)
if __name__ == "__main__":
unittest.main(verbosity=2)
+155
View File
@@ -0,0 +1,155 @@
"""Minimal e2e tests for Discord mention stripping + /command detection.
Covers the fix for slash commands not being recognized when sent via
@mention in a channel, especially after auto-threading.
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from tests.e2e.conftest import (
BOT_USER_ID,
E2E_MESSAGE_SETTLE_DELAY,
get_response_text,
make_discord_message,
make_fake_dm_channel,
make_fake_thread,
)
pytestmark = pytest.mark.asyncio
async def dispatch(adapter, msg):
await adapter._handle_message(msg)
await asyncio.sleep(E2E_MESSAGE_SETTLE_DELAY)
class TestMentionStrippedCommandDispatch:
async def test_mention_then_command(self, discord_adapter, bot_user):
"""<@BOT> /help → mention stripped, /help dispatched."""
msg = make_discord_message(
content=f"<@{BOT_USER_ID}> /help",
mentions=[bot_user],
)
await dispatch(discord_adapter, msg)
response = get_response_text(discord_adapter)
assert response is not None
assert "/new" in response
async def test_nickname_mention_then_command(self, discord_adapter, bot_user):
"""<@!BOT> /help → nickname mention also stripped, /help works."""
msg = make_discord_message(
content=f"<@!{BOT_USER_ID}> /help",
mentions=[bot_user],
)
await dispatch(discord_adapter, msg)
response = get_response_text(discord_adapter)
assert response is not None
assert "/new" in response
async def test_text_before_command_not_detected(self, discord_adapter, bot_user):
"""'<@BOT> something else /help' → mention stripped, but 'something else /help'
doesn't start with / so it's treated as text, not a command."""
msg = make_discord_message(
content=f"<@{BOT_USER_ID}> something else /help",
mentions=[bot_user],
)
await dispatch(discord_adapter, msg)
# Message is accepted (not dropped by mention gate), but since it doesn't
# start with / it's routed as text — no command output, and no agent in this
# mock setup means no send call either.
response = get_response_text(discord_adapter)
assert response is None or "/new" not in response
async def test_no_mention_in_channel_dropped(self, discord_adapter):
"""Message without @mention in server channel → silently dropped."""
msg = make_discord_message(content="/help", mentions=[])
await dispatch(discord_adapter, msg)
assert get_response_text(discord_adapter) is None
async def test_dm_no_mention_needed(self, discord_adapter):
"""DMs don't require @mention — /help works directly."""
dm = make_fake_dm_channel()
msg = make_discord_message(content="/help", channel=dm, mentions=[])
await dispatch(discord_adapter, msg)
response = get_response_text(discord_adapter)
assert response is not None
assert "/new" in response
class TestAutoThreadingPreservesCommand:
async def test_command_detected_after_auto_thread(self, discord_adapter, bot_user, monkeypatch):
"""@mention /help in channel with auto-thread → thread created AND command dispatched."""
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
fake_thread = make_fake_thread(thread_id=90001, name="help")
msg = make_discord_message(
content=f"<@{BOT_USER_ID}> /help",
mentions=[bot_user],
)
# Simulate discord.py restoring the original raw content (with mention)
# after create_thread(), which undoes any prior mention stripping.
original_content = msg.content
async def clobber_content(**kwargs):
msg.content = original_content
return fake_thread
msg.create_thread = AsyncMock(side_effect=clobber_content)
await dispatch(discord_adapter, msg)
msg.create_thread.assert_awaited_once()
response = get_response_text(discord_adapter)
assert response is not None
assert "/new" in response
class TestRepliedToMediaDispatch:
async def test_reply_to_image_message_caches_referenced_attachment(
self, discord_adapter, bot_user, monkeypatch
):
"""A text reply to an image-bearing Discord message should give the agent that image."""
cached_path = "/tmp/replied-discord-image.png"
async def fake_cache_image_from_url(url, *, ext=".jpg"):
assert url == "https://cdn.discordapp.com/attachments/image.png"
assert ext == ".png"
return cached_path
monkeypatch.setattr(
"plugins.platforms.discord.adapter.cache_image_from_url",
fake_cache_image_from_url,
)
discord_adapter.handle_message = AsyncMock()
attachment = SimpleNamespace(
content_type="image/png",
filename="image.png",
url="https://cdn.discordapp.com/attachments/image.png",
size=1234,
)
referenced_message = SimpleNamespace(
id=12345,
content="",
attachments=[attachment],
)
msg = make_discord_message(
content=f"<@{BOT_USER_ID}> what's in this image?",
mentions=[bot_user],
)
msg.type = 19
msg.reference = SimpleNamespace(message_id=12345, resolved=referenced_message)
await discord_adapter._handle_message(msg)
discord_adapter.handle_message.assert_awaited_once()
await_args = discord_adapter.handle_message.await_args
assert await_args is not None
event = await_args.args[0]
assert event.reply_to_message_id == "12345"
assert event.media_urls == [cached_path]
assert event.media_types == ["image/png"]
assert event.message_type.value == "photo"
+237
View File
@@ -0,0 +1,237 @@
"""E2E tests for gateway slash commands (Telegram, Discord).
Each test drives a message through the full async pipeline:
adapter.handle_message(event)
→ BasePlatformAdapter._process_message_background()
→ GatewayRunner._handle_message() (command dispatch)
→ adapter.send() (captured for assertions)
No LLM involved — only gateway-level commands are tested.
Tests are parametrized over platforms via the ``platform`` fixture in conftest.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import Platform
from gateway.platforms.base import SendResult
from tests.e2e.conftest import make_event, send_and_capture
class TestSlashCommands:
"""Gateway slash commands dispatched through the full adapter pipeline."""
@pytest.mark.asyncio
async def test_help_returns_command_list(self, adapter, platform):
send = await send_and_capture(adapter, "/help", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "/new" in response_text
assert "/status" in response_text
@pytest.mark.asyncio
async def test_status_shows_session_info(self, adapter, platform):
send = await send_and_capture(adapter, "/status", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "session" in response_text.lower() or "Session" in response_text
@pytest.mark.asyncio
async def test_new_resets_session(self, adapter, runner, platform):
send = await send_and_capture(adapter, "/new", platform)
send.assert_called_once()
runner.session_store.reset_session.assert_called_once()
@pytest.mark.asyncio
async def test_stop_when_no_agent_running(self, adapter, platform):
send = await send_and_capture(adapter, "/stop", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
response_lower = response_text.lower()
assert "no" in response_lower or "stop" in response_lower or "not running" in response_lower
@pytest.mark.asyncio
async def test_commands_shows_listing(self, adapter, platform):
send = await send_and_capture(adapter, "/commands", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
# Should list at least some commands
assert "/" in response_text
@pytest.mark.asyncio
async def test_sequential_commands_share_session(self, adapter, platform):
"""Two commands from the same chat_id should both succeed."""
send_help = await send_and_capture(adapter, "/help", platform)
send_help.assert_called_once()
send_status = await send_and_capture(adapter, "/status", platform)
send_status.assert_called_once()
@pytest.mark.asyncio
async def test_verbose_responds(self, adapter, platform):
send = await send_and_capture(adapter, "/verbose", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
# Either shows the mode cycle or tells user to enable it in config
assert "verbose" in response_text.lower() or "tool_progress" in response_text
@pytest.mark.asyncio
async def test_plaintext_restart_gateway_routes_to_safe_restart_command(self, adapter, runner, platform, monkeypatch):
if platform != Platform.TELEGRAM:
pytest.skip("Plaintext restart shortcut is intentionally DM/Telegram-focused")
monkeypatch.setenv("INVOCATION_ID", "e2e-systemd")
runner.request_restart = MagicMock(return_value=True)
send = await send_and_capture(adapter, "restart gateway", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "restart" in response_text.lower() or "draining" in response_text.lower()
runner.request_restart.assert_called_once_with(detached=False, via_service=True)
@pytest.mark.asyncio
async def test_plaintext_restart_gateway_in_group_stays_plain_text(self, adapter, runner, platform, monkeypatch):
if platform != Platform.TELEGRAM:
pytest.skip("Shortcut scope is only verified for Telegram here")
monkeypatch.setenv("INVOCATION_ID", "e2e-systemd")
runner.request_restart = MagicMock(return_value=True)
runner._handle_message_with_agent = AsyncMock(return_value="agent-handled")
send = await send_and_capture(adapter, "restart gateway", platform, chat_id="group-chat-1", user_id="u1", chat_type="group")
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert response_text == "agent-handled"
runner.request_restart.assert_not_called()
@pytest.mark.asyncio
async def test_personality_lists_options(self, adapter, platform):
send = await send_and_capture(adapter, "/personality", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "personalit" in response_text.lower() # matches "personality" or "personalities"
@pytest.mark.asyncio
async def test_yolo_toggles_mode(self, adapter, platform):
send = await send_and_capture(adapter, "/yolo", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "yolo" in response_text.lower()
@pytest.mark.asyncio
async def test_compress_command(self, adapter, platform):
send = await send_and_capture(adapter, "/compress", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "compress" in response_text.lower() or "context" in response_text.lower()
@pytest.mark.asyncio
async def test_quick_command_alias_targets_builtin_command_with_args(
self, adapter, runner, platform
):
"""Alias targets with args must reach the built-in command handler."""
runner.config.quick_commands = {
"s": {"type": "alias", "target": "/status extra-arg"}
}
async def _handle_status(event):
assert event.get_command_args() == "extra-arg"
return "status via alias"
runner._handle_status_command = AsyncMock(side_effect=_handle_status)
send = await send_and_capture(adapter, "/s", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert response_text == "status via alias"
runner._handle_status_command.assert_awaited_once()
runner._handle_message_with_agent.assert_not_awaited()
class TestSessionLifecycle:
"""Verify session state changes across command sequences."""
@pytest.mark.asyncio
async def test_new_then_status_reflects_reset(self, adapter, runner, session_entry, platform):
"""After /new, /status should report the fresh session."""
await send_and_capture(adapter, "/new", platform)
runner.session_store.reset_session.assert_called_once()
send = await send_and_capture(adapter, "/status", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
# Session ID from the entry should appear in the status output
assert session_entry.session_id[:8] in response_text
@pytest.mark.asyncio
async def test_new_is_idempotent(self, adapter, runner, platform):
"""/new called twice should not crash."""
await send_and_capture(adapter, "/new", platform)
await send_and_capture(adapter, "/new", platform)
assert runner.session_store.reset_session.call_count == 2
class TestAuthorization:
"""Verify the pipeline handles unauthorized users."""
@pytest.mark.asyncio
async def test_unauthorized_user_gets_pairing_response(self, adapter, runner, platform):
"""Unauthorized DM should trigger pairing code, not a command response."""
runner._is_user_authorized = lambda _source: False
event = make_event(platform, "/help")
adapter.send.reset_mock()
await adapter.handle_message(event)
await asyncio.sleep(0.3)
# The adapter.send is called directly by the authorization path
# (not via _send_with_retry), so check it was called with a pairing message
adapter.send.assert_called()
response_text = adapter.send.call_args[0][1] if len(adapter.send.call_args[0]) > 1 else ""
assert "recognize" in response_text.lower() or "pair" in response_text.lower() or "ABC123" in response_text
@pytest.mark.asyncio
async def test_unauthorized_user_does_not_get_help(self, adapter, runner, platform):
"""Unauthorized user should NOT see the help command output."""
runner._is_user_authorized = lambda _source: False
event = make_event(platform, "/help")
adapter.send.reset_mock()
await adapter.handle_message(event)
await asyncio.sleep(0.3)
# If send was called, it should NOT contain the help text
if adapter.send.called:
response_text = adapter.send.call_args[0][1] if len(adapter.send.call_args[0]) > 1 else ""
assert "/new" not in response_text
class TestSendFailureResilience:
"""Verify the pipeline handles send failures gracefully."""
@pytest.mark.asyncio
async def test_send_failure_does_not_crash_pipeline(self, adapter, platform):
"""If send() returns failure, the pipeline should not raise."""
adapter.send = AsyncMock(return_value=SendResult(success=False, error="network timeout"))
adapter.set_message_handler(adapter._message_handler) # re-wire with same handler
event = make_event(platform, "/help")
# Should not raise — pipeline handles send failures internally
await adapter.handle_message(event)
await asyncio.sleep(0.3)
adapter.send.assert_called()