mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Follow-up on the salvaged #71610 commits: - acquire/release a scoped lock on relay_url+pubkey in connect/disconnect (IRC pattern) so two profiles can't drive one Buzz identity — duplicate replies and split de-dupe state; +2 tests - negative-cache _resolve_user_name failures so a profile-less pubkey doesn't re-hit 'users get' every poll sweep (flagged by @jethac on the PR) - register user-guide/messaging/buzz in website/sidebars.ts (page was unreachable — the #63359 trap)
898 lines
36 KiB
Python
898 lines
36 KiB
Python
"""Tests for the Buzz platform adapter plugin."""
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from tests.gateway._plugin_adapter_loader import load_plugin_adapter
|
|
|
|
# Load plugins/platforms/buzz/adapter.py under a unique module name
|
|
# (plugin_adapter_buzz) so it cannot collide with other plugin adapters
|
|
# loaded by sibling tests in the same xdist worker.
|
|
_buzz_mod = load_plugin_adapter("buzz")
|
|
|
|
BuzzAdapter = _buzz_mod.BuzzAdapter
|
|
hex_to_npub = _buzz_mod.hex_to_npub
|
|
npub_to_hex = _buzz_mod.npub_to_hex
|
|
_normalize_user_ref = _buzz_mod._normalize_user_ref
|
|
_cli_error_message = _buzz_mod._cli_error_message
|
|
_resolve_private_key = _buzz_mod._resolve_private_key
|
|
check_requirements = _buzz_mod.check_requirements
|
|
validate_config = _buzz_mod.validate_config
|
|
register = _buzz_mod.register
|
|
_env_enablement = _buzz_mod._env_enablement
|
|
_standalone_send = _buzz_mod._standalone_send
|
|
|
|
# Real key pair (Chip's public identity — public information, not a secret)
|
|
SELF_PUBKEY = "9fd5c7ba6d3ef224da78f541e0fcb9c50f72cc63edb19aae76ac6a0474dfa860"
|
|
SELF_NPUB = "npub1nl2u0wnd8mezfknc74q7pl9ec58h9nrrakce4tnk434qgaxl4psqe5twr6"
|
|
OTHER_PUBKEY = "a" * 64
|
|
CHANNEL = "ccc2bc1a-7a82-5a8f-8c4e-57a070cbe7cd"
|
|
# Real DM conversation as materialized by a hosted relay: `dms list` returns
|
|
# [] for it (#68871) while `channels list` shows it as name "DM", empty
|
|
# description, indistinguishable from a channel except via message p-tags.
|
|
DM_CHANNEL = "6468cc16-a114-4f23-8b8c-02c1655cbf6b"
|
|
|
|
_ENV_VARS = (
|
|
"BUZZ_RELAY_URL",
|
|
"BUZZ_PRIVATE_KEY",
|
|
"BUZZ_CHANNELS",
|
|
"BUZZ_HOME_CHANNEL",
|
|
"BUZZ_ALLOWED_USERS",
|
|
"BUZZ_ALLOW_ALL_USERS",
|
|
"BUZZ_POLL_INTERVAL",
|
|
"BUZZ_CLI_PATH",
|
|
"BUZZ_CREDENTIALS_FILE",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_env(monkeypatch, tmp_path):
|
|
"""Keep tests hermetic: no ambient Buzz env vars or real credentials."""
|
|
for var in _ENV_VARS:
|
|
monkeypatch.delenv(var, raising=False)
|
|
monkeypatch.setattr(_buzz_mod, "_DEFAULT_CREDENTIALS_DIR", tmp_path / "no-creds")
|
|
yield
|
|
|
|
|
|
def _event(event_id, pubkey=OTHER_PUBKEY, content="hello", created_at=1000, kind=9):
|
|
return {
|
|
"id": event_id,
|
|
"pubkey": pubkey,
|
|
"content": content,
|
|
"created_at": created_at,
|
|
"kind": kind,
|
|
"tags": [["h", CHANNEL]],
|
|
}
|
|
|
|
|
|
def _make_adapter(extra=None):
|
|
from gateway.config import PlatformConfig
|
|
|
|
cfg = PlatformConfig(enabled=True, extra={"relay_url": "https://test.relay", **(extra or {})})
|
|
adapter = BuzzAdapter(cfg)
|
|
adapter._self_pubkey = SELF_PUBKEY
|
|
adapter._self_npub = SELF_NPUB
|
|
adapter._display_name = "Chip"
|
|
adapter._private_key = "nsec1test"
|
|
return adapter
|
|
|
|
|
|
class _ScriptedCli:
|
|
"""Fake ``_run_cli`` that routes on the buzz subcommand and records calls."""
|
|
|
|
def __init__(self):
|
|
self.responses = {} # (group, cmd) -> list of (code, stdout, stderr)
|
|
self.calls = []
|
|
|
|
def script(self, group, cmd, payload, code=0, stderr=""):
|
|
stdout = payload if isinstance(payload, str) else json.dumps(payload)
|
|
self.responses.setdefault((group, cmd), []).append((code, stdout, stderr))
|
|
|
|
async def __call__(self, args, *, input_text=None):
|
|
self.calls.append((list(args), input_text))
|
|
queue = self.responses.get((args[0], args[1]), [])
|
|
if len(queue) > 1:
|
|
return queue.pop(0)
|
|
if queue:
|
|
return queue[0]
|
|
return 0, "[]", ""
|
|
|
|
|
|
# ── bech32 / identity helpers ─────────────────────────────────────────────
|
|
|
|
|
|
class TestBech32Helpers:
|
|
|
|
def test_hex_to_npub_known_pair(self):
|
|
assert hex_to_npub(SELF_PUBKEY) == SELF_NPUB
|
|
|
|
def test_npub_to_hex_known_pair(self):
|
|
assert npub_to_hex(SELF_NPUB) == SELF_PUBKEY
|
|
|
|
def test_round_trip(self):
|
|
npub = hex_to_npub(OTHER_PUBKEY)
|
|
assert npub is not None and npub.startswith("npub1")
|
|
assert npub_to_hex(npub) == OTHER_PUBKEY
|
|
|
|
def test_invalid_inputs(self):
|
|
assert hex_to_npub("not-hex") is None
|
|
assert hex_to_npub("ab") is None
|
|
assert npub_to_hex("npub1invalidchecksum") is None
|
|
assert npub_to_hex("nsec1notanpub") is None
|
|
|
|
def test_normalize_user_ref(self):
|
|
assert _normalize_user_ref(SELF_NPUB) == SELF_PUBKEY
|
|
assert _normalize_user_ref(SELF_PUBKEY.upper()) == SELF_PUBKEY
|
|
assert _normalize_user_ref("") is None
|
|
assert _normalize_user_ref("bogus") is None
|
|
|
|
|
|
# ── Adapter init / config precedence ──────────────────────────────────────
|
|
|
|
|
|
class TestBuzzAdapterInit:
|
|
|
|
def test_init_from_env(self, monkeypatch):
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://env.relay")
|
|
monkeypatch.setenv("BUZZ_CHANNELS", "aaa, bbb ,")
|
|
monkeypatch.setenv("BUZZ_POLL_INTERVAL", "7.5")
|
|
|
|
from gateway.config import PlatformConfig
|
|
adapter = BuzzAdapter(PlatformConfig(enabled=True))
|
|
|
|
assert adapter.relay_url == "https://env.relay"
|
|
assert adapter.channels == ["aaa", "bbb"]
|
|
assert adapter.poll_interval == 7.5
|
|
|
|
def test_init_from_config_extra(self):
|
|
from gateway.config import PlatformConfig
|
|
cfg = PlatformConfig(
|
|
enabled=True,
|
|
extra={
|
|
"relay_url": "https://cfg.relay",
|
|
"channels": ["ccc"],
|
|
"poll_interval": 2,
|
|
"home_channel": "ccc",
|
|
},
|
|
)
|
|
adapter = BuzzAdapter(cfg)
|
|
assert adapter.relay_url == "https://cfg.relay"
|
|
assert adapter.channels == ["ccc"]
|
|
assert adapter.poll_interval == 2.0
|
|
assert adapter.home_channel == "ccc"
|
|
|
|
def test_env_overrides_config(self, monkeypatch):
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://env.relay")
|
|
from gateway.config import PlatformConfig
|
|
adapter = BuzzAdapter(PlatformConfig(enabled=True, extra={"relay_url": "https://cfg.relay"}))
|
|
assert adapter.relay_url == "https://env.relay"
|
|
|
|
def test_poll_interval_clamped_and_defaulted(self):
|
|
adapter = _make_adapter({"poll_interval": 0.01})
|
|
assert adapter.poll_interval == _buzz_mod._MIN_POLL_INTERVAL
|
|
adapter = _make_adapter({"poll_interval": "garbage"})
|
|
assert adapter.poll_interval == _buzz_mod._DEFAULT_POLL_INTERVAL
|
|
|
|
def test_allowed_users_normalized_from_mixed_forms(self):
|
|
adapter = _make_adapter({"allowed_users": [SELF_NPUB, OTHER_PUBKEY.upper(), "junk"]})
|
|
assert adapter._allowed_pubkeys == {SELF_PUBKEY, OTHER_PUBKEY}
|
|
|
|
|
|
# ── CLI error contract ────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCliErrorContract:
|
|
|
|
def test_parses_json_error(self):
|
|
msg = _cli_error_message('{"error":"relay_error","message":"boom","retryable":false}', 2)
|
|
assert "relay_error" in msg and "boom" in msg and "exit 2" in msg
|
|
|
|
def test_falls_back_to_raw_stderr(self):
|
|
assert "garbage" in _cli_error_message("garbage output", 4)
|
|
|
|
def test_empty_stderr(self):
|
|
assert "exit code 3" in _cli_error_message("", 3)
|
|
|
|
|
|
# ── Seeding / high-water mark / de-dupe ───────────────────────────────────
|
|
|
|
|
|
class TestPollingDedupe:
|
|
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = _make_adapter()
|
|
a._dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
a._dispatched.append(kwargs)
|
|
|
|
a._dispatch_message = capture
|
|
a._message_handler = AsyncMock()
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seed_sets_high_water_mark_without_dispatch(self, adapter):
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [
|
|
_event("e1", content="@Chip old history", created_at=100),
|
|
_event("e2", content="@Chip newer history", created_at=200),
|
|
])
|
|
adapter._run_cli = cli
|
|
await adapter._seed_channel(CHANNEL, chat_type="group")
|
|
|
|
state = adapter._channel_state[CHANNEL]
|
|
assert state["last_ts"] == 200
|
|
assert set(state["seen"]) == {"e1", "e2"}
|
|
# Seeding must never replay history into the agent
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_new_event_dispatched_once(self, adapter):
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [_event("e1", content="@Chip hi", created_at=100)])
|
|
adapter._run_cli = cli
|
|
await adapter._seed_channel(CHANNEL, chat_type="group")
|
|
|
|
# Poll 1: seeded event + a genuinely new mention
|
|
cli.responses.clear()
|
|
cli.script("messages", "get", [
|
|
_event("e1", content="@Chip hi", created_at=100),
|
|
_event("e2", content="hey @Chip, ping", created_at=150),
|
|
])
|
|
await adapter._poll_channel(CHANNEL)
|
|
assert [d["message_id"] for d in adapter._dispatched] == ["e2"]
|
|
assert adapter._dispatched[0]["text"] == "hey @Chip, ping"
|
|
assert adapter._channel_state[CHANNEL]["last_ts"] == 150
|
|
|
|
# Poll 2: identical response — the seen-id set must de-dupe
|
|
await adapter._poll_channel(CHANNEL)
|
|
assert len(adapter._dispatched) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_uses_since_high_water_mark(self, adapter):
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 4242, "seen": {}}
|
|
cli = _ScriptedCli()
|
|
adapter._run_cli = cli
|
|
await adapter._poll_channel(CHANNEL)
|
|
args, _stdin = cli.calls[0]
|
|
assert "--since" in args
|
|
assert args[args.index("--since") + 1] == "4242"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_self_echo_suppressed(self, adapter):
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [
|
|
_event("own", pubkey=SELF_PUBKEY, content="@Chip I mention myself", created_at=300),
|
|
])
|
|
adapter._run_cli = cli
|
|
await adapter._poll_channel(CHANNEL)
|
|
assert adapter._dispatched == []
|
|
# …but the event still advances the high-water mark
|
|
assert adapter._channel_state[CHANNEL]["last_ts"] == 300
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_chat_kinds_ignored(self, adapter):
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [
|
|
_event("j1", content="@Chip join event", created_at=300, kind=40002),
|
|
])
|
|
adapter._run_cli = cli
|
|
await adapter._poll_channel(CHANNEL)
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seen_set_is_bounded(self, adapter):
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
from collections import OrderedDict
|
|
adapter._channel_state[CHANNEL]["seen"] = OrderedDict()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [
|
|
_event(f"e{i}", content="unrelated chatter", created_at=i)
|
|
for i in range(_buzz_mod._SEEN_CAP + 100)
|
|
])
|
|
adapter._run_cli = cli
|
|
await adapter._poll_channel(CHANNEL)
|
|
assert len(adapter._channel_state[CHANNEL]["seen"]) <= _buzz_mod._SEEN_CAP
|
|
|
|
|
|
# ── Mention gating / DMs / authorization ──────────────────────────────────
|
|
|
|
|
|
class TestMentionGating:
|
|
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = _make_adapter()
|
|
a._dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
a._dispatched.append(kwargs)
|
|
|
|
a._dispatch_message = capture
|
|
a._message_handler = AsyncMock()
|
|
a._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
return a
|
|
|
|
async def _poll_with(self, adapter, *events):
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", list(events))
|
|
adapter._run_cli = cli
|
|
await adapter._poll_channel(CHANNEL)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unaddressed_channel_message_ignored(self, adapter):
|
|
await self._poll_with(adapter, _event("e1", content="just chatting", created_at=10))
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_name_mention_dispatched(self, adapter):
|
|
await self._poll_with(adapter, _event("e1", content="hey @Chip can you help?", created_at=10))
|
|
assert len(adapter._dispatched) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bare_name_mention_dispatched_case_insensitive(self, adapter):
|
|
await self._poll_with(adapter, _event("e1", content="chip: what do you think?", created_at=10))
|
|
assert len(adapter._dispatched) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_name_inside_word_not_a_mention(self, adapter):
|
|
await self._poll_with(adapter, _event("e1", content="I love potato chips", created_at=10))
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_npub_mention_dispatched(self, adapter):
|
|
await self._poll_with(adapter, _event("e1", content=f"nostr:{SELF_NPUB} hello", created_at=10))
|
|
assert len(adapter._dispatched) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_always_dispatched(self, adapter):
|
|
adapter._channel_state[CHANNEL]["chat_type"] = "dm"
|
|
await self._poll_with(adapter, _event("e1", content="no mention here", created_at=10))
|
|
assert len(adapter._dispatched) == 1
|
|
assert adapter._dispatched[0]["chat_type"] == "dm"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allowlist_blocks_unauthorized(self, adapter):
|
|
adapter._allowed_pubkeys = {"b" * 64}
|
|
await self._poll_with(adapter, _event("e1", content="@Chip hello", created_at=10))
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allowlist_admits_npub_entry(self):
|
|
a = _make_adapter({"allowed_users": [hex_to_npub(OTHER_PUBKEY)]})
|
|
a._dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
a._dispatched.append(kwargs)
|
|
|
|
a._dispatch_message = capture
|
|
a._message_handler = AsyncMock()
|
|
a._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [_event("e1", content="@Chip hello", created_at=10)])
|
|
a._run_cli = cli
|
|
await a._poll_channel(CHANNEL)
|
|
assert len(a._dispatched) == 1
|
|
|
|
|
|
# ── DM classification via p-tags (issue #68871) ──────────────────────────
|
|
#
|
|
# `buzz dms list` returns [] on some hosted relays, so DM conversations leak
|
|
# in via `channels list` and get seeded chat_type="group". The adapter must
|
|
# reclassify them from the Nostr tags of real traffic: DM messages are
|
|
# p-tagged to our own pubkey WITHOUT the text mentioning us, while channel
|
|
# messages only ever p-tag us when the text visibly @mentions us.
|
|
|
|
|
|
def _tagged_event(event_id, channel, *, content, pubkey=OTHER_PUBKEY,
|
|
created_at=1000, kind=9, p=None, reply_to=None):
|
|
"""Event with the tag shapes observed on a live relay (h/p/e tags)."""
|
|
tags = [["h", channel]]
|
|
if reply_to:
|
|
tags.append(["e", reply_to, "", "reply"])
|
|
if p:
|
|
tags.append(["p", p])
|
|
return {
|
|
"id": event_id,
|
|
"pubkey": pubkey,
|
|
"content": content,
|
|
"created_at": created_at,
|
|
"kind": kind,
|
|
"tags": tags,
|
|
}
|
|
|
|
|
|
class TestDmClassification:
|
|
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = _make_adapter()
|
|
a._dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
a._dispatched.append(kwargs)
|
|
|
|
a._dispatch_message = capture
|
|
a._message_handler = AsyncMock()
|
|
# Metadata exactly as `channels list` returns it on the hosted relay.
|
|
a._channel_meta = {
|
|
DM_CHANNEL: {"channel_id": DM_CHANNEL, "name": "DM", "description": ""},
|
|
CHANNEL: {
|
|
"channel_id": CHANNEL,
|
|
"name": "general",
|
|
"description": "General conversation and community updates.",
|
|
},
|
|
}
|
|
a._channel_names = {DM_CHANNEL: "DM", CHANNEL: "general"}
|
|
# Both leaked in as group — the bug under test.
|
|
a._channel_state[DM_CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
a._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
return a
|
|
|
|
async def _poll_with(self, adapter, channel, *events):
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", list(events))
|
|
adapter._run_cli = cli
|
|
await adapter._poll_channel(channel)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unmentioned_ptagged_dm_latches_and_dispatches(self, adapter):
|
|
"""The reported bug: a DM without an @mention must dispatch."""
|
|
await self._poll_with(
|
|
adapter, DM_CHANNEL,
|
|
_tagged_event("e1", DM_CHANNEL, content="here's a test message", p=SELF_PUBKEY),
|
|
)
|
|
assert adapter._channel_state[DM_CHANNEL]["chat_type"] == "dm"
|
|
assert [d["message_id"] for d in adapter._dispatched] == ["e1"]
|
|
assert adapter._dispatched[0]["chat_type"] == "dm"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_latch_persists_for_followup_messages(self, adapter):
|
|
await self._poll_with(
|
|
adapter, DM_CHANNEL,
|
|
_tagged_event("e1", DM_CHANNEL, content="first direct message", p=SELF_PUBKEY),
|
|
)
|
|
# Follow-up carries no p-tag at all — the latched state must carry it.
|
|
await self._poll_with(
|
|
adapter, DM_CHANNEL,
|
|
_tagged_event("e2", DM_CHANNEL, content="and a follow-up", created_at=1001),
|
|
)
|
|
assert [d["message_id"] for d in adapter._dispatched] == ["e1", "e2"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_general_broadcast_without_mention_still_gated(self, adapter):
|
|
await self._poll_with(
|
|
adapter, CHANNEL,
|
|
_tagged_event("e1", CHANNEL, content="just chatting away"),
|
|
)
|
|
assert adapter._dispatched == []
|
|
assert adapter._channel_state[CHANNEL]["chat_type"] == "group"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_general_reply_ptagging_self_stays_channel(self, adapter):
|
|
"""A #general reply to us p-tags our pubkey (observed live) — that
|
|
must NOT reclassify the channel; mention gating still applies."""
|
|
await self._poll_with(
|
|
adapter, CHANNEL,
|
|
_tagged_event("e1", CHANNEL, content="@chip what's up?",
|
|
p=SELF_PUBKEY, reply_to="root-event"),
|
|
)
|
|
assert adapter._channel_state[CHANNEL]["chat_type"] == "group"
|
|
# It carried a mention, so it dispatches — but as a group message.
|
|
assert [d["chat_type"] for d in adapter._dispatched] == ["group"]
|
|
|
|
# And once the mention is absent, the channel gate drops the message
|
|
# even though the earlier reply p-tagged us.
|
|
await self._poll_with(
|
|
adapter, CHANNEL,
|
|
_tagged_event("e2", CHANNEL, content="thanks everyone", created_at=1001),
|
|
)
|
|
assert len(adapter._dispatched) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_general_nonreply_mention_ptag_stays_channel(self, adapter):
|
|
"""Typed channel mentions p-tag us WITHOUT a reply tag (observed
|
|
live: '@Chip are you up and running?') — still not a DM: the p-tag
|
|
is explained by the visible mention, and the metadata guard protects
|
|
real channels regardless."""
|
|
await self._poll_with(
|
|
adapter, CHANNEL,
|
|
_tagged_event("e1", CHANNEL, content="@Chip are you up and running?", p=SELF_PUBKEY),
|
|
)
|
|
assert adapter._channel_state[CHANNEL]["chat_type"] == "group"
|
|
assert [d["chat_type"] for d in adapter._dispatched] == ["group"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_like_metadata_blocks_latch_even_without_mention(self, adapter):
|
|
"""Second guard on its own: even a p-tagged, un-mentioned message
|
|
cannot reclassify a conversation whose metadata says real channel."""
|
|
adapter._channel_meta[CHANNEL]["description"] = ""
|
|
adapter._channel_meta[CHANNEL]["name"] = "announcements"
|
|
await self._poll_with(
|
|
adapter, CHANNEL,
|
|
_tagged_event("e1", CHANNEL, content="fyi everyone", p=SELF_PUBKEY),
|
|
)
|
|
assert adapter._channel_state[CHANNEL]["chat_type"] == "group"
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_configured_channel_without_metadata_not_latched(self):
|
|
a = _make_adapter({"channels": ["private-chan"]})
|
|
a._dispatched = []
|
|
|
|
async def capture(**kwargs):
|
|
a._dispatched.append(kwargs)
|
|
|
|
a._dispatch_message = capture
|
|
a._message_handler = AsyncMock()
|
|
a._channel_state["private-chan"] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [
|
|
_tagged_event("e1", "private-chan", content="no mention here", p=SELF_PUBKEY),
|
|
])
|
|
a._run_cli = cli
|
|
await a._poll_channel("private-chan")
|
|
assert a._channel_state["private-chan"]["chat_type"] == "group"
|
|
assert a._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seed_latches_dm_from_history_without_dispatch(self, adapter):
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [
|
|
_tagged_event("h1", DM_CHANNEL, content="@Chip hello there", p=SELF_PUBKEY, created_at=100),
|
|
_tagged_event("h2", DM_CHANNEL, content="here's a test message", p=SELF_PUBKEY, created_at=200),
|
|
_tagged_event("h3", DM_CHANNEL, content="my own reply", pubkey=SELF_PUBKEY, created_at=300),
|
|
])
|
|
adapter._run_cli = cli
|
|
await adapter._seed_channel(DM_CHANNEL, chat_type="group")
|
|
assert adapter._channel_state[DM_CHANNEL]["chat_type"] == "dm"
|
|
# Seeding classifies but never replays history into the agent.
|
|
assert adapter._dispatched == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_chat_kind_ptag_does_not_latch(self, adapter):
|
|
"""Housekeeping events (joins, member adds) may p-tag members —
|
|
only kind-9 chat traffic classifies."""
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "get", [
|
|
_tagged_event("h1", DM_CHANNEL, content="member added", p=SELF_PUBKEY, kind=40002),
|
|
])
|
|
adapter._run_cli = cli
|
|
await adapter._seed_channel(DM_CHANNEL, chat_type="group")
|
|
assert adapter._channel_state[DM_CHANNEL]["chat_type"] == "group"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_leading_mention_stripped_for_commands(self, adapter):
|
|
adapter._channel_state[DM_CHANNEL]["chat_type"] = "dm"
|
|
await self._poll_with(
|
|
adapter, DM_CHANNEL,
|
|
_tagged_event("e1", DM_CHANNEL, content="@chip /whoami", p=SELF_PUBKEY),
|
|
)
|
|
assert adapter._dispatched[0]["text"] == "/whoami"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_shaped_channel_discovered_when_dms_list_empty(self):
|
|
"""Fallback discovery: with `dms list` broken (returns []), a
|
|
DM-shaped `channels list` entry gets watched; real channels not
|
|
already watched are left alone."""
|
|
a = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("dms", "list", [])
|
|
cli.script("channels", "list", [
|
|
{"channel_id": DM_CHANNEL, "name": "DM", "description": "", "created_at": 1},
|
|
{"channel_id": CHANNEL, "name": "general",
|
|
"description": "General conversation and community updates.", "created_at": 2},
|
|
])
|
|
a._run_cli = cli
|
|
await a._discover_dms(seed=False)
|
|
# Watched as group; the p-tag latch flips it on the first real DM.
|
|
assert a._channel_state[DM_CHANNEL]["chat_type"] == "group"
|
|
assert a._may_reclassify_as_dm(DM_CHANNEL) is True
|
|
assert CHANNEL not in a._channel_state
|
|
assert a._may_reclassify_as_dm(CHANNEL) is False
|
|
|
|
|
|
# ── Sending ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBuzzAdapterSend:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_success_via_stdin(self):
|
|
adapter = _make_adapter()
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt123", "message": ""})
|
|
adapter._run_cli = cli
|
|
|
|
result = await adapter.send(CHANNEL, "hello **markdown**")
|
|
assert result.success is True
|
|
assert result.message_id == "evt123"
|
|
|
|
args, stdin_text = cli.calls[0]
|
|
assert args[:2] == ["messages", "send"]
|
|
assert args[args.index("--channel") + 1] == CHANNEL
|
|
# Content travels via stdin (--content -), never argv
|
|
assert args[args.index("--content") + 1] == "-"
|
|
assert stdin_text == "hello **markdown**"
|
|
# Our own event id is marked seen for echo suppression
|
|
assert "evt123" in adapter._channel_state[CHANNEL]["seen"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reply_to(self):
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt124", "message": ""})
|
|
adapter._run_cli = cli
|
|
await adapter.send(CHANNEL, "threaded", reply_to="root-event")
|
|
args, _stdin = cli.calls[0]
|
|
assert args[args.index("--reply-to") + 1] == "root-event"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_failure_maps_error_contract(self):
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", "", code=2,
|
|
stderr='{"error":"relay_error","message":"relay unreachable"}')
|
|
adapter._run_cli = cli
|
|
result = await adapter.send(CHANNEL, "hi")
|
|
assert result.success is False
|
|
assert "relay unreachable" in result.error
|
|
assert result.retryable is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_empty_message_rejected(self):
|
|
adapter = _make_adapter()
|
|
result = await adapter.send(CHANNEL, "")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_url_falls_back_to_link(self):
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt125", "message": ""})
|
|
adapter._run_cli = cli
|
|
result = await adapter.send_image(CHANNEL, "https://example.com/cat.png", caption="a cat")
|
|
assert result.success is True
|
|
_args, stdin_text = cli.calls[0]
|
|
assert "https://example.com/cat.png" in stdin_text
|
|
assert "a cat" in stdin_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_local_file_uses_file_flag(self, tmp_path):
|
|
img = tmp_path / "shot.png"
|
|
img.write_bytes(b"\x89PNG fake")
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("messages", "send", {"accepted": True, "event_id": "evt126", "message": ""})
|
|
adapter._run_cli = cli
|
|
result = await adapter.send_image(CHANNEL, str(img), caption="screenshot")
|
|
assert result.success is True
|
|
args, _stdin = cli.calls[0]
|
|
assert args[args.index("--file") + 1] == str(img)
|
|
|
|
|
|
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBuzzAdapterLifecycle:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_fails_without_relay_url(self):
|
|
from gateway.config import PlatformConfig
|
|
adapter = BuzzAdapter(PlatformConfig(enabled=True, extra={}))
|
|
assert await adapter.connect() is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_cancels_poll_task(self):
|
|
adapter = _make_adapter()
|
|
|
|
async def forever():
|
|
while True:
|
|
await asyncio.sleep(3600)
|
|
|
|
adapter._poll_task = asyncio.create_task(forever())
|
|
task = adapter._poll_task
|
|
await adapter.disconnect()
|
|
assert task.cancelled()
|
|
assert adapter._poll_task is None
|
|
assert adapter.is_connected is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_releases_scoped_lock(self, monkeypatch):
|
|
"""The identity lock taken in connect() must be released on disconnect."""
|
|
import gateway.status as gateway_status
|
|
|
|
released = []
|
|
monkeypatch.setattr(
|
|
gateway_status,
|
|
"release_scoped_lock",
|
|
lambda platform, key: released.append((platform, key)),
|
|
)
|
|
adapter = _make_adapter()
|
|
adapter._lock_key = "wss://relay.example:" + SELF_PUBKEY
|
|
await adapter.disconnect()
|
|
assert released == [("buzz", "wss://relay.example:" + SELF_PUBKEY)]
|
|
assert adapter._lock_key is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_fails_when_identity_lock_held(self, monkeypatch):
|
|
"""A second profile using the same relay+pubkey must fail fast."""
|
|
import gateway.status as gateway_status
|
|
|
|
monkeypatch.setattr(
|
|
gateway_status, "acquire_scoped_lock", lambda platform, key: False
|
|
)
|
|
adapter = _make_adapter()
|
|
adapter.cli_path = "/fake/buzz"
|
|
monkeypatch.setattr(_buzz_mod, "_resolve_private_key", lambda extra=None: "nsec1test")
|
|
cli = _ScriptedCli()
|
|
cli.script(
|
|
"users", "get",
|
|
[{"pubkey": SELF_PUBKEY, "display_name": "Chip"}],
|
|
)
|
|
adapter._run_cli = cli
|
|
assert await adapter.connect() is False
|
|
assert adapter._lock_key is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_chat_info_uses_cached_names(self):
|
|
adapter = _make_adapter()
|
|
adapter._channel_names[CHANNEL] = "general"
|
|
adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}}
|
|
info = await adapter.get_chat_info(CHANNEL)
|
|
assert info == {"name": "general", "type": "group", "chat_id": CHANNEL}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_discovery_registers_conversation(self):
|
|
adapter = _make_adapter()
|
|
cli = _ScriptedCli()
|
|
cli.script("dms", "list", [{"dm_id": "dm-1", "participants": [OTHER_PUBKEY], "created_at": 5}])
|
|
adapter._run_cli = cli
|
|
await adapter._discover_dms(seed=False)
|
|
assert adapter._channel_state["dm-1"]["chat_type"] == "dm"
|
|
|
|
|
|
# ── Credentials / requirements ────────────────────────────────────────────
|
|
|
|
|
|
class TestCredentialResolution:
|
|
|
|
def test_env_key_wins(self, monkeypatch):
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1fromenv")
|
|
assert _resolve_private_key() == "nsec1fromenv"
|
|
|
|
def test_credentials_file_fallback(self, monkeypatch, tmp_path):
|
|
creds = tmp_path / "agent_credentials.json"
|
|
creds.write_text(json.dumps({"nsec": "nsec1fromfile", "npub": "npub1x"}), encoding="utf-8")
|
|
monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(creds))
|
|
assert _resolve_private_key() == "nsec1fromfile"
|
|
|
|
def test_default_dir_glob(self, monkeypatch, tmp_path):
|
|
(tmp_path / "chip_nostr_credentials.json").write_text(
|
|
json.dumps({"private_key_hex": "ab" * 32}), encoding="utf-8"
|
|
)
|
|
monkeypatch.setattr(_buzz_mod, "_DEFAULT_CREDENTIALS_DIR", tmp_path)
|
|
assert _resolve_private_key() == "ab" * 32
|
|
|
|
def test_missing_everywhere(self):
|
|
assert _resolve_private_key() == ""
|
|
|
|
def test_check_requirements(self, monkeypatch):
|
|
assert check_requirements() is False
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://r")
|
|
assert check_requirements() is False # still no key
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x")
|
|
assert check_requirements() is True
|
|
|
|
def test_validate_config_from_extra(self, monkeypatch):
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x")
|
|
from gateway.config import PlatformConfig
|
|
assert validate_config(PlatformConfig(extra={"relay_url": "https://r"})) is True
|
|
assert validate_config(PlatformConfig(extra={})) is False
|
|
|
|
|
|
# ── Env enablement / registration / standalone send ──────────────────────
|
|
|
|
|
|
class TestEnvEnablement:
|
|
|
|
def test_returns_none_when_unconfigured(self):
|
|
assert _env_enablement() is None
|
|
|
|
def test_seeds_extra_and_home_channel(self, monkeypatch):
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://r")
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x")
|
|
monkeypatch.setenv("BUZZ_CHANNELS", "chan-a,chan-b")
|
|
seed = _env_enablement()
|
|
assert seed["relay_url"] == "https://r"
|
|
assert seed["channels"] == ["chan-a", "chan-b"]
|
|
# Home channel defaults to the first watched channel
|
|
assert seed["home_channel"]["chat_id"] == "chan-a"
|
|
|
|
def test_explicit_home_channel(self, monkeypatch):
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://r")
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x")
|
|
monkeypatch.setenv("BUZZ_HOME_CHANNEL", "home-chan")
|
|
assert _env_enablement()["home_channel"]["chat_id"] == "home-chan"
|
|
|
|
|
|
class TestBuzzPluginRegistration:
|
|
|
|
def test_register_platform_contract(self):
|
|
from gateway.platform_registry import platform_registry
|
|
|
|
platform_registry.unregister("buzz")
|
|
ctx = MagicMock()
|
|
register(ctx)
|
|
ctx.register_platform.assert_called_once()
|
|
kwargs = ctx.register_platform.call_args.kwargs
|
|
assert kwargs["name"] == "buzz"
|
|
assert kwargs["cron_deliver_env_var"] == "BUZZ_HOME_CHANNEL"
|
|
assert kwargs["allowed_users_env"] == "BUZZ_ALLOWED_USERS"
|
|
assert kwargs["allow_all_env"] == "BUZZ_ALLOW_ALL_USERS"
|
|
assert callable(kwargs["standalone_sender_fn"])
|
|
assert callable(kwargs["env_enablement_fn"])
|
|
assert set(kwargs["required_env"]) == {"BUZZ_RELAY_URL", "BUZZ_PRIVATE_KEY"}
|
|
|
|
|
|
class TestStandaloneSend:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_success(self, monkeypatch, tmp_path):
|
|
from gateway.config import PlatformConfig
|
|
|
|
fake_cli = tmp_path / "buzz"
|
|
fake_cli.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://r")
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x")
|
|
monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli))
|
|
|
|
captured = {}
|
|
|
|
async def fake_exec(cli_path, args, *, relay_url, private_key, input_text=None, timeout=30.0):
|
|
captured.update(cli_path=cli_path, args=args, relay_url=relay_url, input_text=input_text)
|
|
return 0, json.dumps({"accepted": True, "event_id": "evt-cron", "message": ""}), ""
|
|
|
|
monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec)
|
|
|
|
result = await _standalone_send(PlatformConfig(enabled=True, extra={}), CHANNEL, "cron says hi")
|
|
assert result == {"success": True, "message_id": "evt-cron"}
|
|
assert captured["args"][:2] == ["messages", "send"]
|
|
assert captured["input_text"] == "cron says hi"
|
|
# The private key must never be part of argv
|
|
assert all("nsec1x" not in str(a) for a in captured["args"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_unconfigured(self):
|
|
from gateway.config import PlatformConfig
|
|
result = await _standalone_send(PlatformConfig(enabled=True, extra={}), CHANNEL, "hi")
|
|
assert "error" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_falls_back_to_home_channel(self, monkeypatch, tmp_path):
|
|
from gateway.config import PlatformConfig
|
|
|
|
fake_cli = tmp_path / "buzz"
|
|
fake_cli.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
monkeypatch.setenv("BUZZ_RELAY_URL", "https://r")
|
|
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x")
|
|
monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli))
|
|
monkeypatch.setenv("BUZZ_HOME_CHANNEL", "home-chan")
|
|
|
|
captured = {}
|
|
|
|
async def fake_exec(cli_path, args, *, relay_url, private_key, input_text=None, timeout=30.0):
|
|
captured["args"] = args
|
|
return 0, json.dumps({"accepted": True, "event_id": "e"}), ""
|
|
|
|
monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec)
|
|
result = await _standalone_send(PlatformConfig(enabled=True, extra={}), "", "hi")
|
|
assert result["success"] is True
|
|
assert captured["args"][captured["args"].index("--channel") + 1] == "home-chan"
|