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

1""" 

2room_capable — UNIF-G2 — opt-in mixin marking adapters that support 

3room/channel join semantics. 

4 

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)``. 

13 

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) 

21 

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) 

29 

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. 

35 

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): ...`` 

40 

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""" 

47 

48from __future__ import annotations 

49 

50from typing import Any, Dict, List, Optional 

51 

52 

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.""" 

58 

59 

60class RoomCapableAdapter: 

61 """Mixin marker for channel adapters that support room/channel join 

62 semantics. 

63 

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. 

68 

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 """ 

79 

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. 

83 

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``). 

90 

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. 

95 

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") 

103 

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") 

109 

110 async def list_room_members(self, room_id: str) -> List[Dict[str, Any]]: 

111 """Return a list of member dicts for the room. 

112 

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 [] 

119 

120 

121def is_room_capable(adapter: Any) -> bool: 

122 """Return True iff ``adapter`` supports room operations. 

123 

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)