Coverage for integrations / channels / room_capable.py: 100.0%
10 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2room_capable — UNIF-G2 — opt-in mixin marking adapters that support
3room/channel join semantics.
5Why a Mixin and not a base-class extension:
6 Not every channel concept supports rooms (SMS / email / iMessage 1:1
7 / signal 1:1 don't). Adding ``join_room`` to ``ChannelAdapter`` would
8 force those adapters to raise ``UnsupportedRoomError`` from a method
9 they have no business implementing. A separate Mixin keeps
10 ``ChannelAdapter`` minimal (single-responsibility) and lets G2's
11 ``Join_External_Room`` agent tool detect support via
12 ``isinstance(adapter, RoomCapableAdapter)``.
14Adapters that should subclass this Mixin (alongside ``ChannelAdapter``):
15 - DiscordAdapter (text channels + voice rooms via livekit bridge)
16 - SlackAdapter (channels)
17 - MatrixAdapter (rooms)
18 - TelegramAdapter (super-groups + channels)
19 - TeamsAdapter (channels + meetings)
20 - WhatsAppAdapter (groups)
22Adapters that should NOT subclass this Mixin:
23 - SignalAdapter (1:1 only at the protocol level we use today)
24 - IMessageAdapter (1:1 only via current bridge)
25 - WebAdapter (per-user web chat, no rooms)
26 - EmailAdapter (threads, not rooms)
27 - VoiceAdapter (per-call, not rooms)
28 - SMSAdapter (1:1)
30Voice rooms (Discord audio, Teams meet, Zoom-style) route through the
31canonical ``LiveKitService.issue_token`` + ``AgentVoiceBridge.attach_agent``
32pair (see ``integrations/social/agent_voice_bridge.py``). The adapter's
33``join_room`` is for TEXT room-membership only; voice participation is
34livekit-side.
36Per-platform implementation notes are deferred to each adapter — this
37module only declares the contract. The contract is a SUBSET of
38``ChannelAdapter`` so a class can ``extend`` either order:
39 ``class DiscordAdapter(ChannelAdapter, RoomCapableAdapter): ...``
41Per HIVE AI MISSION: G2's ``Join_External_Room`` tool ALWAYS calls
42``room_presence_service.gate(...)`` BEFORE ``join_room(...)`` and
43``announce_presence(...)`` IMMEDIATELY AFTER a successful join.
44``join_room`` itself is the platform-specific transport — never the
45policy point.
46"""
48from __future__ import annotations
50from typing import Any, Dict, List, Optional
53class UnsupportedRoomError(Exception):
54 """Raised when a caller tries to join a room on an adapter that does
55 not support rooms (e.g. SMS). Caught by the Join_External_Room tool
56 so it can return a graceful "this platform doesn't support rooms"
57 message instead of a stack trace."""
60class RoomCapableAdapter:
61 """Mixin marker for channel adapters that support room/channel join
62 semantics.
64 Subclasses MUST override ``join_room``, ``leave_room``, and
65 ``list_room_members`` (per platform). All three are async because
66 every existing channel adapter uses asyncio and ``ChannelAdapter``
67 is async-first.
69 Roles passed to ``join_room`` are the SAME role strings used by
70 ``room_presence_service`` so consent + adapter speak the same
71 vocabulary:
72 - ``co_pilot`` — full read+write+react participation
73 - ``participant`` — full read+write
74 - ``note_taker`` — read+react only (no write)
75 - ``silent_observer`` — read only
76 - ``writer`` — write-on-behalf only (uncommon — usually paired
77 with another role)
78 """
80 async def join_room(self, room_id: str,
81 role: str = 'participant') -> bool:
82 """Join the named room/channel as the agent's presence.
84 Args:
85 room_id: Platform-native room id (e.g. Discord channel id,
86 Slack channel id, Matrix room id, Telegram chat id).
87 role: One of the role strings above. Adapters MAY use
88 this to set platform-side permissions (e.g. mute
89 for ``silent_observer``).
91 Returns ``True`` iff the join succeeded. Adapters that fail
92 for transient reasons (rate limit, network) should return
93 ``False`` rather than raise — the Join_External_Room tool
94 handles the retry / fallback on the caller side.
96 Adapters MUST raise ``UnsupportedRoomError`` if the protocol
97 cannot represent room membership (e.g. attempting to join a
98 DM "room").
99 """
100 raise NotImplementedError(
101 f"{type(self).__name__} subclasses RoomCapableAdapter "
102 f"but does not implement join_room")
104 async def leave_room(self, room_id: str) -> bool:
105 """Leave the named room/channel. Counterpart to ``join_room``."""
106 raise NotImplementedError(
107 f"{type(self).__name__} subclasses RoomCapableAdapter "
108 f"but does not implement leave_room")
110 async def list_room_members(self, room_id: str) -> List[Dict[str, Any]]:
111 """Return a list of member dicts for the room.
113 Each dict at minimum contains ``id`` and ``display_name``.
114 Adapters MAY include additional platform-specific fields.
115 Optional — implementations may return ``[]`` if listing is not
116 supported or rate-limited.
117 """
118 return []
121def is_room_capable(adapter: Any) -> bool:
122 """Return True iff ``adapter`` supports room operations.
124 Convenience helper used by ``Join_External_Room`` so the agent tool
125 can return a clean "platform support pending" message for adapters
126 that haven't been wired with the Mixin yet, without paying the cost
127 of a try/except around the actual join.
128 """
129 return isinstance(adapter, RoomCapableAdapter)