From ff90b9207c80aa924f69017ee1093ce9db241acf Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Wed, 24 Jun 2026 12:04:03 +0300 Subject: [PATCH] feat(sharing): external client roster on the Sharing tab (trinity-enterprise#20) Surface external channel users (no Trinity account) who have messaged an agent, aggregated across Telegram + WhatsApp into one read-only roster on the Sharing tab. Activity (verified_email, message_count, last_active) was already recorded per channel link but never shown. - db: list_clients_for_agent() join methods (binding -> chat_links) on the Telegram + WhatsApp ops, exposed via the database facade - service: client_roster_service aggregates + sorts by last_active desc (never-active last), degrades per-channel on read failure - api: GET /api/agents/{name}/clients (OwnedAgentByName, read-only, DB-sourced so it renders when the agent container is stopped); ClientRosterEntry model is channel-extensible (Slack/VoIP additive) - ui: read-only Client Roster section in SharingPanel.vue - tests: join normalization, tenant isolation, sort/nulls-last, channel-failure degradation (5 new, all green) - docs: requirements section 44, architecture endpoint + service entries Roster v1 = Telegram + WhatsApp (channels recording the full email/count/last-active triple). Per-client block/revoke/approve controls are the follow-up (trinity-enterprise#21). Related to Abilityai/trinity-enterprise#20 Co-Authored-By: Claude Opus 4.8 --- docs/memory/architecture.md | 2 + docs/memory/requirements.md | 32 ++++ src/backend/database.py | 6 + src/backend/db/telegram_channels.py | 38 +++++ src/backend/db/whatsapp_channels.py | 36 ++++ src/backend/models.py | 17 ++ src/backend/routers/sharing.py | 23 ++- src/backend/services/client_roster_service.py | 56 ++++++ src/frontend/src/components/SharingPanel.vue | 74 +++++++- tests/unit/test_client_roster.py | 160 ++++++++++++++++++ 10 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 src/backend/services/client_roster_service.py create mode 100644 tests/unit/test_client_roster.py diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 923815b76..c0c2a80a4 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -191,6 +191,7 @@ - `proactive_message_service.py` - Agent-to-user proactive messaging with rate limiting and audit (#321) - `agent_shared_files_service.py` - Outbound file sharing — see [Outbound File Sharing](#outbound-file-sharing-files-001) - `loop_service.py` - Sequential agent loop runner — see [Sequential Agent Loops](#sequential-agent-loops-740-ui-1106) +- `client_roster_service.py` - Aggregates external channel clients (Telegram + WhatsApp) into the Sharing-tab roster; cross-channel sort + per-channel failure degradation (#20) - `voip_service.py` - VoIP outbound-call orchestration — see [VoIP](#voip-telephony-voip-001-1056) *Content & Media:* @@ -728,6 +729,7 @@ The per-agent VoIP config + voice-picker UI lives in the agent Settings/Sharing | DELETE | `/api/agents/{name}/share/{email}` | Remove share | | GET | `/api/agents/{name}/shares` | List shares | | GET | `/api/agents/{name}/access` | Operator (Trinity-user) access roster for the **Access tab** (trinity-enterprise#17). Resolves each `agent_sharing` allow-list email against `users`: resolved → **active** operator (`username`/`role`/`last_active`), unresolved → **pending** invite. Read-only typed view over `agent_sharing`; add/remove reuse `/share` + `/share/{email}`. Drawing the operator-vs-client line on the read path is this endpoint's job (strict client roster is the Sharing redesign #18/#20) | +| GET | `/api/agents/{name}/clients` | External-client roster: channel users who've messaged the agent, aggregated across Telegram + WhatsApp, sorted by `last_active` desc (never-active last). Owner-only, read-only, DB-sourced (renders when agent stopped). Slack/VoIP additive (#20) | | GET/PUT | `/api/agents/{name}/access-policy` | Cross-channel access policy: `require_email` / `open_access` flags | | GET | `/api/agents/{name}/access-requests` | Pending access requests | | POST | `/api/agents/{name}/access-requests/{id}/decide` | Approve (auto-shares + fire-and-forget approval notification on the requester's originating channel for telegram/slack/whatsapp, #951) or reject | diff --git a/docs/memory/requirements.md b/docs/memory/requirements.md index e3135f7e4..a058be0b4 100644 --- a/docs/memory/requirements.md +++ b/docs/memory/requirements.md @@ -3415,6 +3415,38 @@ servers (each replica polls + reconciles independently). --- +## 46. External Client Roster (Access/Sharing redesign — epic #16) + +### 46.1 Surface External Channel Clients on the Sharing Tab (#20) + +**Description**: The Sharing tab answers "who is actually talking to this agent +through a channel?" with a read-only **client roster** — external users (no +Trinity account) who have messaged the agent via a channel. Activity is already +collected per channel link (`verified_email`, `message_count`, `last_active`) +but was never surfaced. This is the read surface; per-client controls +(block/revoke/approve) are a separate follow-up (#21). + +- **FR-1 — Aggregated roster endpoint**: `GET /api/agents/{name}/clients` + (owner/admin via `OwnedAgentByName`) returns one entry per external client + across channels: `channel`, `identity` (channel-native handle/phone), + `display_name`, `verified_email`, `message_count`, `last_active`. Sorted by + `last_active` descending (never-active rows last). +- **FR-2 — Channel coverage**: roster v1 covers **Telegram** + (`telegram_bindings` → `telegram_chat_links`) and **WhatsApp** + (`whatsapp_bindings` → `whatsapp_chat_links`) — the channels that record the + full `(verified_email, message_count, last_active)` triple per user. Slack + (verifications carry email but no activity counters) and VoIP (call logs) are + additive follow-ups; the response model is channel-extensible so they slot in + without a contract change. +- **FR-3 — Read-only**: no write actions in this slice. The roster renders even + when the agent container is stopped (DB-sourced). Empty roster renders an + explicit empty state, not an error. +- **FR-4 — Tenant boundary**: the endpoint is scoped to the path agent; the DB + join filters by `agent_name` through the channel binding, so a client of + another agent never appears. + +--- + ## Out of Scope - Multi-tenant deployment (single org only) diff --git a/src/backend/database.py b/src/backend/database.py index 404a04975..15f6ac56e 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -1903,6 +1903,9 @@ def get_or_create_telegram_chat_link(self, binding_id, telegram_user_id, telegra def increment_telegram_message_count(self, chat_link_id): return self._telegram_channel_ops.increment_message_count(chat_link_id) + def list_telegram_clients_for_agent(self, agent_name): + return self._telegram_channel_ops.list_clients_for_agent(agent_name) + def get_telegram_verified_email(self, binding_id, telegram_user_id): return self._telegram_channel_ops.get_verified_email(binding_id, telegram_user_id) @@ -1996,6 +1999,9 @@ def get_whatsapp_chat_link_by_verified_email(self, binding_id, email): def increment_whatsapp_message_count(self, chat_link_id): return self._whatsapp_channel_ops.increment_message_count(chat_link_id) + def list_whatsapp_clients_for_agent(self, agent_name): + return self._whatsapp_channel_ops.list_clients_for_agent(agent_name) + # ========================================================================= # VoIP Telephony (delegated to db/voip.py) - VOIP-001 (#1056) # ========================================================================= diff --git a/src/backend/db/telegram_channels.py b/src/backend/db/telegram_channels.py index c7f184a4a..b3419521e 100644 --- a/src/backend/db/telegram_channels.py +++ b/src/backend/db/telegram_channels.py @@ -297,6 +297,44 @@ def get_verified_email(self, binding_id: int, telegram_user_id: str) -> Optional link = self.get_chat_link(binding_id, telegram_user_id) return link["verified_email"] if link else None + def list_clients_for_agent(self, agent_name: str) -> List[dict]: + """External Telegram clients who have messaged this agent (#20 roster). + + Joins the agent's binding to its chat links. Returns normalized roster + rows; ``identity`` is the @username when known, else the numeric user id. + """ + stmt = ( + select( + telegram_chat_links.c.telegram_user_id, + telegram_chat_links.c.telegram_username, + telegram_chat_links.c.verified_email, + telegram_chat_links.c.message_count, + telegram_chat_links.c.last_active, + ) + .select_from( + telegram_chat_links.join( + telegram_bindings, + telegram_chat_links.c.binding_id == telegram_bindings.c.id, + ) + ) + .where(telegram_bindings.c.agent_name == agent_name) + ) + with get_engine().connect() as conn: + rows = conn.execute(stmt).mappings().all() + clients = [] + for row in rows: + username = row["telegram_username"] + identity = f"@{username}" if username else str(row["telegram_user_id"]) + clients.append({ + "channel": "telegram", + "identity": identity, + "display_name": username or None, + "verified_email": row["verified_email"] or None, + "message_count": row["message_count"] or 0, + "last_active": row["last_active"], + }) + return clients + def set_verified_email( self, binding_id: int, diff --git a/src/backend/db/whatsapp_channels.py b/src/backend/db/whatsapp_channels.py index f98fc499f..8ee84af0c 100644 --- a/src/backend/db/whatsapp_channels.py +++ b/src/backend/db/whatsapp_channels.py @@ -272,6 +272,42 @@ def get_verified_email(self, binding_id: int, wa_user_phone: str) -> Optional[st row = conn.execute(stmt).mappings().first() return row["verified_email"] if row and row["verified_email"] else None + def list_clients_for_agent(self, agent_name: str) -> List[dict]: + """External WhatsApp clients who have messaged this agent (#20 roster). + + Joins the agent's binding to its chat links. ``identity`` is the + E.164-style ``whatsapp:+...`` phone the user messaged from. + """ + stmt = ( + select( + whatsapp_chat_links.c.wa_user_phone, + whatsapp_chat_links.c.wa_user_name, + whatsapp_chat_links.c.verified_email, + whatsapp_chat_links.c.message_count, + whatsapp_chat_links.c.last_active, + ) + .select_from( + whatsapp_chat_links.join( + whatsapp_bindings, + whatsapp_chat_links.c.binding_id == whatsapp_bindings.c.id, + ) + ) + .where(whatsapp_bindings.c.agent_name == agent_name) + ) + with get_engine().connect() as conn: + rows = conn.execute(stmt).mappings().all() + clients = [] + for row in rows: + clients.append({ + "channel": "whatsapp", + "identity": row["wa_user_phone"], + "display_name": row["wa_user_name"] or None, + "verified_email": row["verified_email"] or None, + "message_count": row["message_count"] or 0, + "last_active": row["last_active"], + }) + return clients + def increment_message_count(self, chat_link_id: int) -> None: now = utc_now_iso() stmt = ( diff --git a/src/backend/models.py b/src/backend/models.py index 118cf82bd..8a8c8f90d 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -615,6 +615,23 @@ class SharedFilesList(BaseModel): quota_bytes: int +class ClientRosterEntry(BaseModel): + """One external channel client in the Sharing-tab roster (#20). + + An outside person (no Trinity account) who has messaged the agent through a + channel. `identity` is the channel-native handle (Telegram @username or + numeric id, WhatsApp phone). `display_name`/`verified_email` are null when + unknown; `last_active` is null for a row that has never recorded activity. + Channel-extensible: Slack/VoIP slot in without a contract change. + """ + channel: str + identity: str + display_name: Optional[str] = None + verified_email: Optional[str] = None + message_count: int = 0 + last_active: Optional[str] = None + + class AgentDataImportResponse(BaseModel): """Response for POST /api/agents/{name}/data/import (#1169). diff --git a/src/backend/routers/sharing.py b/src/backend/routers/sharing.py index cd11ddcb5..bb6a2bdc2 100644 --- a/src/backend/routers/sharing.py +++ b/src/backend/routers/sharing.py @@ -8,10 +8,14 @@ from fastapi import APIRouter, Depends, HTTPException, Request -from models import AccessPolicy, AccessPolicyUpdate, AccessRequest, AccessRequestDecision +from models import ( + AccessPolicy, AccessPolicyUpdate, AccessRequest, AccessRequestDecision, + User, ClientRosterEntry, +) from database import db, AgentShare, AgentOperatorAccess, AgentShareRequest from dependencies import get_current_user, OwnedAgentByName, CurrentUser from services.docker_service import get_agent_container +from services.client_roster_service import get_client_roster from services.platform_audit_service import platform_audit_service, AuditEventType from services.proactive_message_service import proactive_message_service @@ -202,6 +206,23 @@ async def get_agent_access_endpoint( return db.get_agent_operator_access(agent_name) +@router.get("/{agent_name}/clients", response_model=List[ClientRosterEntry]) +async def get_client_roster_endpoint( + agent_name: OwnedAgentByName, + current_user: CurrentUser, +): + """External-client roster for the Sharing tab (#20). + + Lists outside users (no Trinity account) who have messaged the agent through + a channel, aggregated across channels and ordered by most-recent activity. + DB-sourced — renders even when the agent container is stopped — so there is + deliberately no container existence check here (`OwnedAgentByName` already + enforces the agent exists and the caller owns it). Read-only; per-client + block/revoke/approve controls are a separate follow-up (#21). + """ + return get_client_roster(agent_name) + + # --------------------------------------------------------------------------- # Unified channel access control (Issue #311) # --------------------------------------------------------------------------- diff --git a/src/backend/services/client_roster_service.py b/src/backend/services/client_roster_service.py new file mode 100644 index 000000000..cb6d1162e --- /dev/null +++ b/src/backend/services/client_roster_service.py @@ -0,0 +1,56 @@ +"""External client roster aggregation (#20 — Access/Sharing redesign, epic #16). + +Surfaces the external channel users (no Trinity account) who have messaged an +agent, aggregated across channels into one roster. This is the read surface for +the reframed Sharing tab; per-client controls (block/revoke/approve) are a +separate follow-up (#21). + +Roster v1 covers the channels that record the full +``(verified_email, message_count, last_active)`` triple per user — Telegram and +WhatsApp. Slack (verifications carry email but no activity counters) and VoIP +(call logs) are additive follow-ups: register another source below and the +endpoint contract is unchanged. + +Three-layer (Invariant #1): this service holds the cross-channel aggregation / +sort business logic; the SQL lives in the ``db/*_channels.py`` join methods; the +router stays thin. +""" + +from __future__ import annotations + +import logging +from typing import List + +from database import db + +logger = logging.getLogger(__name__) + + +def get_client_roster(agent_name: str) -> List[dict]: + """Return external channel clients for ``agent_name``, newest activity first. + + Each entry: ``channel``, ``identity``, ``display_name``, ``verified_email``, + ``message_count``, ``last_active``. Sorted by ``last_active`` descending with + never-active rows (``last_active is None``) last. A single channel failing to + read degrades to skipping that channel rather than failing the whole roster. + """ + sources = ( + ("telegram", db.list_telegram_clients_for_agent), + ("whatsapp", db.list_whatsapp_clients_for_agent), + ) + + roster: List[dict] = [] + for channel, fetch in sources: + try: + roster.extend(fetch(agent_name)) + except Exception as e: # pragma: no cover - defensive per-channel guard + logger.warning( + "[#20] client roster: %s source failed for agent %s: %s", + channel, agent_name, e, + ) + + # ISO-Z timestamps sort lexicographically. With reverse=True the newest + # timestamp comes first and the "" fallback (never-active, last_active=None) + # sorts last — exactly the ordering we want. + roster.sort(key=lambda c: c.get("last_active") or "", reverse=True) + return roster diff --git a/src/frontend/src/components/SharingPanel.vue b/src/frontend/src/components/SharingPanel.vue index 1d05ab46f..395e281b0 100644 --- a/src/frontend/src/components/SharingPanel.vue +++ b/src/frontend/src/components/SharingPanel.vue @@ -145,6 +145,51 @@ + +
+

+ Client roster — who's reaching this agent +

+

+ External users who have messaged this agent through a channel (Telegram, WhatsApp). Read-only. +

+ +
+

No external clients yet

+

Users who message via Telegram or WhatsApp will appear here.

+
+ +
+ + + + + + + + + + + + + + + + + + + +
ClientChannelVerified emailMessagesLast active
+ {{ client.display_name || client.identity }} + {{ client.identity }} + + {{ client.channel }} + + {{ client.verified_email || '—' }} + {{ client.message_count }}{{ formatLastActive(client.last_active) }}
+
+
+

Distribution

@@ -218,6 +263,33 @@ const decisionLoading = ref(null) // to identity-required the next time the operator picks a mode. const accessMode = computed(() => (policy.value.open_access ? 'open' : 'restricted')) +// --------------------------------------------------------------------------- +// External client roster (#20) +// --------------------------------------------------------------------------- +const clients = ref([]) + +const loadClients = async () => { + try { + const { data } = await axios.get( + `/api/agents/${props.agentName}/clients`, + { headers: authStore.authHeader } + ) + clients.value = data + } catch (err) { + console.error('Failed to load client roster:', err) + clients.value = [] + } +} + +const formatLastActive = (iso) => { + if (!iso) return 'never' + try { + return new Date(iso).toLocaleString() + } catch { + return iso + } +} + const loadPolicy = async () => { try { const { data } = await axios.get( @@ -340,6 +412,6 @@ const setPublicChannelModel = async (value) => { watch(() => props.agentName, async (name) => { if (!name) return - await Promise.all([loadPolicy(), loadAccessRequests(), loadPublicChannelModel()]) + await Promise.all([loadPolicy(), loadAccessRequests(), loadPublicChannelModel(), loadClients()]) }, { immediate: true }) diff --git a/tests/unit/test_client_roster.py b/tests/unit/test_client_roster.py new file mode 100644 index 000000000..bdbf1c86a --- /dev/null +++ b/tests/unit/test_client_roster.py @@ -0,0 +1,160 @@ +"""Unit tests for the external client roster (#20 — Access/Sharing redesign). + +Covers: +- DB join methods `list_clients_for_agent` on Telegram + WhatsApp ops: correct + normalization (channel/identity/display_name/verified_email/message_count/ + last_active) and tenant isolation (only the agent's own clients). +- `client_roster_service.get_client_roster`: cross-channel aggregation, sort by + last_active descending with never-active rows last, and per-channel failure + degradation. + +Module: src/backend/services/client_roster_service.py + src/backend/db/telegram_channels.py + src/backend/db/whatsapp_channels.py +Issue: Abilityai/trinity-enterprise#20 +""" + +import os +import secrets +import sys +from pathlib import Path +from types import SimpleNamespace + +# IMPORTANT: set REDIS_URL BEFORE any backend import (Issue #589 hard-fail). +os.environ.setdefault("REDIS_URL", "redis://test:test@redis:6379") +os.environ.setdefault("REDIS_PASSWORD", "test") +os.environ.setdefault("REDIS_BACKEND_PASSWORD", "test") + +import pytest + +_BACKEND = Path(__file__).resolve().parent.parent.parent / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) +from db_harness import db_backend # noqa: E402,F401 + + +@pytest.fixture(autouse=True) +def encryption_key(monkeypatch): + """create_binding encrypts the bot/auth token; needs a key.""" + monkeypatch.setenv("CREDENTIAL_ENCRYPTION_KEY", secrets.token_hex(32)) + yield + + +# --------------------------------------------------------------------------- +# DB join methods +# --------------------------------------------------------------------------- + + +class TestTelegramRosterQuery: + def test_lists_only_this_agents_clients_with_normalization(self, db_backend): + from db import telegram_channels as tg_db + ops = tg_db.TelegramChannelOperations() + + ops.create_binding(agent_name="agent-a", bot_token="tok-a", bot_id="111") + ops.create_binding(agent_name="agent-b", bot_token="tok-b", bot_id="222") + bind_a = ops.get_binding_by_agent("agent-a")["id"] + bind_b = ops.get_binding_by_agent("agent-b")["id"] + + # agent-a: one with username, one without + ops.get_or_create_chat_link(bind_a, "1001", "alice") + ops.get_or_create_chat_link(bind_a, "1002", None) + ops.set_verified_email(bind_a, "1001", "alice@example.com") + # agent-b: should never appear in agent-a's roster + ops.get_or_create_chat_link(bind_b, "2001", "bob") + + clients = ops.list_clients_for_agent("agent-a") + assert len(clients) == 2 + by_identity = {c["identity"]: c for c in clients} + + assert "@alice" in by_identity + assert by_identity["@alice"]["channel"] == "telegram" + assert by_identity["@alice"]["display_name"] == "alice" + assert by_identity["@alice"]["verified_email"] == "alice@example.com" + + # No username → identity falls back to the numeric user id, email None + assert "1002" in by_identity + assert by_identity["1002"]["display_name"] is None + assert by_identity["1002"]["verified_email"] is None + + def test_empty_for_agent_without_binding(self, db_backend): + from db import telegram_channels as tg_db + assert tg_db.TelegramChannelOperations().list_clients_for_agent("nope") == [] + + +class TestWhatsAppRosterQuery: + def test_lists_clients_with_phone_identity(self, db_backend): + from db import whatsapp_channels as wa_db + ops = wa_db.WhatsAppChannelOperations() + + ops.create_binding( + agent_name="agent-wa", + account_sid="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + auth_token="tok", + from_number="whatsapp:+15551230000", + ) + bind = ops.get_binding_by_agent("agent-wa")["id"] + ops.get_or_create_chat_link(bind, "whatsapp:+15559998888", "Carol") + ops.set_verified_email(bind, "whatsapp:+15559998888", "carol@example.com") + + clients = ops.list_clients_for_agent("agent-wa") + assert len(clients) == 1 + c = clients[0] + assert c["channel"] == "whatsapp" + assert c["identity"] == "whatsapp:+15559998888" + assert c["display_name"] == "Carol" + assert c["verified_email"] == "carol@example.com" + + +# --------------------------------------------------------------------------- +# Service aggregation + sort +# --------------------------------------------------------------------------- + + +def _entry(channel, identity, last_active, count=0): + return { + "channel": channel, + "identity": identity, + "display_name": None, + "verified_email": None, + "message_count": count, + "last_active": last_active, + } + + +class TestRosterService: + def test_merges_channels_sorted_desc_nulls_last(self, monkeypatch): + import services.client_roster_service as svc + + tg = [ + _entry("telegram", "@old", "2026-01-01T00:00:00Z"), + _entry("telegram", "@never", None), + ] + wa = [ + _entry("whatsapp", "whatsapp:+1", "2026-06-01T00:00:00Z"), + ] + fake_db = SimpleNamespace( + list_telegram_clients_for_agent=lambda name: tg, + list_whatsapp_clients_for_agent=lambda name: wa, + ) + monkeypatch.setattr(svc, "db", fake_db) + + roster = svc.get_client_roster("agent-x") + identities = [c["identity"] for c in roster] + # newest first, never-active last + assert identities == ["whatsapp:+1", "@old", "@never"] + + def test_one_channel_failing_degrades_to_other(self, monkeypatch): + import services.client_roster_service as svc + + def boom(name): + raise RuntimeError("telegram source down") + + wa = [_entry("whatsapp", "whatsapp:+1", "2026-06-01T00:00:00Z")] + fake_db = SimpleNamespace( + list_telegram_clients_for_agent=boom, + list_whatsapp_clients_for_agent=lambda name: wa, + ) + monkeypatch.setattr(svc, "db", fake_db) + + roster = svc.get_client_roster("agent-x") + assert [c["identity"] for c in roster] == ["whatsapp:+1"]