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
« 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.
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.):
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"
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.
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
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.
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"""
41from __future__ import annotations
43import logging
44from typing import Optional, Tuple
46logger = logging.getLogger('hevolve_social')
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'
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)
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)
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.
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, ...)``.
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.
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'
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 )
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.
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.
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".
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')
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
222def is_objection(text: str) -> bool:
223 """Return True iff ``text`` contains a known objection phrase.
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)
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.
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).
247 Caller signature for ``on_detach``: ``on_detach(reason: str) -> None``.
248 Best-effort — exceptions from the callback are logged, never raised.
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
260 detail = {
261 'platform': getattr(adapter, 'name', '?'),
262 'room_id': room_id, 'agent_id': agent_id,
263 }
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)
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]})
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)