diff --git a/app/src/components/skills/MeetingBotsCard.tsx b/app/src/components/skills/MeetingBotsCard.tsx index 6d0d3fa47..ca579baaf 100644 --- a/app/src/components/skills/MeetingBotsCard.tsx +++ b/app/src/components/skills/MeetingBotsCard.tsx @@ -29,6 +29,7 @@ import { selectSelectedMascotId, } from '../../store/mascotSlice'; import { selectPersonaDescription, selectPersonaDisplayName } from '../../store/personaSlice'; +import { RecentCallsSection } from './RecentCallsSection'; type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string }; @@ -239,6 +240,15 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps) useEffect(() => { void refreshRecentCalls(); + // This inline form remounts the instant a call ends, but the core writes + // the call record asynchronously a few ms after the transcript arrives — + // so the mount-time fetch can race ahead of that write and miss the just- + // ended call. A couple of short delayed re-fetches reliably reflect it + // without the user having to reopen the tab. Cheap (a ~2ms RPC each). + const retries = [1200, 3000].map(delay => + setTimeout(() => void refreshRecentCalls(), delay) + ); + return () => retries.forEach(clearTimeout); }, [refreshRecentCalls]); const selectedLabel = t('skills.meetingBots.platforms.gmeet'); @@ -387,99 +397,3 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps) ); } -function RecentCallsSection({ - rows, - error, -}: { - rows: MeetCallRecord[] | null; - error: string | null; -}) { - const { t } = useT(); - return ( -
-
-

- {t('skills.meetingBots.recentCallsHeading')} - {rows && rows.length > 0 && ( - - ({rows.length}) - - )} -

-
- - {error && ( -

{error}

- )} - - {rows === null ? ( -

- {t('skills.meetingBots.recentCallsLoading')} -

- ) : rows.length === 0 ? ( -

- {t('skills.meetingBots.recentCallsEmpty')} -

- ) : ( - - )} -
- ); -} - -function RecentCallRow({ call }: { call: MeetCallRecord }) { - const meetingCode = (() => { - try { - const parsed = new URL(call.meet_url); - const tail = parsed.pathname.replace(/^\/+/, ''); - return tail || call.meet_url; - } catch { - return call.meet_url || '(unknown URL)'; - } - })(); - const duration = Math.max(0, Math.round(call.spoken_seconds + call.listened_seconds)); - return ( -
  • -
    - - {meetingCode} - - - {formatRelativeTime(call.started_at_ms)} - -
    -
    - - {call.turn_count} turn{call.turn_count === 1 ? '' : 's'} - - {duration}s on call -
    -
  • - ); -} - -function formatRelativeTime(ms: number): string { - if (!ms) return '—'; - const diff = Date.now() - ms; - if (diff < 0) return 'just now'; - const seconds = Math.floor(diff / 1000); - if (seconds < 60) return 'just now'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days === 1) return 'yesterday'; - if (days < 7) return `${days}d ago`; - try { - return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); - } catch { - return '—'; - } -} diff --git a/app/src/components/skills/RecentCallsSection.tsx b/app/src/components/skills/RecentCallsSection.tsx new file mode 100644 index 000000000..19a3915c0 --- /dev/null +++ b/app/src/components/skills/RecentCallsSection.tsx @@ -0,0 +1,118 @@ +import { useT } from '../../lib/i18n/I18nContext'; +import { type MeetCallRecord } from '../../services/meetCallService'; + +/** + * Recent-calls history shown under the meeting-bot join form. Renders the + * loading / empty / populated states and one row per completed call (meeting + * code, relative time, turn count, duration, owner, and participants). + * + * Extracted from `MeetingBotsCard` to keep that component within the repo's + * ~500-line file-size guideline. + */ +export function RecentCallsSection({ + rows, + error, +}: { + rows: MeetCallRecord[] | null; + error: string | null; +}) { + const { t } = useT(); + return ( +
    +
    +

    + {t('skills.meetingBots.recentCallsHeading')} + {rows && rows.length > 0 && ( + + ({rows.length}) + + )} +

    +
    + + {error &&

    {error}

    } + + {rows === null ? ( +

    + {t('skills.meetingBots.recentCallsLoading')} +

    + ) : rows.length === 0 ? ( +

    + {t('skills.meetingBots.recentCallsEmpty')} +

    + ) : ( + + )} +
    + ); +} + +function RecentCallRow({ call }: { call: MeetCallRecord }) { + const { t } = useT(); + const meetingCode = (() => { + try { + const parsed = new URL(call.meet_url); + const tail = parsed.pathname.replace(/^\/+/, ''); + return tail || call.meet_url; + } catch { + return call.meet_url || '(unknown URL)'; + } + })(); + const duration = Math.max(0, Math.round(call.spoken_seconds + call.listened_seconds)); + const owner = call.owner_display_name?.trim(); + const participants = (call.participants ?? []).map(p => p.trim()).filter(Boolean); + return ( +
  • +
    + + {meetingCode} + + + {formatRelativeTime(call.started_at_ms)} + +
    +
    + + {call.turn_count} turn{call.turn_count === 1 ? '' : 's'} + + {duration}s on call + {owner && ( + + {t('skills.meetingBots.recentCallAddedBy').replace('{name}', owner)} + + )} +
    + {participants.length > 0 && ( +
    + {t('skills.meetingBots.recentCallParticipants').replace('{names}', participants.join(', '))} +
    + )} +
  • + ); +} + +function formatRelativeTime(ms: number): string { + if (!ms) return '—'; + const diff = Date.now() - ms; + if (diff < 0) return 'just now'; + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days === 1) return 'yesterday'; + if (days < 7) return `${days}d ago`; + try { + return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } catch { + return '—'; + } +} diff --git a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx index 9bc663e0a..cf9a1dde8 100644 --- a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx +++ b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx @@ -348,6 +348,32 @@ describe('MeetingBotsCard — recent calls section', () => { expect(screen.getByText(/5 turns/i)).toBeInTheDocument(); }); + it('renders the owner and participant names on a call row', async () => { + listMock.mockResolvedValueOnce([ + makeCallRecord({ + owner_display_name: 'Shanu Goyanka', + participants: ['Shanu Goyanka', 'Alex Rivera'], + }), + ]); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText(/added by shanu goyanka/i)).toBeInTheDocument(); + }); + expect(screen.getByText(/with shanu goyanka, alex rivera/i)).toBeInTheDocument(); + }); + + it('omits the participants line when the record has none', async () => { + listMock.mockResolvedValueOnce([ + makeCallRecord({ owner_display_name: '', participants: [] }), + ]); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText(/3 turns/i)).toBeInTheDocument(); + }); + expect(screen.queryByText(/^with /i)).not.toBeInTheDocument(); + expect(screen.queryByText(/added by/i)).not.toBeInTheDocument(); + }); + it('shows the count badge when there is at least one record', async () => { listMock.mockResolvedValueOnce([makeCallRecord()]); renderWithProviders(); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index c7a14b316..2c14a4632 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4542,6 +4542,8 @@ const messages: TranslationMap = { 'skills.meetingBots.recentCallsHeading': 'المكالمات الأخيرة', 'skills.meetingBots.recentCallsEmpty': 'لا توجد مكالمات سابقة بعد — سيظهر سجل اجتماعاتك هنا.', 'skills.meetingBots.recentCallsLoading': 'جارٍ التحميل\u2026', + 'skills.meetingBots.recentCallAddedBy': 'أضافه {name}', + 'skills.meetingBots.recentCallParticipants': 'مع {names}', 'skills.meetingBots.liveBadge': 'مباشر', 'skills.meetingBots.liveTitle': 'في اجتماع', 'skills.meetingBots.liveStatusJoining': 'جارٍ الانضمام\u2026', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 6c4b02b75..f8746fd2e 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4634,6 +4634,8 @@ const messages: TranslationMap = { 'skills.meetingBots.recentCallsHeading': 'সাম্প্রতিক কল', 'skills.meetingBots.recentCallsEmpty': 'এখনও কোনো আগের কল নেই — আপনার মিটিং ইতিহাস এখানে দেখাবে।', 'skills.meetingBots.recentCallsLoading': 'লোড হচ্ছে\u2026', + 'skills.meetingBots.recentCallAddedBy': '{name} যোগ করেছেন', + 'skills.meetingBots.recentCallParticipants': '{names} এর সাথে', 'skills.meetingBots.liveBadge': 'লাইভ', 'skills.meetingBots.liveTitle': 'মিটিংয়ে', 'skills.meetingBots.liveStatusJoining': 'যোগ দিচ্ছে\u2026', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 9b2adc08d..4064ec303 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4752,6 +4752,8 @@ const messages: TranslationMap = { 'skills.meetingBots.recentCallsEmpty': 'Noch keine früheren Anrufe — Ihr Meeting-Verlauf erscheint hier.', 'skills.meetingBots.recentCallsLoading': 'Laden\u2026', + 'skills.meetingBots.recentCallAddedBy': 'Hinzugefügt von {name}', + 'skills.meetingBots.recentCallParticipants': 'Mit {names}', 'skills.meetingBots.liveBadge': 'Live', 'skills.meetingBots.liveTitle': 'Im Meeting', 'skills.meetingBots.liveStatusJoining': 'Beitritt\u2026', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index cb382ecad..783e335c8 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -5239,6 +5239,8 @@ const en: TranslationMap = { 'skills.meetingBots.recentCallsEmpty': 'No previous calls yet — your meeting history will appear here.', 'skills.meetingBots.recentCallsLoading': 'Loading\u2026', + 'skills.meetingBots.recentCallAddedBy': 'Added by {name}', + 'skills.meetingBots.recentCallParticipants': 'With {names}', 'skills.meetingBots.liveBadge': 'Live', 'skills.meetingBots.liveTitle': 'In Meeting', 'skills.meetingBots.liveStatusJoining': 'Joining\u2026', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 9672c06ea..f9d94e3c3 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4723,6 +4723,8 @@ const messages: TranslationMap = { 'skills.meetingBots.recentCallsEmpty': 'Aún no hay llamadas anteriores — tu historial de reuniones aparecerá aquí.', 'skills.meetingBots.recentCallsLoading': 'Cargando\u2026', + 'skills.meetingBots.recentCallAddedBy': 'Añadido por {name}', + 'skills.meetingBots.recentCallParticipants': 'Con {names}', 'skills.meetingBots.liveBadge': 'En vivo', 'skills.meetingBots.liveTitle': 'En reunión', 'skills.meetingBots.liveStatusJoining': 'Uniéndose\u2026', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 2a5694bd2..8af1313f9 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4743,6 +4743,8 @@ const messages: TranslationMap = { 'skills.meetingBots.recentCallsEmpty': 'Pas encore d\u2019appels précédents — votre historique de réunions apparaîtra ici.', 'skills.meetingBots.recentCallsLoading': 'Chargement\u2026', + 'skills.meetingBots.recentCallAddedBy': 'Ajouté par {name}', + 'skills.meetingBots.recentCallParticipants': 'Avec {names}', 'skills.meetingBots.liveBadge': 'En direct', 'skills.meetingBots.liveTitle': 'En réunion', 'skills.meetingBots.liveStatusJoining': 'Connexion\u2026', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index dc84edb96..000382bdf 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4638,6 +4638,8 @@ const messages: TranslationMap = { 'skills.meetingBots.recentCallsEmpty': 'अभी तक कोई पिछली कॉल नहीं — आपका मीटिंग इतिहास यहाँ दिखेगा।', 'skills.meetingBots.recentCallsLoading': 'लोड हो रहा है\u2026', + 'skills.meetingBots.recentCallAddedBy': '{name} द्वारा जोड़ा गया', + 'skills.meetingBots.recentCallParticipants': '{names} के साथ', 'skills.meetingBots.liveBadge': 'लाइव', 'skills.meetingBots.liveTitle': 'मीटिंग में', 'skills.meetingBots.liveStatusJoining': 'शामिल हो रहा है\u2026', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 13dbfc5d2..890b96aa7 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4651,6 +4651,8 @@ const messages: TranslationMap = { 'skills.meetingBots.recentCallsEmpty': 'Belum ada panggilan sebelumnya — riwayat rapat Anda akan muncul di sini.', 'skills.meetingBots.recentCallsLoading': 'Memuat\u2026', + 'skills.meetingBots.recentCallAddedBy': 'Ditambahkan oleh {name}', + 'skills.meetingBots.recentCallParticipants': 'Dengan {names}', 'skills.meetingBots.liveBadge': 'Langsung', 'skills.meetingBots.liveTitle': 'Dalam Rapat', 'skills.meetingBots.liveStatusJoining': 'Bergabung\u2026', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 54f02b4e6..3f3396fa0 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4712,6 +4712,8 @@ const messages: TranslationMap = { 'skills.meetingBots.recentCallsEmpty': 'Nessuna chiamata precedente — la cronologia delle riunioni apparirà qui.', 'skills.meetingBots.recentCallsLoading': 'Caricamento\u2026', + 'skills.meetingBots.recentCallAddedBy': 'Aggiunto da {name}', + 'skills.meetingBots.recentCallParticipants': 'Con {names}', 'skills.meetingBots.liveBadge': 'In diretta', 'skills.meetingBots.liveTitle': 'In Riunione', 'skills.meetingBots.liveStatusJoining': 'Partecipando\u2026', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index c796c4fad..6ab975d28 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4591,6 +4591,8 @@ const messages: TranslationMap = { 'skills.meetingBots.recentCallsHeading': '최근 통화', 'skills.meetingBots.recentCallsEmpty': '이전 통화가 없습니다 — 회의 기록이 여기에 표시됩니다.', 'skills.meetingBots.recentCallsLoading': '로딩 중\u2026', + 'skills.meetingBots.recentCallAddedBy': '{name}님이 추가함', + 'skills.meetingBots.recentCallParticipants': '{names}님과 함께', 'skills.meetingBots.liveBadge': '라이브', 'skills.meetingBots.liveTitle': '회의 중', 'skills.meetingBots.liveStatusJoining': '참가 중\u2026', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index a80d0dedd..fdd2110bd 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4706,6 +4706,8 @@ const messages: TranslationMap = { 'skills.meetingBots.recentCallsEmpty': 'Brak poprzednich rozmów — historia spotkań pojawi się tutaj.', 'skills.meetingBots.recentCallsLoading': 'Ładowanie\u2026', + 'skills.meetingBots.recentCallAddedBy': 'Dodane przez {name}', + 'skills.meetingBots.recentCallParticipants': 'Uczestnicy: {names}', 'skills.meetingBots.liveBadge': 'Na żywo', 'skills.meetingBots.liveTitle': 'Na spotkaniu', 'skills.meetingBots.liveStatusJoining': 'Dołączanie\u2026', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 25f220691..b424a61bd 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4715,6 +4715,8 @@ const messages: TranslationMap = { 'skills.meetingBots.recentCallsEmpty': 'Nenhuma chamada anterior ainda — seu histórico de reuniões aparecerá aqui.', 'skills.meetingBots.recentCallsLoading': 'Carregando\u2026', + 'skills.meetingBots.recentCallAddedBy': 'Adicionado por {name}', + 'skills.meetingBots.recentCallParticipants': 'Com {names}', 'skills.meetingBots.liveBadge': 'Ao vivo', 'skills.meetingBots.liveTitle': 'Em reunião', 'skills.meetingBots.liveStatusJoining': 'Entrando\u2026', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index a186fdc1c..906e5ee67 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4679,6 +4679,8 @@ const messages: TranslationMap = { 'skills.meetingBots.recentCallsHeading': 'Недавние звонки', 'skills.meetingBots.recentCallsEmpty': 'Предыдущих звонков нет — история встреч появится здесь.', 'skills.meetingBots.recentCallsLoading': 'Загрузка\u2026', + 'skills.meetingBots.recentCallAddedBy': 'Добавил {name}', + 'skills.meetingBots.recentCallParticipants': 'С {names}', 'skills.meetingBots.liveBadge': 'Эфир', 'skills.meetingBots.liveTitle': 'На встрече', 'skills.meetingBots.liveStatusJoining': 'Подключение\u2026', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index f5138ce35..9a73e7483 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4403,6 +4403,8 @@ const messages: TranslationMap = { 'skills.meetingBots.recentCallsHeading': '最近通话', 'skills.meetingBots.recentCallsEmpty': '暂无历史通话记录 — 您的会议记录将显示在此处。', 'skills.meetingBots.recentCallsLoading': '加载中\u2026', + 'skills.meetingBots.recentCallAddedBy': '由 {name} 添加', + 'skills.meetingBots.recentCallParticipants': '与 {names}', 'skills.meetingBots.liveBadge': '直播', 'skills.meetingBots.liveTitle': '会议中', 'skills.meetingBots.liveStatusJoining': '正在加入\u2026', diff --git a/app/src/services/meetCallService.ts b/app/src/services/meetCallService.ts index 8a2262c36..37ea4ed90 100644 --- a/app/src/services/meetCallService.ts +++ b/app/src/services/meetCallService.ts @@ -128,6 +128,12 @@ export interface MeetCallRecord { listened_seconds: number; spoken_seconds: number; turn_count: number; + /** + * Distinct human participant display names mined from the transcript + * (backend-meet flow). Older records and local meet-agent calls omit this, + * so it is optional and defaults to an empty list at the UI. + */ + participants?: string[]; } interface CoreListCallsResponse { diff --git a/src/openhuman/agent_meetings/bus.rs b/src/openhuman/agent_meetings/bus.rs index 8c799333d..f93ad8108 100644 --- a/src/openhuman/agent_meetings/bus.rs +++ b/src/openhuman/agent_meetings/bus.rs @@ -61,6 +61,18 @@ impl EventHandler for MeetingEventSubscriber { "{LOG_PREFIX} transcript received — creating meeting thread" ); + // Record a recent-calls entry (meet id, duration, owner, + // participants) so the meeting-bots panel shows call history. + // Done first (before the heavier thread-creation path) so the + // record is on disk by the time the panel refetches at call-end. + // Best-effort: never blocks; logs on failure internally. + super::recent_calls::record_backend_call( + turns, + *duration_ms, + correlation_id.as_deref(), + ) + .await; + // Create the meeting thread with transcript. if let Err(e) = create_meeting_thread_with_transcript( turns, diff --git a/src/openhuman/agent_meetings/mod.rs b/src/openhuman/agent_meetings/mod.rs index 99e2f7c6c..4c332d865 100644 --- a/src/openhuman/agent_meetings/mod.rs +++ b/src/openhuman/agent_meetings/mod.rs @@ -18,6 +18,7 @@ pub mod bus; pub mod calendar; pub mod in_call; pub mod ops; +pub mod recent_calls; pub mod schemas; pub mod store; pub mod types; diff --git a/src/openhuman/agent_meetings/ops.rs b/src/openhuman/agent_meetings/ops.rs index 8b08c03d8..97c458dda 100644 --- a/src/openhuman/agent_meetings/ops.rs +++ b/src/openhuman/agent_meetings/ops.rs @@ -327,6 +327,26 @@ pub async fn handle_join(params: Map) -> Result { .await .map_err(|e| format!("[agent_meetings] emit failed: {e}"))?; + // Snapshot join context so the post-call recent-calls record can show who + // launched the bot, into which meeting. Keyed by correlation_id; consumed + // when the `BackendMeetTranscript` event arrives at call-end. No-op when + // the caller didn't supply a correlation_id. + super::recent_calls::remember_join( + req.correlation_id.as_deref(), + super::recent_calls::JoinMeta { + meet_url: normalized_url.to_string(), + // "Your Name in This Meeting" — the human who launched the bot and + // whom it answers to. This is the owner shown in the recent-calls list. + owner_display_name: req.respond_to_participant.clone().unwrap_or_default(), + // The bot's tile name in the meeting (persona display name). + bot_display_name: display_name.clone(), + started_at_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + }, + ); + // Active mode (listen_only = false, the modal's "respond when addressed" // toggle) enables in-call agency for just this meeting, so the toggle // "just works" without flipping the global config. Passive joins leave diff --git a/src/openhuman/agent_meetings/recent_calls.rs b/src/openhuman/agent_meetings/recent_calls.rs new file mode 100644 index 000000000..902ba1506 --- /dev/null +++ b/src/openhuman/agent_meetings/recent_calls.rs @@ -0,0 +1,358 @@ +//! Recent-calls recording for the **backend meet** flow. +//! +//! The "Send OpenHuman to a meeting" card drives the backend bot via +//! `agent_meetings_join` → `bot:join`, and the call ends with a +//! `BackendMeetTranscript` event carrying only `turns` / `duration_ms` / +//! `correlation_id`. None of the join context (who launched the bot, which +//! URL, the bot's display name) survives to call-end on its own. +//! +//! To give the Recent-calls panel real detail we: +//! 1. snapshot the join inputs in an in-memory registry keyed by +//! `correlation_id` at [`remember_join`] time, and +//! 2. at transcript time [`record_backend_call`] looks that snapshot back +//! up, mines the participant names out of the transcript, and appends a +//! [`MeetCallRecord`] to the shared recent-calls store the UI reads. +//! +//! The registry is intentionally in-memory: a call lives for minutes and the +//! snapshot is only needed for that window. If the app restarts mid-call we +//! simply fall back to a leaner record (duration + participants, no owner/URL) +//! rather than failing — recording is best-effort and never blocks the call. + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use crate::core::event_bus::BackendMeetTurn; +use crate::openhuman::meet_agent::store::{self, MeetCallRecord}; + +const LOG_PREFIX: &str = "[agent_meetings::recent_calls]"; + +/// Join-time context snapshotted so the call record can be enriched at +/// transcript time. Keyed by `correlation_id` in the pending registry. +#[derive(Debug, Clone)] +pub struct JoinMeta { + pub meet_url: String, + /// Display name the user gave themselves in the call ("who added the bot"). + pub owner_display_name: String, + /// The bot's tile name in the meeting. + pub bot_display_name: String, + pub started_at_ms: u64, +} + +/// Evict a pending join this long after its `started_at_ms` if no transcript +/// ever claimed it (failed join, crash, bot never admitted, …). Generous — +/// a real call's transcript lands within minutes — but bounded so abandoned +/// joins can't accumulate in a long-lived process. +const PENDING_JOIN_TTL_MS: u64 = 6 * 60 * 60 * 1000; // 6h + +/// Backstop hard cap on retained pending joins, independent of the TTL, so +/// pathological churn within the window still can't grow without bound. +/// Oldest entries are evicted first. +const MAX_PENDING_JOINS: usize = 256; + +fn registry() -> &'static Mutex> { + static REG: OnceLock>> = OnceLock::new(); + REG.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Snapshot join context for `correlation_id`. No-op when the id is absent +/// (we can't key the later lookup without it). Opportunistically prunes stale +/// entries on each insert so a transcript that never arrives can't leak. +pub fn remember_join(correlation_id: Option<&str>, meta: JoinMeta) { + let Some(cid) = correlation_id.map(str::trim).filter(|c| !c.is_empty()) else { + return; + }; + let mut map = registry().lock().unwrap(); + map.insert(cid.to_string(), meta); + prune_stale(&mut map, now_ms()); + log::debug!( + "{LOG_PREFIX} remembered join correlation_id={cid} pending={}", + map.len() + ); +} + +/// Drop entries past their TTL, then enforce `MAX_PENDING_JOINS` by evicting +/// the oldest. Pure over its inputs (takes `now_ms`) so it is unit-testable. +fn prune_stale(map: &mut HashMap, now_ms: u64) { + map.retain(|_, m| now_ms.saturating_sub(m.started_at_ms) <= PENDING_JOIN_TTL_MS); + if map.len() > MAX_PENDING_JOINS { + let mut by_age: Vec<(String, u64)> = map + .iter() + .map(|(k, m)| (k.clone(), m.started_at_ms)) + .collect(); + by_age.sort_by_key(|(_, ts)| *ts); + for (k, _) in by_age.into_iter().take(map.len() - MAX_PENDING_JOINS) { + map.remove(&k); + } + } +} + +/// Take (and remove) the snapshot for `correlation_id`, if any. +fn take_join(correlation_id: Option<&str>) -> Option { + let cid = correlation_id.map(str::trim).filter(|c| !c.is_empty())?; + registry().lock().unwrap().remove(cid) +} + +/// Build and persist a [`MeetCallRecord`] for a finished backend-meet call. +/// +/// Best-effort: any failure is logged and swallowed — the call is already +/// over and the UI degrades to "no record" rather than erroring. +pub async fn record_backend_call( + turns: &[BackendMeetTurn], + duration_ms: u64, + correlation_id: Option<&str>, +) { + let meta = take_join(correlation_id); + let record = build_record(meta.as_ref(), turns, duration_ms, correlation_id, now_ms()); + match store::append_record(&record).await { + Ok(()) => log::info!( + "{LOG_PREFIX} recorded call request_id={} participants={} duration_s={:.0}", + record.request_id, + record.participants.len(), + record.listened_seconds + ), + Err(e) => log::warn!("{LOG_PREFIX} append_record failed: {e}"), + } +} + +/// Map a finished call's inputs to a [`MeetCallRecord`]. Pure (takes `now_ms`, +/// no I/O) so the field-mapping and fallback logic is unit-testable without a +/// store or a workspace. +fn build_record( + meta: Option<&JoinMeta>, + turns: &[BackendMeetTurn], + duration_ms: u64, + correlation_id: Option<&str>, + now_ms: u64, +) -> MeetCallRecord { + // Prefer the snapshotted start; otherwise derive it from the duration so + // the row still sorts roughly correctly in the newest-first list. + let started_at_ms = meta + .map(|m| m.started_at_ms) + .unwrap_or_else(|| now_ms.saturating_sub(duration_ms)); + MeetCallRecord { + request_id: correlation_id + .map(str::trim) + .filter(|c| !c.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("backend-{started_at_ms}")), + meet_url: meta.map(|m| m.meet_url.clone()).unwrap_or_default(), + bot_display_name: meta.map(|m| m.bot_display_name.clone()).unwrap_or_default(), + owner_display_name: meta + .map(|m| m.owner_display_name.clone()) + .unwrap_or_default(), + started_at_ms, + ended_at_ms: now_ms, + // The backend flow reports a single wall-clock duration rather than + // split listen/speak meters; surface it as "listened" so the existing + // UI ("Ns on call" = listened + spoken) shows the real call length. + listened_seconds: (duration_ms as f32) / 1000.0, + spoken_seconds: 0.0, + turn_count: turns.len() as u32, + participants: extract_participants(turns), + } +} + +/// Extract distinct human participant names from a transcript. +/// +/// Backend transcript lines look like `[00:51] [Shanu Goyanka] your time` or +/// `[00:00] [System] Tiny joined the meeting`. The speaker is the first +/// bracketed token that is **not** a `MM:SS` timestamp. We skip: +/// - assistant turns (that's the bot, not a participant), +/// - the synthetic `System` speaker (join/leave/presence noise), +/// - blank/duplicate names (first-seen order preserved). +fn extract_participants(turns: &[BackendMeetTurn]) -> Vec { + let mut seen = Vec::new(); + for turn in turns { + if turn.role.eq_ignore_ascii_case("assistant") { + continue; + } + let Some(name) = speaker_name(&turn.content) else { + continue; + }; + if name.eq_ignore_ascii_case("system") { + continue; + } + if !seen.iter().any(|n: &String| n == &name) { + seen.push(name); + } + } + seen +} + +/// Pull the speaker name from a single transcript line: the first +/// `[...]` group whose contents are not a clock timestamp. +fn speaker_name(content: &str) -> Option { + let mut rest = content.trim_start(); + while let Some(stripped) = rest.strip_prefix('[') { + let close = stripped.find(']')?; + let inner = stripped[..close].trim(); + rest = stripped[close + 1..].trim_start(); + if inner.is_empty() || is_timestamp(inner) { + continue; + } + return Some(inner.to_string()); + } + None +} + +/// True for `M:SS` / `MM:SS` / `H:MM:SS` clock stamps. +fn is_timestamp(s: &str) -> bool { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() < 2 || parts.len() > 3 { + return false; + } + parts + .iter() + .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn turn(role: &str, content: &str) -> BackendMeetTurn { + BackendMeetTurn { + role: role.to_string(), + content: content.to_string(), + } + } + + #[test] + fn speaker_name_skips_timestamp_and_takes_name() { + assert_eq!( + speaker_name("[00:51] [Shanu Goyanka] your time").as_deref(), + Some("Shanu Goyanka") + ); + assert_eq!( + speaker_name("[System] Tiny joined").as_deref(), + Some("System") + ); + // No bracketed speaker → none. + assert_eq!(speaker_name("just text"), None); + assert_eq!(speaker_name("[12:00]"), None); + } + + #[test] + fn is_timestamp_matches_clock_only() { + assert!(is_timestamp("0:00")); + assert!(is_timestamp("00:51")); + assert!(is_timestamp("1:02:03")); + assert!(!is_timestamp("Shanu")); + assert!(!is_timestamp("12")); + assert!(!is_timestamp("a:bb")); + } + + #[test] + fn extract_participants_dedups_and_excludes_system_and_bot() { + let turns = vec![ + turn("user", "[00:00] [System] Tiny joined the meeting"), + turn("user", "[00:51] [Shanu Goyanka] your time"), + turn("assistant", "[00:55] [Tiny] On it."), + turn("user", "[02:09] [Shanu Goyanka] hey hello"), + turn("user", "[02:20] [Alex Rivera] sounds good"), + ]; + let names = extract_participants(&turns); + assert_eq!(names, vec!["Shanu Goyanka", "Alex Rivera"]); + } + + #[test] + fn extract_participants_empty_when_no_speakers() { + let turns = vec![turn("user", "no brackets here"), turn("assistant", "hi")]; + assert!(extract_participants(&turns).is_empty()); + } + + fn meta(started_at_ms: u64) -> JoinMeta { + JoinMeta { + meet_url: "https://meet.google.com/abc-defg-hij".into(), + owner_display_name: "Shanu".into(), + bot_display_name: "Tiny".into(), + started_at_ms, + } + } + + #[test] + fn remember_then_take_round_trips_and_consumes() { + let cid = "corr-rc-test-1"; + // Fresh timestamp so the opportunistic prune in remember_join keeps it. + remember_join(Some(cid), meta(now_ms())); + let got = take_join(Some(cid)).expect("present"); + assert_eq!(got.owner_display_name, "Shanu"); + // Consumed — a second take is empty. + assert!(take_join(Some(cid)).is_none()); + } + + #[test] + fn prune_stale_evicts_expired_keeps_fresh() { + let now = 10 * PENDING_JOIN_TTL_MS; + let mut map = HashMap::new(); + map.insert("fresh".to_string(), meta(now)); // age 0 + map.insert("old".to_string(), meta(now - PENDING_JOIN_TTL_MS - 1)); // just past TTL + prune_stale(&mut map, now); + assert!(map.contains_key("fresh")); + assert!(!map.contains_key("old")); + } + + #[test] + fn prune_stale_enforces_size_cap_evicting_oldest() { + let now = 10 * PENDING_JOIN_TTL_MS; + let mut map = HashMap::new(); + // All within TTL, but more than the cap — oldest must be evicted. + for i in 0..(MAX_PENDING_JOINS + 5) as u64 { + map.insert(format!("c{i}"), meta(now - i)); // c0 newest … higher i older + } + prune_stale(&mut map, now); + assert_eq!(map.len(), MAX_PENDING_JOINS); + assert!(map.contains_key("c0")); // newest kept + assert!(!map.contains_key(&format!("c{}", MAX_PENDING_JOINS + 4))); // oldest evicted + } + + #[test] + fn build_record_uses_join_meta_when_present() { + let turns = vec![turn("user", "[00:10] [Shanu Goyanka] hi")]; + let rec = build_record(Some(&meta(1000)), &turns, 30_000, Some("corr-42"), 50_000); + assert_eq!(rec.request_id, "corr-42"); + assert_eq!(rec.meet_url, "https://meet.google.com/abc-defg-hij"); + assert_eq!(rec.owner_display_name, "Shanu"); + assert_eq!(rec.bot_display_name, "Tiny"); + assert_eq!(rec.started_at_ms, 1000); // from meta, not derived + assert_eq!(rec.ended_at_ms, 50_000); + assert_eq!(rec.listened_seconds, 30.0); + assert_eq!(rec.turn_count, 1); + assert_eq!(rec.participants, vec!["Shanu Goyanka"]); + } + + #[test] + fn build_record_falls_back_without_meta() { + let turns = vec![turn("user", "[00:00] [System] joined")]; + let rec = build_record(None, &turns, 8_000, None, 100_000); + // No correlation id → synthesised request_id from the derived start. + assert_eq!(rec.started_at_ms, 92_000); // now - duration + assert_eq!(rec.request_id, "backend-92000"); + assert!(rec.meet_url.is_empty()); + assert!(rec.owner_display_name.is_empty()); + // Only a System turn → no human participants. + assert!(rec.participants.is_empty()); + } + + #[test] + fn remember_join_noop_without_correlation_id() { + remember_join( + None, + JoinMeta { + meet_url: "x".into(), + owner_display_name: "y".into(), + bot_display_name: "z".into(), + started_at_ms: 0, + }, + ); + assert!(take_join(None).is_none()); + assert!(take_join(Some(" ")).is_none()); + } +} diff --git a/src/openhuman/meet_agent/rpc.rs b/src/openhuman/meet_agent/rpc.rs index 25442f652..efdb6b88d 100644 --- a/src/openhuman/meet_agent/rpc.rs +++ b/src/openhuman/meet_agent/rpc.rs @@ -232,6 +232,10 @@ pub async fn handle_stop_session(params: Map) -> Result, } /// Hard cap on the rows returned from `read_recent`. The UI shows ~20 @@ -177,6 +184,7 @@ mod tests { listened_seconds: 12.5, spoken_seconds: 4.2, turn_count: 3, + participants: vec!["Alice".into(), "Bob".into()], } }