"""Managed Slack relay client (Milestone 3) — dual-mode desktop, team-qualified addressing, per-team reply tokens. Hermetic: an injected fake relay transport (no live WebSocket) + captured senders (no network).""" from __future__ import annotations import asyncio import pytest from coworker.connectors import relay_client from coworker.connectors.adapters import make_adapter from coworker.connectors.base import InteractionEvent, MessageEvent from coworker.connectors.config import ConnectorSettings, load_settings from coworker.connectors.relay_client import SlackRelayAdapter from coworker.connectors.slack_addr import qualify, split from coworker.connectors.tools import make_send_message_tool from coworker.secrets import SecretStore @pytest.fixture(autouse=True) def _no_slack_network(monkeypatch): """Name/channel resolution is best-effort; unstubbed lookups must fail instantly at a dead loopback port, never reach slack.com — a slow real answer was blowing the 2s wait_dispatched window intermittently.""" monkeypatch.setenv("SLACK_API_URL", "http://127.0.0.1:9/") TEAMS = { "T1": {"bot_token": "xoxb-team1", "bot_user_id": "UBOT1"}, "T2": {"bot_token": "xoxb-team2", "bot_user_id": "UBOT2"}, } class FakeTransport: """Yields queued frames, then either closes (recv→None) or blocks forever.""" def __init__(self, frames, *, close_after: bool): self._q: asyncio.Queue = asyncio.Queue() for f in frames: self._q.put_nowait(f) self._close_after = close_after self.opened = False async def open(self): self.opened = True async def recv(self): if not self._q.empty(): return self._q.get_nowait() if self._close_after: return None await asyncio.Event().wait() # block until cancelled async def close(self): pass def _factory(*transports): """A transport_factory that hands out the given transports in order.""" it = iter(transports) def make(): return next(it) return make def _event_frame(team, channel, user, text="hi", ts="1.0"): return { "provider": "slack", "team_id": team, "address": f"slack:{team}:{channel}", "channel": channel, "event_id": f"Ev-{ts}", "event": { "type": "app_mention", "user": user, "channel": channel, "text": text, "ts": ts, }, } def _adapter(frames, *, close_after=False, **kw) -> SlackRelayAdapter: return SlackRelayAdapter( "wss://relay.test/ws", token_provider=lambda: "jwt-token", teams=dict(TEAMS), transport_factory=_factory(FakeTransport(frames, close_after=close_after)), reconnect_delay=0.0, **kw, ) def _collect(adapter): events: list[MessageEvent] = [] adapter.set_message_handler(lambda e: events.append(e) or asyncio.sleep(0)) return events # --- pure addressing -------------------------------------------------------- def test_slack_addr_roundtrip(): assert qualify("T1", "C9") == "T1/C9" assert qualify(None, "C9") == "C9" assert split("T1/C9") == ("T1", "C9") assert split("C9") == (None, "C9") # --- inbound dispatch ------------------------------------------------------- async def test_relay_dispatches_team_qualified_event(): adapter = _adapter([_event_frame("T1", "C1", "U_ALICE")]) events: list[MessageEvent] = [] async def handler(e): events.append(e) adapter.set_message_handler(handler) assert await adapter.connect() is True try: await adapter.wait_dispatched(1) finally: await adapter.disconnect() assert len(events) == 1 ev = events[0] assert ev.source.chat_id == "T1/C1" assert ev.source.target == "slack:T1/C1" # team-qualified reply handle assert ev.source.team_id == "T1" assert ev.text == "hi" async def test_relay_resolves_names_and_mentions(monkeypatch): adapter = _adapter([_event_frame("T1", "C1", "U_ALICE", text="<@UBOT1> hey bot")]) async def fake_get(team_id, method, params): assert team_id == "T1" # resolved with THIS workspace's token if method == "users.info": names = { "U_ALICE": {"profile": {"display_name": "Rohit"}}, "UBOT1": {"profile": {"display_name": "ocw"}}, } return {"ok": True, "user": names.get(params["user"], {})} if method == "conversations.info": return {"ok": True, "channel": {"name": "ocw-test"}} return None monkeypatch.setattr(adapter, "_slack_get", fake_get) events: list[MessageEvent] = [] async def handler(e): events.append(e) adapter.set_message_handler(handler) await adapter.connect() try: await adapter.wait_dispatched(1) finally: await adapter.disconnect() ev = events[0] assert ev.source.user_name == "Rohit" # not U_ALICE assert ev.source.chat_name == "ocw-test" # not C1 assert ev.text == "@ocw hey bot" # <@UBOT1> rewritten async def test_relay_name_cache_is_per_workspace(monkeypatch): """A cached (team, id) must not leak across workspaces.""" adapter = SlackRelayAdapter("wss://x", lambda: "jwt", teams=dict(TEAMS)) calls = [] async def fake_get(team_id, method, params): calls.append((team_id, params.get("user"))) return { "ok": True, "user": {"profile": {"display_name": f"{team_id}:{params['user']}"}}, } monkeypatch.setattr(adapter, "_slack_get", fake_get) # Same uid string in two workspaces resolves independently + caches per team. assert await adapter._display_name("T1", "U9") == "T1:U9" assert await adapter._display_name("T2", "U9") == "T2:U9" assert await adapter._display_name("T1", "U9") == "T1:U9" # cached, no new call assert calls == [("T1", "U9"), ("T2", "U9")] # T1 second lookup served from cache async def test_relay_two_workspace_fan_in(): adapter = _adapter( [ _event_frame("T1", "C1", "U_A", ts="1"), _event_frame("T2", "C2", "U_B", ts="2"), ] ) events: list[MessageEvent] = [] async def handler(e): events.append(e) adapter.set_message_handler(handler) await adapter.connect() try: await adapter.wait_dispatched(2) finally: await adapter.disconnect() chats = {e.source.chat_id for e in events} assert chats == {"T1/C1", "T2/C2"} async def test_relay_ignores_own_bot_echo(): # event.user == the team's bot user id → dropped by the mapper. adapter = _adapter([_event_frame("T1", "C1", "UBOT1")]) events: list = [] adapter.set_message_handler(lambda e: events.append(e)) await adapter.connect() await asyncio.sleep(0.05) await adapter.disconnect() assert events == [] # --- reconnect -------------------------------------------------------------- async def test_relay_watchdog_reconnects(): t1 = FakeTransport([_event_frame("T1", "C1", "U_A", ts="1")], close_after=True) t2 = FakeTransport([_event_frame("T1", "C1", "U_A", ts="2")], close_after=False) adapter = SlackRelayAdapter( "wss://relay.test/ws", token_provider=lambda: "jwt", teams=dict(TEAMS), transport_factory=_factory(t1, t2), reconnect_delay=0.0, ) events: list = [] async def handler(e): events.append(e) adapter.set_message_handler(handler) await adapter.connect() try: await adapter.wait_dispatched(2) finally: await adapter.disconnect() assert adapter.reconnects == 1 # dropped socket self-healed assert t2.opened is True assert len(events) == 2 # --- interactivity / revoked / nudge --------------------------------------- async def test_relay_interactivity_maps_to_interaction(): frame = { "kind": "interactivity", "team_id": "T2", "interaction": { "user": {"id": "U2", "username": "bob"}, "channel": {"id": "C7"}, "message": {"ts": "9.9"}, "response_url": "https://hooks.slack.com/actions/abc", "actions": [{"value": "approve:42"}], }, } adapter = _adapter([frame]) seen: list[InteractionEvent] = [] async def on_interaction(e): seen.append(e) adapter.set_interaction_handler(on_interaction) await adapter.connect() try: await adapter.wait_dispatched(1) finally: await adapter.disconnect() assert len(seen) == 1 assert seen[0].chat_id == "T2/C7" and seen[0].value == "approve:42" assert seen[0].user_id == "U2" assert seen[0].team_id == "T2" assert seen[0].response_url == "https://hooks.slack.com/actions/abc" async def test_relay_revoked_drops_team(): adapter = _adapter([{"kind": "revoked", "team_id": "T1"}]) await adapter.connect() try: await adapter.wait_dispatched(1) finally: await adapter.disconnect() assert "T1" not in adapter._teams and "T2" in adapter._teams async def test_relay_nudge_pulls_history(): fetched = {} async def fetcher(team, channel, count): fetched.update(team=team, channel=channel, count=count) return [ {"type": "message", "user": "U_A", "text": "missed one", "ts": "1"}, {"type": "message", "user": "U_A", "text": "missed two", "ts": "2"}, ] adapter = _adapter( [{"kind": "missed", "team_id": "T1", "channel": "C1", "count": 2}], history_fetcher=fetcher, ) events: list[MessageEvent] = [] async def handler(e): events.append(e) adapter.set_message_handler(handler) await adapter.connect() try: await adapter.wait_dispatched(1) finally: await adapter.disconnect() assert fetched == {"team": "T1", "channel": "C1", "count": 2} assert [e.text for e in events] == ["missed one", "missed two"] assert all(e.source.chat_id == "T1/C1" for e in events) # --- outbound per-team token ------------------------------------------------ async def test_relay_send_selects_per_team_token(monkeypatch): captured = {} def fake_send(token, chat_id, text, thread_id=None): captured.update(token=token, chat_id=chat_id, text=text) from coworker.connectors.base import SendResult return SendResult(True, message_id="ts1") monkeypatch.setattr(relay_client, "_send_slack", fake_send) adapter = SlackRelayAdapter("wss://x", lambda: "jwt", teams=dict(TEAMS)) res = await adapter.send("T2/C9", "hello") assert res.ok assert captured["token"] == "xoxb-team2" # T2's token, not T1's assert captured["chat_id"] == "T2/C9" # --- send_message tool per-team selection ----------------------------------- def test_send_message_tool_per_team_and_default_token(): secrets = SecretStore() secrets.put("slack:team:T1", {"bot_token": "xoxb-team1"}) secrets.put("slack:default", {"bot_token": "xoxb-manual", "mode": "socket"}) calls = [] def fake_slack(token, chat_id, text, thread_id): calls.append((token, chat_id)) from coworker.connectors.base import SendResult return SendResult(True, message_id="ts") tool = make_send_message_tool(secrets, senders={"slack": fake_slack}) # Team-qualified → per-team token tool("slack:T1/C1", "hi") # Bare (manual socket mode) → default token tool("slack:Cbare", "hi") assert calls[0][0] == "xoxb-team1" assert calls[1][0] == "xoxb-manual" # --- dual-mode adapter selection ------------------------------------------- def test_make_adapter_relay_mode_builds_relay_client(): adapter = make_adapter( "slack", {"mode": "relay", "enabled": True}, secrets=SecretStore(), token_provider=lambda: "jwt", relay_url="wss://relay.test/ws", ) assert isinstance(adapter, SlackRelayAdapter) def test_make_adapter_socket_mode_builds_socket_adapter(): from coworker.connectors.adapters import SlackAdapter adapter = make_adapter("slack", {"bot_token": "xoxb", "app_token": "xapp"}) assert isinstance(adapter, SlackAdapter) def test_make_adapter_relay_without_endpoint_returns_none(): # Relay mode configured but no relay_url / sign-in → don't build (falls back). adapter = make_adapter( "slack", {"mode": "relay", "enabled": True}, secrets=SecretStore() ) assert adapter is None def test_relay_mode_enables_slack_without_bot_token(): secrets = SecretStore() secrets.put("slack:default", {"mode": "relay", "enabled": True}) settings = load_settings(secrets) assert settings["slack"].enabled is True def test_make_adapter_loads_per_team_tokens(): secrets = SecretStore() secrets.put("slack:team:T9", {"bot_token": "xoxb-9", "bot_user_id": "U9"}) adapter = make_adapter( "slack", {"mode": "relay", "enabled": True}, secrets=secrets, token_provider=lambda: "jwt", relay_url="wss://relay/ws", ) assert adapter._bot_token("T9") == "xoxb-9"