Coverage for integrations / social / room_presence_service.py: 92.3%

78 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2room_presence_service — UNIF-G6 — canonical agent-presence-in-external-room policy. 

3 

4This module is the single writer for the three policies that MUST stay 

5together when an AI agent participates in an external room (Discord 

6audio, Teams meet, WhatsApp group, Slack channel, Matrix room, 

7Telegram supergroup, etc.): 

8 

9 1. ``gate(...)`` — consent check before joining 

10 2. ``announce_presence(...)`` — required notice posted into the room 

11 3. ``listen_for_objection(...)`` — auto-detach if anyone says "no AI" 

12 

13Per HIVE AI MISSION (memory/project_hive_mission.md): the agent NEVER 

14silently observes external rooms. Three guardrails: 

15 - Consent before join (UI prompt or pre-granted scope). 

16 - Announcement on join (every participant sees a clearly-flagged 

17 notice that an AI is present, who it serves, and how to remove it). 

18 - Listen for objection — if any participant uses an opt-out phrase 

19 in any of the supported languages, the agent posts a farewell and 

20 leaves immediately. 

21 

22Canonical primitives reused (no parallel paths): 

23 - ``ConsentService.check_consent`` — gate decision (consent_type 

24 ``'cloud_capability'`` + scope ``agent_joins_external_room`` etc.) 

25 - ``ChannelAdapter.send_message`` — post the announcement into the room 

26 - ``ImmutableAuditLog.log_event`` — durable audit chain for join/leave 

27 - ``AgentVoiceBridge.detach_agent`` — voice-room detach on objection 

28 

29Caller (G2 ``Join_External_Room`` agent tool) is responsible for 

30calling ``gate`` BEFORE invoking the adapter join, then 

31``announce_presence`` immediately after a successful join, then 

32``listen_for_objection`` to wire the objection-watcher. 

33 

34This module is intentionally agnostic of which platform — every 

35platform that supports text messaging exposes 

36``ChannelAdapter.send_message``, so the announce + farewell flows 

37through one code path. Voice rooms (livekit) get their detach via the 

38existing ``AgentVoiceBridge`` (line 239 ``detach_agent``). 

39""" 

40 

41from __future__ import annotations 

42 

43import logging 

44from typing import Optional, Tuple 

45 

46logger = logging.getLogger('hevolve_social') 

47 

48# ── Consent scopes per role ──────────────────────────────────────── 

49# The role determines which scope we check. These string literals 

50# MUST stay in sync with ``landing-page/src/components/Social/Settings/ 

51# cloudCapabilityScopes.js:CLOUD_CAPABILITY_SCOPES``. 

52_SCOPE_BY_ROLE = { 

53 'note_taker': 'agent_listens_external_audio', 

54 'co_pilot': 'agent_joins_external_room', 

55 'participant': 'agent_joins_external_room', 

56 'silent_observer': 'agent_joins_external_room', 

57 'writer': 'agent_writes_external_room', 

58} 

59_DEFAULT_SCOPE = 'agent_joins_external_room' 

60_CONSENT_TYPE = 'cloud_capability' 

61 

62# ── Objection phrases (i18n) ─────────────────────────────────────── 

63# Any participant typing one of these (case-insensitive, word-boundary) 

64# triggers immediate agent detach + farewell. Adding a language: append 

65# the phrase here. Single source of truth — no per-adapter copy. 

66_OBJECTION_PHRASES = ( 

67 # English 

68 'no ai', 'no bot', 'no agent', 'remove ai', 'remove bot', 

69 'kick the ai', 'kick the bot', '/agent-out', 

70 # Spanish 

71 'sin ia', 'no ia', 'fuera ia', 'sin bot', 

72 # French 

73 'pas d\'ia', 'sans ia', 'pas de bot', 

74 # German 

75 'keine ki', 'kein bot', 'ki raus', 

76 # Hindi (Latin script) 

77 'no ai please', 'ai hata', 

78 # Mandarin (transliteration + simplified) 

79 'wu ai', '不要 ai', 

80 # Japanese 

81 'ai なし', 

82 # Portuguese 

83 'sem ia', 'fora ia', 

84 # Tamil (Latin) 

85 'ai venda', 

86) 

87 

88 

89def _scope_for_role(role: str) -> str: 

90 """Map a join role to the consent scope it requires.""" 

91 return _SCOPE_BY_ROLE.get((role or '').lower(), _DEFAULT_SCOPE) 

92 

93 

94def gate(user_id: str, platform: str, room_id: str, 

95 role: str = 'co_pilot') -> Tuple[bool, str]: 

96 """Return ``(allowed, reason)`` for an agent-in-room join. 

97 

98 Checks ``ConsentService.check_consent`` against the appropriate 

99 cloud-capability scope for the requested role. Caller (G2 agent 

100 tool) is responsible for surfacing a Liquid UI consent prompt 

