mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(meetings): populate Recent calls for backend meet flow (owner, duration, participants) (#3710)
This commit is contained in:
@@ -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 (
|
||||
<section
|
||||
aria-label={t('skills.meetingBots.recentCallsAriaLabel')}
|
||||
className="mt-4 border-t border-stone-200 dark:border-neutral-800 pt-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('skills.meetingBots.recentCallsHeading')}
|
||||
{rows && rows.length > 0 && (
|
||||
<span className="ml-1 text-stone-400 dark:text-neutral-500 normal-case font-normal">
|
||||
({rows.length})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-[11px] text-coral-600 dark:text-coral-400">{error}</p>
|
||||
)}
|
||||
|
||||
{rows === null ? (
|
||||
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.meetingBots.recentCallsLoading')}
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.meetingBots.recentCallsEmpty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-2 max-h-48 space-y-1 overflow-y-auto pr-1">
|
||||
{rows.map(call => (
|
||||
<RecentCallRow key={call.request_id} call={call} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<li className="rounded-lg px-2 py-1.5 text-[11px] text-stone-700 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/40">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-stone-800 dark:text-neutral-200">
|
||||
{meetingCode}
|
||||
</span>
|
||||
<span className="shrink-0 text-stone-400 dark:text-neutral-500">
|
||||
{formatRelativeTime(call.started_at_ms)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-stone-500 dark:text-neutral-400">
|
||||
<span>
|
||||
{call.turn_count} turn{call.turn_count === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span>{duration}s on call</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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 '—';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<section
|
||||
aria-label={t('skills.meetingBots.recentCallsAriaLabel')}
|
||||
className="mt-4 border-t border-stone-200 dark:border-neutral-800 pt-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('skills.meetingBots.recentCallsHeading')}
|
||||
{rows && rows.length > 0 && (
|
||||
<span className="ml-1 text-stone-400 dark:text-neutral-500 normal-case font-normal">
|
||||
({rows.length})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-2 text-[11px] text-coral-600 dark:text-coral-400">{error}</p>}
|
||||
|
||||
{rows === null ? (
|
||||
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.meetingBots.recentCallsLoading')}
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.meetingBots.recentCallsEmpty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-2 max-h-48 space-y-1 overflow-y-auto pr-1">
|
||||
{rows.map(call => (
|
||||
<RecentCallRow key={call.request_id} call={call} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<li className="rounded-lg px-2 py-1.5 text-[11px] text-stone-700 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/40">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-stone-800 dark:text-neutral-200">
|
||||
{meetingCode}
|
||||
</span>
|
||||
<span className="shrink-0 text-stone-400 dark:text-neutral-500">
|
||||
{formatRelativeTime(call.started_at_ms)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-stone-500 dark:text-neutral-400">
|
||||
<span>
|
||||
{call.turn_count} turn{call.turn_count === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span>{duration}s on call</span>
|
||||
{owner && (
|
||||
<span className="truncate">
|
||||
{t('skills.meetingBots.recentCallAddedBy').replace('{name}', owner)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{participants.length > 0 && (
|
||||
<div className="mt-0.5 truncate text-[10px] text-stone-500 dark:text-neutral-400">
|
||||
{t('skills.meetingBots.recentCallParticipants').replace('{names}', participants.join(', '))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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 '—';
|
||||
}
|
||||
}
|
||||
@@ -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(<MeetingBotsCard />);
|
||||
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(<MeetingBotsCard />);
|
||||
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(<MeetingBotsCard />);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -327,6 +327,26 @@ pub async fn handle_join(params: Map<String, Value>) -> Result<Value, String> {
|
||||
.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
|
||||
|
||||
@@ -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<HashMap<String, JoinMeta>> {
|
||||
static REG: OnceLock<Mutex<HashMap<String, JoinMeta>>> = 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<String, JoinMeta>, 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<JoinMeta> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,10 @@ pub async fn handle_stop_session(params: Map<String, Value>) -> Result<Value, St
|
||||
listened_seconds: session.listened_seconds(),
|
||||
spoken_seconds: session.spoken_seconds(),
|
||||
turn_count: session.turn_count,
|
||||
// Local meet-agent calls expose no per-speaker transcript here,
|
||||
// so we leave participants empty. The backend-meet flow
|
||||
// (agent_meetings) populates this from the transcript.
|
||||
participants: Vec::new(),
|
||||
};
|
||||
if let Err(err) = store::append_record(&record).await {
|
||||
log::warn!(
|
||||
|
||||
@@ -62,6 +62,13 @@ pub struct MeetCallRecord {
|
||||
pub spoken_seconds: f32,
|
||||
/// Completed agent turns during the call.
|
||||
pub turn_count: u32,
|
||||
/// Distinct human participant display names observed in the
|
||||
/// transcript (excludes the bot and system/presence lines). Empty
|
||||
/// for the local meet-agent flow, which has no transcript to mine.
|
||||
/// `#[serde(default)]` keeps older JSONL lines (written before this
|
||||
/// field existed) parseable.
|
||||
#[serde(default)]
|
||||
pub participants: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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()],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user