diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 794434de..efd0fb65 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/public-access.md b/docs/memory/requirements/public-access.md index 3899a164..5b09f824 100644 --- a/docs/memory/requirements/public-access.md +++ b/docs/memory/requirements/public-access.md @@ -482,3 +482,33 @@ Standalone mobile-friendly admin page for managing agents on the go. Designed as - Prevent overscroll bounce on iOS --- + +## 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. diff --git a/src/backend/database.py b/src/backend/database.py index 14ab8dc5..860c3e28 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -1906,6 +1906,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) @@ -1999,6 +2002,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 c7f184a4..b3419521 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 f98fc499..8ee84af0 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 2c782c2d..b5ce392d 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 cd11ddcb..bb6a2bdc 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 00000000..cb6d1162 --- /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 89765edd..fc098c6c 100644 --- a/src/frontend/src/components/SharingPanel.vue +++ b/src/frontend/src/components/SharingPanel.vue @@ -155,6 +155,51 @@ + +
+ 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.
+| Client | +Channel | +Verified email | +Messages | +Last active | +
|---|---|---|---|---|
| + {{ client.display_name || client.identity }} + {{ client.identity }} + | ++ {{ client.channel }} + | ++ {{ client.verified_email || '—' }} + | +{{ client.message_count }} | +{{ formatLastActive(client.last_active) }} | +