101 when this returns ``(False, ...)``. 

102 

103 Failure modes (all return ``(False, <reason>)`` not raise): 

104 - DB unavailable / consent service import fails → assume DENIED 

105 (fail-closed; never auto-grant). 

106 - User has revoked the scope → DENIED. 

107 - User never granted → DENIED, with reason ``'consent required'`` 

108 so caller can prompt. 

109 

110 Audit trail: every gate decision (allow OR deny) emits an audit log 

111 entry via ``ImmutableAuditLog.log_event`` so security review can 

112 reconstruct who allowed what when. Never raises. 

113 """ 

114 scope = _scope_for_role(role) 

115 detail = { 

116 'platform': platform, 

117 'room_id': room_id, 

118 'role': role, 

119 'scope': scope, 

120 } 

121 try: 

122 from integrations.social.consent_service import ConsentService 

123 from integrations.social.models import get_db 

124 db = get_db() 

125 try: 

126 allowed = ConsentService.check_consent( 

127 db, str(user_id), _CONSENT_TYPE, scope=scope) 

128 finally: 

129 db.close() 

130 except Exception as e: 

131 logger.warning( 

132 "room_presence.gate: consent check failed for " 

133 "user=%s scope=%s platform=%s room=%s: %s — failing closed", 

134 user_id, scope, platform, room_id, e) 

135 _audit('room_presence.gate', user_id, 'denied_error', 

136 {**detail, 'error': str(e)[:200]}) 

137 return False, 'consent service unavailable — please retry' 

138 

139 if allowed: 

140 _audit('room_presence.gate', user_id, 'allowed', detail) 

141 return True, 'ok' 

142 _audit('room_presence.gate', user_id, 'denied_no_consent', detail) 

143 return False, ( 

144 f"Permission required: I need your consent to " 

145 f"join {platform} rooms as a {role}. Please grant the " 

146 f"'{scope}' scope in Settings → Privacy." 

147 ) 

148 

149 

150def announce_presence(adapter, room_id: str, user_id: str, 

151 role: str = 'co_pilot', 

152 *, owner_display_name: Optional[str] = None) -> bool: 

153 """Post the canonical announcement message into the external room. 

154 

155 Honors HIVE AI MISSION rule that the agent ALWAYS makes its 

156 presence known. Wording is fixed (single canon — no per-platform 

157 variant) so users see consistent disclosure across Discord / Teams 

158 / WhatsApp / etc. 

159 

160 Args: 

161 adapter: A ``ChannelAdapter`` instance (must support 

162 ``send_message`` per ``channels/base.py:141``). 

163 room_id: Platform-native room/chat id. 

164 user_id: Owner's user id (for the audit log). 

165 role: Same role string used in ``gate()``. 

166 owner_display_name: Optional friendly name to insert into 

167 the announcement. Falls back to "this user". 

168 

169 Returns ``True`` iff the announcement was sent successfully. 

170 On failure, audit logs the error and returns ``False`` — the 

171 caller (G2) MUST treat that as a hard failure and DETACH the 

172 agent rather than continuing silently. 

173 """ 

174 name = owner_display_name or 'this user' 

175 role_label = { 

176 'note_taker': 'note-taker', 

177 'co_pilot': 'co-pilot', 

178 'participant': 'participant', 

179 'silent_observer': 'silent observer (read-only)', 

180 'writer': 'message writer', 

181 }.get((role or '').lower(), 'co-pilot') 

182 

183 text = ( 

184 f"\U0001F916 An AI agent has joined this room as {name}'s " 

185 f"{role_label}. It will follow the conversation to help " 

186 f"with notes / answers. Reply 'no AI' (or '/agent-out') " 

187 f"any time to have it leave." 

188 ) 

189 detail = { 

190 'platform': getattr(adapter, 'name', '?'), 

191 'room_id': room_id, 'role': role, 

192 } 

193 try: 

194 import asyncio 

195 # ChannelAdapter.send_message is async; the caller may already 

196 # be inside an event loop (Flask + asyncio mix). Defer to the 

197 # adapter's registry loop when present, else run a one-shot. 

198 coro = adapter.send_message(room_id, text) 

199 try: 

200 loop = asyncio.get_event_loop() 

201 if loop.is_running(): 

202 fut = asyncio.run_coroutine_threadsafe(coro, loop) 

203 result = fut.result(timeout=10) 

204 else: 

205 result = loop.run_until_complete(coro) 

206 except RuntimeError: 

207 result = asyncio.run(coro) 

208 ok = bool(getattr(result, 'success', False)) 

209 _audit('room_presence.announce', user_id, 

210 'announced' if ok else 'announce_failed', 

211 {**detail, 'message_id': getattr(result, 'message_id', None)}) 

212 return ok 

213 except Exception as e: 

214 logger.warning( 

215 "room_presence.announce_presence failed (platform=%s " 

216 "room=%s): %s", detail['platform'], room_id, e) 

217 _audit('room_presence.announce', user_id, 'announce_error', 

218 {**detail, 'error': str(e)[:200]}) 

219 return False 

220 

221 

222def is_objection(text: str) -> bool: 

223 """Return True iff ``text`` contains a known objection phrase. 

