Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/memory/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:*
Expand Down Expand Up @@ -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 |
Expand Down
30 changes: 30 additions & 0 deletions docs/memory/requirements/public-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions src/backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
# =========================================================================
Expand Down
38 changes: 38 additions & 0 deletions src/backend/db/telegram_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions src/backend/db/whatsapp_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
17 changes: 17 additions & 0 deletions src/backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
23 changes: 22 additions & 1 deletion src/backend/routers/sharing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
56 changes: 56 additions & 0 deletions src/backend/services/client_roster_service.py
Original file line number Diff line number Diff line change
@@ -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
74 changes: 73 additions & 1 deletion src/frontend/src/components/SharingPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,51 @@
</div>
</div>

<!-- Client Roster (#20) — external channel users (read-only) -->
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
Client roster <span class="font-normal text-gray-500 dark:text-gray-400">— who's reaching this agent</span>
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
External users who have messaged this agent through a channel (Telegram, WhatsApp). Read-only.
</p>

<div v-if="clients.length === 0" class="text-center py-6 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-dashed border-gray-300 dark:border-gray-700">
<p class="text-sm">No external clients yet</p>
<p class="text-xs mt-1">Users who message via Telegram or WhatsApp will appear here.</p>
</div>

<div v-else class="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-900/50">
<tr class="text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th class="px-4 py-2">Client</th>
<th class="px-4 py-2">Channel</th>
<th class="px-4 py-2">Verified email</th>
<th class="px-4 py-2 text-right">Messages</th>
<th class="px-4 py-2">Last active</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="client in clients" :key="`${client.channel}:${client.identity}`">
<td class="px-4 py-2 text-gray-900 dark:text-gray-100">
<span class="font-medium">{{ client.display_name || client.identity }}</span>
<span v-if="client.display_name" class="block text-xs text-gray-500 dark:text-gray-400">{{ client.identity }}</span>
</td>
<td class="px-4 py-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 capitalize">{{ client.channel }}</span>
</td>
<td class="px-4 py-2 text-gray-600 dark:text-gray-300">
{{ client.verified_email || '—' }}
</td>
<td class="px-4 py-2 text-right tabular-nums text-gray-700 dark:text-gray-200">{{ client.message_count }}</td>
<td class="px-4 py-2 text-gray-500 dark:text-gray-400">{{ formatLastActive(client.last_active) }}</td>
</tr>
</tbody>
</table>
</div>
</div>

<!-- Distribution: content/links sharing — not client access (#18 nudge) -->
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">Distribution</h4>
Expand Down Expand Up @@ -234,6 +279,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(
Expand Down Expand Up @@ -356,6 +428,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 })
</script>
Loading
Loading