diff --git a/plugins/platforms/buzz/adapter.py b/plugins/platforms/buzz/adapter.py index b07830ae9b6e..4ba989bb5cca 100644 --- a/plugins/platforms/buzz/adapter.py +++ b/plugins/platforms/buzz/adapter.py @@ -803,6 +803,7 @@ async def _handle_membership_event(self, websocket, subscriptions: Dict[str, Opt subscribe to any new ones (fresh DMs dispatch from their beginning).""" self._membership_since = max(self._membership_since, int(event.get("created_at") or 0)) before = set(self._channel_state) + await self._discover_joined_channels() await self._discover_dms(seed=False) for channel_id in self._channel_state: if channel_id in before: @@ -962,6 +963,32 @@ async def _discover_dms(self, *, seed: bool) -> None: else: self._channel_state[ch_id] = {"chat_type": "group", "last_ts": 0, "seen": OrderedDict()} + async def _discover_joined_channels(self) -> None: + """Adopt newly joined community channels when no watch list is pinned. + + The initial high-water mark suppresses pre-membership history, matching + startup channel discovery. This is deliberately separate from the DM + fallback: real channels must never be reclassified as DMs merely + because their membership changes. + """ + if self.channels: + return + code, out, err = await self._run_cli(["channels", "list"]) + if code != 0: + logger.debug( + "Buzz: could not discover newly joined channels — %s", _cli_error_message(err, code) + ) + return + for ch in _parse_json_list(out): + ch_id = str(ch.get("channel_id") or "") + if not ch_id: + continue + self._channel_meta[ch_id] = ch + self._channel_names.setdefault(ch_id, str(ch.get("name") or ch_id)) + if ch_id in self._channel_state or self._may_reclassify_as_dm(ch_id): + continue + await self._seed_channel(ch_id, chat_type="group") + async def _poll_channel(self, channel_id: str) -> None: state = self._channel_state.get(channel_id) if state is None: diff --git a/tests/gateway/test_buzz_websocket.py b/tests/gateway/test_buzz_websocket.py index d6762fb9a74f..fc5c818f28ba 100644 --- a/tests/gateway/test_buzz_websocket.py +++ b/tests/gateway/test_buzz_websocket.py @@ -119,3 +119,83 @@ async def recv(self): await adapter._authenticate_websocket(RejectingWs()) + +@pytest.mark.asyncio +async def test_membership_event_adopts_new_named_channel_when_unpinned(): + """A new community membership must subscribe without a gateway restart (#75107).""" + adapter = _make_adapter() + new_channel = "new-community-channel" + + messages_get_calls = 0 + + async def run_cli(args, **_kwargs): + nonlocal messages_get_calls + if args == ["dms", "list"]: + return 0, json.dumps([{"dm_id": new_channel}]), "" + if args == ["channels", "list"]: + return 0, json.dumps([ + {"channel_id": new_channel, "name": "release-team", "description": "Announcements"} + ]), "" + if args[:2] == ["messages", "get"]: + messages_get_calls += 1 + return 0, json.dumps([{"id": "pre-join", "created_at": 41}]), "" + raise AssertionError(f"unexpected CLI command: {args}") + + class WebSocket: + def __init__(self): + self.sent = [] + + async def send(self, raw): + self.sent.append(json.loads(raw)) + + adapter._run_cli = run_cli + websocket = WebSocket() + subscriptions = {"membership": None} + await adapter._handle_membership_event(websocket, subscriptions, {"created_at": 42}) + + state = adapter._channel_state[new_channel] + assert state["chat_type"] == "group" + assert state["last_ts"] == 41 + assert "pre-join" in state["seen"] + subscription_id = next(key for key, value in subscriptions.items() if value == new_channel) + assert websocket.sent == [[ + "REQ", subscription_id, {"kinds": [9], "#h": [new_channel], "since": 40} + ]] + + await adapter._handle_membership_event(websocket, subscriptions, {"created_at": 43}) + assert messages_get_calls == 1 + assert len(websocket.sent) == 1 + +@pytest.mark.asyncio +async def test_membership_event_does_not_adopt_channel_outside_explicit_watch_list(): + adapter = _make_adapter({"channels": [CHANNEL]}) + + async def run_cli(args, **_kwargs): + if args == ["dms", "list"]: + return 0, "[]", "" + if args == ["channels", "list"]: + return 0, json.dumps([ + {"channel_id": "not-configured", "name": "release-team", "description": "Announcements"} + ]), "" + raise AssertionError(f"unexpected CLI command: {args}") + + adapter._run_cli = run_cli + await adapter._handle_membership_event(_FakeWebSocket(), {"membership": None}, {"created_at": 42}) + assert "not-configured" not in adapter._channel_state + +@pytest.mark.asyncio +async def test_membership_event_leaves_state_unchanged_when_channel_lookup_fails(): + adapter = _make_adapter() + + async def run_cli(args, **_kwargs): + if args == ["dms", "list"]: + return 0, "[]", "" + if args == ["channels", "list"]: + return 1, "", "unavailable" + raise AssertionError(f"unexpected CLI command: {args}") + + adapter._run_cli = run_cli + subscriptions = {"membership": None} + await adapter._handle_membership_event(_FakeWebSocket(), subscriptions, {"created_at": 42}) + assert adapter._channel_state == {} + assert subscriptions == {"membership": None}