224 

225 Pure helper — no side effects. Used by ``listen_for_objection`` 

226 and reusable for unit tests. Case-insensitive substring match; 

227 the phrases are short enough that false positives are negligible 

228 (a bystander typing "no ai" verbatim DOES want the agent to leave). 

229 """ 

230 if not text: 

231 return False 

232 low = text.lower() 

233 return any(p in low for p in _OBJECTION_PHRASES) 

234 

235 

236def listen_for_objection(adapter, room_id: str, user_id: str, 

237 agent_id: str, 

238 *, on_detach=None) -> None: 

239 """Hook ``adapter.on_message`` to watch for objection phrases. 

240 

241 On match: logs the objection, posts a brief farewell into the 

242 room (best-effort, never blocks), invokes ``on_detach`` callback 

243 so the caller can run platform-specific detach (e.g. 

244 ``AgentVoiceBridge.detach_agent`` for voice rooms, 

245 ``adapter.leave_room`` for text rooms when G2 ships the Mixin). 

246 

247 Caller signature for ``on_detach``: ``on_detach(reason: str) -> None``. 

248 Best-effort — exceptions from the callback are logged, never raised. 

249 

250 Note: this function REGISTERS a handler on the adapter; it does 

251 NOT block. The handler stays active until the adapter unregisters 

252 it (which happens automatically when the adapter disconnects). 

253 """ 

254 if adapter is None or not hasattr(adapter, 'on_message'): 

255 logger.debug( 

256 "room_presence.listen_for_objection: adapter %r lacks " 

257 "on_message hook — skipping", adapter) 

258 return 

259 

260 detail = { 

261 'platform': getattr(adapter, 'name', '?'), 

262 'room_id': room_id, 'agent_id': agent_id, 

263 } 

264 

265 async def _check_objection(message): 

266 try: 

267 text = getattr(message, 'text', '') or '' 

268 chat_id = getattr(message, 'chat_id', None) 

269 if chat_id and str(chat_id) != str(room_id): 

270 return 

271 if not is_objection(text): 

272 return 

273 logger.info( 

274 "room_presence: objection detected in %s/%s — " 

275 "detaching agent %s", detail['platform'], room_id, agent_id) 

276 _audit('room_presence.objection', user_id, 'detected', 

277 {**detail, 'phrase_in': text[:120]}) 

278 # Farewell — best-effort. 

279 try: 

280 farewell = ( 

281 "\U0001F44B Understood — leaving now. " 

282 "Re-invite anytime." 

283 ) 

284 await adapter.send_message(room_id, farewell) 

285 except Exception as fe: 

286 logger.debug( 

287 "room_presence: farewell post failed: %s", fe) 

288 # Caller-supplied detach. 

289 if callable(on_detach): 

290 try: 

291 on_detach('participant_objection') 

292 except Exception as de: 

293 logger.warning( 

294 "room_presence: on_detach callback raised: %s", de) 

295 _audit('room_presence.detach', user_id, 

296 'detach_callback_error', 

297 {**detail, 'error': str(de)[:200]}) 

298 else: 

299 _audit('room_presence.detach', user_id, 

300 'no_detach_callback', detail) 

301 except Exception as e: 

302 # Never let a watcher error break adapter delivery. 

303 logger.warning( 

304 "room_presence.listen_for_objection inner: %s", e) 

305 

306 try: 

307 adapter.on_message(_check_objection) 

308 _audit('room_presence.watch', user_id, 'watcher_attached', detail) 

309 except Exception as e: 

310 logger.warning( 

311 "room_presence.listen_for_objection: failed to attach " 

312 "watcher: %s", e) 

313 _audit('room_presence.watch', user_id, 'watcher_attach_failed', 

314 {**detail, 'error': str(e)[:200]}) 

315 

316 

317def _audit(event_type: str, actor_id: str, action: str, detail: dict) -> None: 

318 """Best-effort audit log emit. Never raises.""" 

319 try: 

320 from security.immutable_audit_log import get_audit_log 

321 get_audit_log().log_event( 

322 event_type=event_type, 

323 actor_id=str(actor_id), 

324 action=action, 

325 detail=detail, 

326 ) 

327 except Exception as e: 

328 # Audit log unavailable — log to module logger so a grep over 

329 # the local log file still surfaces it. Don't escalate; 

330 # consent / announce / listen flows must keep going. 

331 logger.info( 

332 "room_presence audit (fallback): %s/%s actor=%s detail=%s " 

333 "(audit_log error: %s)", 

334 event_type, action, actor_id, detail, e)