diff --git a/plugins/cogos-discord/README.md b/plugins/cogos-discord/README.md new file mode 100644 index 0000000..50a7869 --- /dev/null +++ b/plugins/cogos-discord/README.md @@ -0,0 +1,64 @@ +# cogos-discord + +A Hermes plugin that sits on top of Hermes's bundled Discord adapter. It is a native Hermes plugin +(Python, `plugin.yaml`), not a Claude Code plugin or a skill pack. + +## v0.1: answer in threads the bot created + +Hermes skips the @mention requirement inside a thread only if that thread is on its participation +list, and it only adds threads the gateway itself created or replied in. Threads the bot created some +other way (forum posts made over the REST API by a script, or by another process using the same +token) are never added, so the first human reply in them is ignored unless it @mentions the bot. + +With this plugin, a thread whose `owner_id` is the bot counts as joined. It is added to the list the +first time it's checked, so it persists across restarts like any other joined thread. This covers both +places Hermes checks for a mention: live messages, and messages caught up after downtime. + +Unchanged: `discord.thread_require_mention: true` still forces mentions everywhere; threads started +by people still need a mention until the bot has replied there; allowlists run exactly as before. + +## Install + +```bash +hermes plugins install https://github.com/myrgic/plugins/tree/main/plugins/cogos-discord +hermes plugins enable cogos-discord +# restart the gateway, then look for this line in the gateway log: +# cogos-discord: override active (...) +``` + +**Rollback:** `hermes plugins disable cogos-discord` and restart. Hermes's own adapter comes back. + +## How it works, and what it costs + +Hermes lets a plugin replace a built-in platform: the registry keeps the last registration. The plugin: + +1. loads Hermes's Discord adapter first (Hermes normally loads it lazily); +2. checks that the parts it relies on still exist (`_handle_message`, `_dispatch_recovered_message`, + `_discord_thread_require_mention`, and the thread tracker's `mark`/`in`); +3. registers a **copy** of Hermes's `discord` entry with only the adapter swapped, so config handling, + cron delivery to Discord, setup and allowlist settings stay Hermes's own. + +If step 2 fails, it logs an `ERROR`, registers nothing, and Hermes's adapter runs unchanged. That +means a Hermes update can turn this plugin off, but it can't break Discord. The `override active` log +line is how you confirm it's on. + +Cost: discord.py now imports whenever this profile loads plugins, not only when Discord is first used. + +## Tests + +The tests run against a real Hermes source tree. Rerun them whenever you update Hermes: + +```bash +cd && /bin/python -m pytest /plugins/cogos-discord/tests -q +``` + +They cover the compatibility check, both mention checks (with and without bot ownership), +`thread_require_mention`, persistence, and that the registered entry matches Hermes's own except for +the adapter. With the override removed, the three behaviour tests fail. + +## Roadmap + +- v0.2: an activity stream plus a classifier hook (ignore / react / reply / escalate), watch-only by + default, with the model and what it may do on its own set in config. +- Later and separate: keeping server structure in sync with a declared layout. It won't be bundled + here. diff --git a/plugins/cogos-discord/__init__.py b/plugins/cogos-discord/__init__.py new file mode 100644 index 0000000..2ea3be0 --- /dev/null +++ b/plugins/cogos-discord/__init__.py @@ -0,0 +1,172 @@ +"""cogos-discord: a layer over Hermes's bundled Discord adapter. + +v0.1 changes one thing: a thread the bot itself created counts as a thread the bot is in. +Hermes only skips the @mention requirement inside threads listed in its participation tracker +(``adapter._threads``), and it only adds a thread to that list when the gateway itself creates or +replies in it. Threads the bot created some other way (a forum post made over REST by a script, +a second process sharing the token) are never listed, so the first human reply there is dropped +unless it @mentions the bot. + +How the override works (Hermes ``gateway/platform_registry.py``): +- Bundled platform plugins register a *deferred* loader; user plugins load after them. +- A concrete ``register()`` for the same name replaces the entry and drops the deferred loader, + so after we register nothing would ever load the bundled module. We therefore resolve the bundled + entry first (``platform_registry.get("discord")`` runs its loader), subclass its adapter, and + register a *copy* of the bundled entry with only ``adapter_factory`` swapped. Everything else + on the entry (YAML config translation, cron delivery, setup, auth env names) stays the bundled one. +- Cost: discord.py now imports whenever this profile loads plugins, not only when the gateway + first asks for Discord. + +If the contract check fails (Hermes renamed something we rely on) we log an ERROR and do not +register, so the stock adapter keeps running unchanged. The log line +``cogos-discord: override active`` is the positive signal to look for after a restart. +""" + +from __future__ import annotations + +import dataclasses +import logging +import sys +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +PLATFORM = "discord" + +# Fields of PlatformEntry that register_platform() sets itself. +_SET_BY_REGISTER = {"name", "label", "adapter_factory", "check_fn", "source", "plugin_name"} + +# What we rely on in the bundled adapter. Checked at load; a miss disables the override loudly. +_REQUIRED_ADAPTER_ATTRS = ("_handle_message", "_dispatch_recovered_message", "_discord_thread_require_mention") + + +class OwnedThreadTracker: + """Wraps Hermes's ThreadParticipationTracker. + + Membership is true if the wrapped tracker has the thread, or if the thread is a Discord thread + whose ``owner_id`` is the bot's own user id. The second case is marked into the wrapped tracker, + so it persists (``discord_threads.json``) and later checks are a dict lookup. + """ + + def __init__(self, inner: Any, adapter: Any): + self._inner = inner + self._adapter = adapter + + def __contains__(self, thread_id: Any) -> bool: + tid = str(thread_id) + if tid in self._inner: + return True + if self._bot_owns(tid): + try: + self._inner.mark(tid) + except Exception: # persistence failure must not change the answer + logger.warning("cogos-discord: could not persist owned thread %s", tid, exc_info=True) + logger.info("cogos-discord: thread %s was created by this bot; treating it as joined", tid) + return True + return False + + def _bot_owns(self, tid: str) -> bool: + client = getattr(self._adapter, "_client", None) + me = getattr(client, "user", None) + my_id = getattr(me, "id", None) + if client is None or my_id is None: + return False + try: + channel = client.get_channel(int(tid)) + except (TypeError, ValueError): + return False + except Exception: + logger.debug("cogos-discord: get_channel(%s) failed", tid, exc_info=True) + return False + owner_id = getattr(channel, "owner_id", None) + return owner_id is not None and str(owner_id) == str(my_id) + + def mark(self, thread_id: str) -> None: + self._inner.mark(thread_id) + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + + +def make_adapter_class(base: type) -> type: + """Subclass the bundled DiscordAdapter, wrapping its thread tracker after construction.""" + + class CogosDiscordAdapter(base): # type: ignore[misc, valid-type] + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._threads = OwnedThreadTracker(self._threads, self) + + CogosDiscordAdapter.__name__ = "CogosDiscordAdapter" + CogosDiscordAdapter.__qualname__ = "CogosDiscordAdapter" + return CogosDiscordAdapter + + +def contract_problems(base: Optional[type], tracker_cls: Optional[type]) -> list[str]: + """Return what the bundled code no longer provides. Empty list = compatible.""" + problems = [] + if base is None: + return ["bundled DiscordAdapter class not found"] + for attr in _REQUIRED_ADAPTER_ATTRS: + if not hasattr(base, attr): + problems.append(f"DiscordAdapter.{attr} missing") + if tracker_cls is None: + problems.append("ThreadParticipationTracker not importable") + else: + for attr in ("mark", "__contains__"): + if not hasattr(tracker_cls, attr): + problems.append(f"ThreadParticipationTracker.{attr} missing") + return problems + + +def _bundled_adapter_class(entry: Any) -> Optional[type]: + factory = getattr(entry, "adapter_factory", None) + module = sys.modules.get(getattr(factory, "__module__", "") or "") + cls = getattr(module, "DiscordAdapter", None) + return cls if isinstance(cls, type) else None + + +def register(ctx: Any) -> None: + from gateway.platform_registry import platform_registry + + bundled = platform_registry.get(PLATFORM) # runs the bundled deferred loader + if bundled is None: + logger.error("cogos-discord: no '%s' platform registered; override not installed", PLATFORM) + return + if getattr(bundled, "plugin_name", "") == ctx.manifest.name: + logger.info("cogos-discord: already registered in this scope") + return + + base = _bundled_adapter_class(bundled) + try: + from gateway.platforms.helpers import ThreadParticipationTracker as tracker_cls + except Exception: + tracker_cls = None + problems = contract_problems(base, tracker_cls) + if problems: + logger.error( + "cogos-discord: Hermes Discord adapter is not compatible (%s); override NOT installed, " + "stock adapter stays active", "; ".join(problems), + ) + return + + adapter_cls = make_adapter_class(base) + + def _build(config: Any) -> Any: + return adapter_cls(config) + + carried = { + f.name: getattr(bundled, f.name) + for f in dataclasses.fields(bundled) + if f.name not in _SET_BY_REGISTER + } + ctx.register_platform( + name=PLATFORM, + label=bundled.label, + adapter_factory=_build, + check_fn=bundled.check_fn, + **carried, + ) + logger.info( + "cogos-discord: override active (base %s.%s, %d entry fields carried over)", + base.__module__, base.__name__, len(carried), + ) diff --git a/plugins/cogos-discord/plugin.yaml b/plugins/cogos-discord/plugin.yaml new file mode 100644 index 0000000..89270b1 --- /dev/null +++ b/plugins/cogos-discord/plugin.yaml @@ -0,0 +1,11 @@ +name: cogos-discord +version: 0.1.0 +description: > + Layer over Hermes's bundled Discord adapter. v0.1: threads the bot itself created (for example + forum posts made over REST) count as threads the bot is in, so it answers there without an @mention. + Replaces the bundled `discord` platform entry with a copy whose only change is the adapter class. +author: myrgic +kind: standalone +platforms: + - macos + - linux diff --git a/plugins/cogos-discord/tests/test_cogos_discord.py b/plugins/cogos-discord/tests/test_cogos_discord.py new file mode 100644 index 0000000..d9729db --- /dev/null +++ b/plugins/cogos-discord/tests/test_cogos_discord.py @@ -0,0 +1,209 @@ +"""Contract + behaviour tests for cogos-discord against a real Hermes checkout. + +Run from a Hermes source tree (so ``gateway`` and ``plugins.platforms.discord`` import): + + cd && /bin/python -m pytest /tests -q + +These are the tests to rerun whenever the Hermes runtime pin changes. +""" + +from __future__ import annotations + +import dataclasses +import importlib.util +import json +import pathlib +import sys +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +PLUGIN_DIR = pathlib.Path(__file__).resolve().parents[1] +BOT_ID = 999 +OTHER_ID = 4242 + + +def _load_plugin(): + spec = importlib.util.spec_from_file_location("cogos_discord_under_test", PLUGIN_DIR / "__init__.py") + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +plugin = _load_plugin() +discord_platform = pytest.importorskip("plugins.platforms.discord.adapter") +from gateway.config import PlatformConfig # noqa: E402 +from gateway.platforms.helpers import ThreadParticipationTracker # noqa: E402 + + +# --- contract --------------------------------------------------------------------------------- + +def test_contract_holds_against_this_hermes(): + assert plugin.contract_problems(discord_platform.DiscordAdapter, ThreadParticipationTracker) == [] + + +def test_contract_reports_missing_pieces(): + class Bare: + pass + + problems = plugin.contract_problems(Bare, None) + assert "DiscordAdapter._handle_message missing" in problems + assert "ThreadParticipationTracker not importable" in problems + + +def test_gates_still_read_self_threads(): + """Both mention gates must still consult self._threads, or the wrapper does nothing.""" + import inspect + + live = inspect.getsource(discord_platform.DiscordAdapter._handle_message) + recovered = inspect.getsource(discord_platform.DiscordAdapter._dispatch_recovered_message) + assert "in self._threads" in live + assert "in self._threads" in recovered + + +# --- behaviour -------------------------------------------------------------------------------- + +class FakeForum: + def __init__(self, cid=500): + self.id = cid + self.name = "cog-dev" + self.guild = SimpleNamespace(name="Lab") + self.type = 15 + self.topic = None + + +class FakeThread: + def __init__(self, cid, owner_id, parent): + self.id = cid + self.name = "post" + self.parent = parent + self.parent_id = parent.id + self.owner_id = owner_id + self.guild = parent.guild + self.topic = None + + def history(self, *, limit, before, after=None, oldest_first=None): + async def _iter(): + return + yield + return _iter() + + +class FakeDM: + pass + + +@pytest.fixture +def adapter(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(discord_platform.discord, "Thread", FakeThread, raising=False) + monkeypatch.setattr(discord_platform.discord, "DMChannel", FakeDM, raising=False) + monkeypatch.setattr(discord_platform.discord, "ForumChannel", FakeForum, raising=False) + for var in ("DISCORD_FREE_RESPONSE_CHANNELS", "DISCORD_ALLOWED_CHANNELS", "DISCORD_IGNORED_CHANNELS", + "DISCORD_HISTORY_BACKFILL", "DISCORD_ALLOW_BOTS"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "false") + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + cls = plugin.make_adapter_class(discord_platform.DiscordAdapter) + a = cls(PlatformConfig(enabled=True, token="fake")) + a._allowed_user_ids = {"42"} # recovery path runs the allowlist before the mention gate + forum = FakeForum() + threads = { + 111: FakeThread(111, BOT_ID, forum), # created by the bot over REST + 222: FakeThread(222, OTHER_ID, forum), # created by a human + } + a._client = SimpleNamespace(user=SimpleNamespace(id=BOT_ID), get_channel=lambda cid: threads.get(cid)) + a._text_batch_delay_seconds = 0 + a.handle_message = AsyncMock() + a._fake_threads = threads + return a + + +def _msg(channel, text="hello, no mention"): + return SimpleNamespace( + id=7, content=text, mentions=[], attachments=[], reference=None, + created_at=datetime.now(timezone.utc), channel=channel, + author=SimpleNamespace(id=42, display_name="Chaz", name="chaz", bot=False), + type=discord_platform.discord.MessageType.default, + ) + + +def test_tracker_is_wrapped(adapter): + assert isinstance(adapter._threads, plugin.OwnedThreadTracker) + + +@pytest.mark.asyncio +async def test_bot_owned_thread_answers_without_mention(adapter, tmp_path): + await adapter._handle_message(_msg(adapter._fake_threads[111])) + adapter.handle_message.assert_awaited_once() + saved = json.loads((tmp_path / "discord_threads.json").read_text()) + assert "111" in saved # persisted, survives restart + + +@pytest.mark.asyncio +async def test_human_owned_thread_still_needs_mention(adapter): + await adapter._handle_message(_msg(adapter._fake_threads[222])) + adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_thread_require_mention_still_wins(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true") + await adapter._handle_message(_msg(adapter._fake_threads[111])) + adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_recovered_message_in_bot_owned_thread_is_admitted(adapter): + adapter._handle_message = AsyncMock(return_value=True) + assert await adapter._dispatch_recovered_message(_msg(adapter._fake_threads[111])) is True + adapter._handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_recovered_message_in_human_thread_is_refused(adapter): + adapter._handle_message = AsyncMock(return_value=True) + assert await adapter._dispatch_recovered_message(_msg(adapter._fake_threads[222])) is False + + +def test_unknown_channel_and_missing_client_are_not_owned(adapter): + assert "333" not in adapter._threads + adapter._client = None + assert "444" not in adapter._threads + + +# --- registration ----------------------------------------------------------------------------- + +def test_register_copies_bundled_entry_and_swaps_only_the_factory(monkeypatch): + from hermes_cli.plugins import discover_plugins + from gateway.platform_registry import PlatformEntry, platform_registry + + discover_plugins() # registers the bundled deferred loader, as the gateway does at startup + bundled = platform_registry.get("discord") + assert bundled is not None + captured = {} + + class Ctx: + manifest = SimpleNamespace(name="cogos-discord") + + def register_platform(self, name, label, adapter_factory, check_fn, **kw): + captured["entry"] = PlatformEntry( + name=name, label=label, adapter_factory=adapter_factory, check_fn=check_fn, + source="plugin", plugin_name="cogos-discord", **kw, + ) + + plugin.register(Ctx()) + entry = captured["entry"] + assert entry.adapter_factory is not bundled.adapter_factory + for f in dataclasses.fields(bundled): + if f.name in plugin._SET_BY_REGISTER: + continue + assert getattr(entry, f.name) == getattr(bundled, f.name), f.name + # the pieces the review named explicitly + assert entry.apply_yaml_config_fn is bundled.apply_yaml_config_fn + assert entry.standalone_sender_fn is bundled.standalone_sender_fn + assert entry.check_fn is bundled.check_fn