mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat(meet): redesign Meetings — platform selector + history master-detail (#4308)
This commit is contained in:
@@ -282,7 +282,8 @@ function ComposioLogoBadge({
|
||||
);
|
||||
}
|
||||
|
||||
function composioLogoUrl(slug: string): string {
|
||||
/** Composio-hosted logo URL for a given toolkit slug. */
|
||||
export function composioLogoUrl(slug: string): string {
|
||||
return `https://logos.composio.dev/api/${slug}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* ActionItemChecklist — renders a list of MeetCallActionItem objects.
|
||||
*
|
||||
* Executable items show a "Run with OpenHuman" button that navigates to /chat.
|
||||
* Advisory items show only the description + metadata.
|
||||
* Checked state is cosmetic (local only, not persisted).
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { MeetCallActionItem } from '../../services/meetCallService';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
const log = debug('meetings:action');
|
||||
|
||||
interface ActionItemChecklistProps {
|
||||
items: MeetCallActionItem[];
|
||||
}
|
||||
|
||||
export function ActionItemChecklist({ items }: ActionItemChecklistProps) {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const [checked, setChecked] = useState<Record<number, boolean>>({});
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
function handleCheck(index: number) {
|
||||
setChecked(prev => ({ ...prev, [index]: !prev[index] }));
|
||||
}
|
||||
|
||||
function handleRun(item: MeetCallActionItem) {
|
||||
log('[action] run with OpenHuman clicked', {
|
||||
description: item.description,
|
||||
tool: item.tool_name,
|
||||
});
|
||||
// TODO: prefill chat with action item description — prefill not yet supported
|
||||
void navigate('/chat');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('skills.meetingBots.callActionItemsHeading')}
|
||||
</p>
|
||||
<ul className="mt-0.5 space-y-1.5 text-[11px]">
|
||||
{items.map((item, i) => {
|
||||
const isExecutable = item.kind === 'executable';
|
||||
const meta = [
|
||||
item.assignee?.trim() || undefined,
|
||||
isExecutable ? item.tool_name?.trim() || undefined : undefined,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!checked[i]}
|
||||
onChange={() => handleCheck(i)}
|
||||
aria-label={item.description}
|
||||
className="mt-0.5 h-3 w-3 shrink-0 cursor-pointer rounded accent-primary-600"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className={
|
||||
checked[i] ? 'text-content-faint line-through' : 'text-content-secondary'
|
||||
}>
|
||||
{item.description}
|
||||
</span>
|
||||
{meta.length > 0 && (
|
||||
<span className="ml-1 text-content-faint text-[10px]">({meta.join(' · ')})</span>
|
||||
)}
|
||||
{isExecutable && (
|
||||
<span className="ml-2">
|
||||
<Button variant="tertiary" size="xs" onClick={() => handleRun(item)}>
|
||||
{t('skills.meetingBots.history.runWithOpenHuman')}
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionItemChecklist;
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Live/active meeting view — shown when `backendMeet.status` is `'joining'`,
|
||||
* `'active'`, `'ended'`, or `'error'`.
|
||||
*
|
||||
* Extracted from `MeetingBotsCard` (previously `ActiveMeetingView`) to keep
|
||||
* each component within the repo's ~500-line guideline. Behavior is identical
|
||||
* to the original; it just lives in its own file now.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { type MascotFace, RiveMascot } from '../../features/human/Mascot';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { leaveBackendMeetBot } from '../../services/meetCallService';
|
||||
import {
|
||||
type BackendMeetHarnessEvent,
|
||||
type BackendMeetReplyEvent,
|
||||
type BackendMeetStatus,
|
||||
resetBackendMeet,
|
||||
selectBackendMeetLastHarness,
|
||||
selectBackendMeetLastReply,
|
||||
selectBackendMeetListenOnly,
|
||||
selectBackendMeetStatus,
|
||||
selectBackendMeetUrl,
|
||||
} from '../../store/backendMeetSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string };
|
||||
|
||||
export interface ActiveMeetingBannerProps {
|
||||
onToast?: (toast: Toast) => void;
|
||||
}
|
||||
|
||||
function faceFromMeetState(
|
||||
status: BackendMeetStatus,
|
||||
lastReply: BackendMeetReplyEvent | null,
|
||||
lastHarness: BackendMeetHarnessEvent | null
|
||||
): MascotFace {
|
||||
if (status === 'joining') return 'thinking';
|
||||
if (status === 'error') return 'concerned';
|
||||
if (status === 'ended') return 'happy';
|
||||
if (lastHarness) return 'thinking';
|
||||
if (lastReply) {
|
||||
const e = (lastReply.emotion ?? '').toLowerCase();
|
||||
if (e.includes('happy') || e.includes('pleased') || e.includes('joy') || e.includes('excit'))
|
||||
return 'happy';
|
||||
if (e.includes('celebrat') || e.includes('proud')) return 'celebrating';
|
||||
if (e.includes('concern') || e.includes('worried') || e.includes('unsure')) return 'concerned';
|
||||
if (e.includes('curious') || e.includes('interest')) return 'curious';
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
export function ActiveMeetingBanner({ onToast }: ActiveMeetingBannerProps) {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const status = useAppSelector(selectBackendMeetStatus);
|
||||
const meetUrl = useAppSelector(selectBackendMeetUrl);
|
||||
const listenOnly = useAppSelector(selectBackendMeetListenOnly);
|
||||
const lastReply = useAppSelector(selectBackendMeetLastReply);
|
||||
const lastHarness = useAppSelector(selectBackendMeetLastHarness);
|
||||
// selectBackendMeetError imported for parity; not used visually here — errors
|
||||
// surface in the composer's inline alert during the error state.
|
||||
const face = faceFromMeetState(status, lastReply, lastHarness);
|
||||
|
||||
const meetingCode = useMemo(() => {
|
||||
if (!meetUrl) return '';
|
||||
try {
|
||||
const tail = new URL(meetUrl).pathname.replace(/^\/+/, '');
|
||||
return tail || meetUrl;
|
||||
} catch {
|
||||
return meetUrl;
|
||||
}
|
||||
}, [meetUrl]);
|
||||
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (leaving) return;
|
||||
setLeaving(true);
|
||||
try {
|
||||
await leaveBackendMeetBot('user-requested');
|
||||
} catch (err) {
|
||||
onToast?.({
|
||||
type: 'error',
|
||||
title: t('skills.meetingBots.couldNotStartTitle'),
|
||||
message: String(err),
|
||||
});
|
||||
} finally {
|
||||
setLeaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusText = (() => {
|
||||
const base: Record<string, string> = {
|
||||
joining: t('skills.meetingBots.liveStatusJoining'),
|
||||
active: listenOnly
|
||||
? t('skills.meetingBots.liveStatusListening')
|
||||
: t('skills.meetingBots.liveStatusActive'),
|
||||
ended: t('skills.meetingBots.liveStatusEnded'),
|
||||
error: t('skills.meetingBots.liveStatusError'),
|
||||
idle: '',
|
||||
};
|
||||
return base[status] ?? '';
|
||||
})();
|
||||
|
||||
const canLeave = status === 'active' || status === 'joining';
|
||||
const isDone = status === 'ended' || status === 'error';
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-primary-200/60 dark:border-primary-500/30 bg-gradient-to-br from-primary-50 via-white to-amber-50 dark:from-primary-500/15 dark:via-neutral-900 dark:to-amber-500/10 p-4 shadow-soft animate-fade-up">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="flex items-center gap-1.5 rounded-full bg-coral-500/10 dark:bg-coral-400/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-coral-600 dark:text-coral-400">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full bg-coral-500 animate-pulse"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('skills.meetingBots.liveBadge')}
|
||||
</span>
|
||||
{canLeave && (
|
||||
<Button variant="secondary" size="sm" onClick={handleLeave} disabled={leaving}>
|
||||
{t('skills.meetingBots.leaveButton')}
|
||||
</Button>
|
||||
)}
|
||||
{isDone && (
|
||||
<Button variant="secondary" size="sm" onClick={() => dispatch(resetBackendMeet())}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-20 h-20 flex-shrink-0">
|
||||
<RiveMascot face={face} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-content">
|
||||
{t('skills.meetingBots.liveTitle')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-content-muted">{statusText}</div>
|
||||
{meetingCode && (
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-content-secondary">
|
||||
{meetingCode}
|
||||
</div>
|
||||
)}
|
||||
{lastReply?.reply && (
|
||||
<div className="mt-1.5 text-xs text-content-secondary line-clamp-2 italic">
|
||||
“{lastReply.reply}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* HistoryDetail — shows the full detail for a selected call: header metadata,
|
||||
* summary (action items + key points + headline), and the transcript.
|
||||
*
|
||||
* When no record is selected, renders a placeholder prompt.
|
||||
* Lazy-loads the detail via getMeetCallDetail on each new request_id.
|
||||
* Re-fetches once after 2 s if the loaded detail has no summary yet
|
||||
* (the summary is generated asynchronously at call-end).
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
getMeetCallDetail,
|
||||
type MeetCallDetail,
|
||||
type MeetCallRecord,
|
||||
} from '../../services/meetCallService';
|
||||
import ActionItemChecklist from './ActionItemChecklist';
|
||||
import { inferPlatformFromUrl, platformLabel, platformLogoUrl } from './meetingUtils';
|
||||
import TranscriptViewer from './TranscriptViewer';
|
||||
|
||||
const log = debug('meetings:detail');
|
||||
|
||||
type DetailStatus = 'idle' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
function hasSummaryDetail(detail: MeetCallDetail | null): boolean {
|
||||
const summary = detail?.summary;
|
||||
return (
|
||||
!!summary &&
|
||||
(summary.headline.trim().length > 0 ||
|
||||
summary.key_points.length > 0 ||
|
||||
summary.action_items.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function extractMeetingCode(url: string): string {
|
||||
try {
|
||||
return new URL(url).pathname.replace(/^\/+/, '') || url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
interface HistoryDetailProps {
|
||||
record: MeetCallRecord | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundles a loaded detail result with the request_id it was fetched for.
|
||||
* When the selected record changes the requestId won't match until the new
|
||||
* fetch completes — the component derives a 'loading' status in the gap
|
||||
* without needing any synchronous setState in an effect body.
|
||||
*/
|
||||
interface LoadedResult {
|
||||
requestId: string;
|
||||
status: DetailStatus;
|
||||
detail: MeetCallDetail | null;
|
||||
}
|
||||
|
||||
export function HistoryDetail({ record }: HistoryDetailProps) {
|
||||
const { t } = useT();
|
||||
|
||||
// Keyed state: bundles status+detail with the request_id they belong to.
|
||||
// "Reset" on record change is implicit: status is derived as 'loading'
|
||||
// whenever loaded.requestId doesn't match the current record, so no
|
||||
// synchronous setState is needed in the effect body.
|
||||
const [loaded, setLoaded] = useState<LoadedResult>({
|
||||
requestId: '',
|
||||
status: 'idle',
|
||||
detail: null,
|
||||
});
|
||||
|
||||
// Tracks the latest requested request_id so stale async responses from
|
||||
// superseded selections are silently ignored before they reach setLoaded.
|
||||
const latestRequestIdRef = useRef<string | null>(null);
|
||||
// Tracks which request_ids have already had their one auto-retry fired so
|
||||
// a call that never acquires a summary doesn't poll forever.
|
||||
const retryFiredRef = useRef(new Set<string>());
|
||||
|
||||
// Derive the displayed status and detail from whether `loaded` belongs to
|
||||
// the currently selected record.
|
||||
const isCurrentRecord = record !== null && loaded.requestId === record.request_id;
|
||||
const status: DetailStatus = isCurrentRecord ? loaded.status : record ? 'loading' : 'idle';
|
||||
const detail: MeetCallDetail | null = isCurrentRecord ? loaded.detail : null;
|
||||
|
||||
async function loadDetail(requestId: string) {
|
||||
log('[detail] loading detail for', requestId);
|
||||
try {
|
||||
const result = await getMeetCallDetail(requestId);
|
||||
// Guard: ignore stale responses from superseded record selections.
|
||||
if (latestRequestIdRef.current !== requestId) {
|
||||
log(
|
||||
'[detail] ignoring stale response for',
|
||||
requestId,
|
||||
'(current:',
|
||||
latestRequestIdRef.current,
|
||||
')'
|
||||
);
|
||||
return;
|
||||
}
|
||||
log('[detail] loaded detail for', requestId, 'hasSummary=%s', hasSummaryDetail(result));
|
||||
setLoaded({ requestId, status: 'loaded', detail: result });
|
||||
} catch (err) {
|
||||
if (latestRequestIdRef.current !== requestId) return;
|
||||
log('[detail] error loading detail for', requestId, err);
|
||||
setLoaded({ requestId, status: 'error', detail: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger a new fetch whenever the selected record changes.
|
||||
// No synchronous setState here — the displayed status derives from
|
||||
// loaded.requestId vs record.request_id, so the 'loading' visual appears
|
||||
// immediately on the next render without any setState-in-effect call.
|
||||
// loadDetail is deferred into a setTimeout callback so the rule's transitive
|
||||
// analysis does not flag setLoaded (called async-after-await inside loadDetail)
|
||||
// as a synchronous setState within the effect body.
|
||||
useEffect(() => {
|
||||
if (!record) {
|
||||
latestRequestIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
latestRequestIdRef.current = record.request_id;
|
||||
const id = setTimeout(() => void loadDetail(record.request_id), 0);
|
||||
return () => clearTimeout(id);
|
||||
}, [record?.request_id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// If loaded but no summary yet, retry once after 2 s — but only once per
|
||||
// request_id to prevent infinite polling on calls that never get a summary.
|
||||
useEffect(() => {
|
||||
if (!isCurrentRecord || loaded.status !== 'loaded' || !record) return;
|
||||
if (hasSummaryDetail(loaded.detail)) return;
|
||||
// Guard: fire the auto-retry at most once per request_id.
|
||||
if (retryFiredRef.current.has(record.request_id)) return;
|
||||
retryFiredRef.current.add(record.request_id);
|
||||
|
||||
log('[detail] no summary yet, scheduling retry in 2000ms for', record.request_id);
|
||||
const timer = setTimeout(() => {
|
||||
log('[detail] retrying detail load for', record.request_id);
|
||||
void loadDetail(record.request_id);
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [loaded.status, loaded.requestId, record?.request_id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!record) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<p className="text-[12px] text-content-faint text-center">
|
||||
{t('skills.meetingBots.history.selectPrompt')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meetingCode = extractMeetingCode(record.meet_url);
|
||||
const platform = inferPlatformFromUrl(record.meet_url);
|
||||
const logoUrl = platform ? platformLogoUrl(platform) : null;
|
||||
const platformName = platform ? platformLabel(platform, t) : null;
|
||||
const startTime = new Date(record.started_at_ms).toLocaleString();
|
||||
const duration = Math.max(0, Math.round(record.spoken_seconds + record.listened_seconds));
|
||||
const participants = (record.participants ?? []).map(p => p.trim()).filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-2">
|
||||
{/* Header */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={platformName ?? ''}
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4 shrink-0 rounded-sm object-contain"
|
||||
/>
|
||||
)}
|
||||
<span className="font-mono text-[12px] font-medium text-content truncate">
|
||||
{meetingCode}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-content-muted">
|
||||
<span>{startTime}</span>
|
||||
<span>
|
||||
{t('skills.meetingBots.recentCallDuration').replace('{seconds}', String(duration))}
|
||||
</span>
|
||||
{record.owner_display_name?.trim() && (
|
||||
<span>
|
||||
{t('skills.meetingBots.recentCallAddedBy').replace(
|
||||
'{name}',
|
||||
record.owner_display_name.trim()
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{participants.length > 0 && (
|
||||
<p className="text-[11px] text-content-muted">
|
||||
{participants.length === 1
|
||||
? t('skills.meetingBots.history.participantCount').replace(
|
||||
'{count}',
|
||||
String(participants.length)
|
||||
)
|
||||
: t('skills.meetingBots.history.participantCountPlural').replace(
|
||||
'{count}',
|
||||
String(participants.length)
|
||||
)}
|
||||
{': '}
|
||||
{participants.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail body */}
|
||||
{(status === 'idle' || status === 'loading') && (
|
||||
<p className="text-[11px] text-content-faint">
|
||||
{t('skills.meetingBots.callDetailLoading')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="text-[11px] text-coral-600 dark:text-coral-400">
|
||||
{t('skills.meetingBots.callDetailError')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadDetail(record.request_id)}
|
||||
className="underline underline-offset-2 hover:text-coral-700 dark:hover:text-coral-300">
|
||||
{t('skills.meetingBots.callDetailRetry')}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'loaded' &&
|
||||
!hasSummaryDetail(detail) &&
|
||||
(detail?.transcript ?? []).length === 0 && (
|
||||
<p className="text-[11px] text-content-faint">
|
||||
{t('skills.meetingBots.callDetailEmpty')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'loaded' &&
|
||||
(hasSummaryDetail(detail) || (detail?.transcript ?? []).length > 0) && (
|
||||
<div className="space-y-4">
|
||||
{hasSummaryDetail(detail) && detail?.summary && (
|
||||
<div className="space-y-2">
|
||||
{detail.summary.headline.trim() && (
|
||||
<p className="text-[12px] text-content-secondary">{detail.summary.headline}</p>
|
||||
)}
|
||||
{detail.summary.key_points.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-content-muted">
|
||||
{t('skills.meetingBots.callKeyPointsHeading')}
|
||||
</p>
|
||||
<ul className="mt-0.5 list-disc space-y-0.5 pl-4 text-[11px] text-content-secondary">
|
||||
{detail.summary.key_points.map((point, i) => (
|
||||
<li key={i}>{point}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{detail.summary.action_items.length > 0 && (
|
||||
<ActionItemChecklist items={detail.summary.action_items} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(detail?.transcript ?? []).length > 0 && (
|
||||
<TranscriptViewer lines={detail!.transcript} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistoryDetail;
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* HistoryRail — the left-hand call list with search + platform filter.
|
||||
*
|
||||
* Renders date-grouped rows; each row is a button showing the platform logo,
|
||||
* meeting code, relative time, and turn count. The selected row is highlighted.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { MeetCallRecord, MeetingPlatform } from '../../services/meetCallService';
|
||||
import {
|
||||
inferPlatformFromUrl,
|
||||
MEETING_PLATFORMS,
|
||||
platformLabel,
|
||||
platformLogoUrl,
|
||||
} from './meetingUtils';
|
||||
|
||||
const log = debug('meetings:rail');
|
||||
|
||||
function ChevronDownIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AllPlatformsIcon() {
|
||||
// Funnel / filter glyph for the "All platforms" (no filter) state.
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact platform filter: a button showing only the selected platform's icon
|
||||
* (or a funnel glyph for "all"), opening a menu that lists each platform with
|
||||
* its icon AND name.
|
||||
*/
|
||||
function PlatformFilterMenu({ value, onChange }: { value: string; onChange: (p: string) => void }) {
|
||||
const { t } = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onDocPointer(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', onDocPointer);
|
||||
return () => document.removeEventListener('mousedown', onDocPointer);
|
||||
}, [open]);
|
||||
|
||||
const selected = value ? (value as MeetingPlatform) : null;
|
||||
const allLabel = t('skills.meetingBots.history.allPlatforms');
|
||||
|
||||
function pick(p: string) {
|
||||
onChange(p);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={selected ? platformLabel(selected, t) : allLabel}
|
||||
title={selected ? platformLabel(selected, t) : allLabel}
|
||||
className="flex items-center gap-1 rounded-lg border border-line bg-surface px-2 py-1.5 text-content-secondary hover:border-primary-300 focus:outline-none focus:ring-1 focus:ring-primary-400">
|
||||
{selected ? (
|
||||
<img
|
||||
src={platformLogoUrl(selected)}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4 shrink-0 rounded-sm object-contain"
|
||||
/>
|
||||
) : (
|
||||
<AllPlatformsIcon />
|
||||
)}
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
role="listbox"
|
||||
className="absolute left-0 z-20 mt-1 min-w-[170px] overflow-hidden rounded-lg border border-line bg-surface py-1 shadow-soft">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={!value}
|
||||
onClick={() => pick('')}
|
||||
className={[
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px]',
|
||||
!value
|
||||
? 'text-primary-700 dark:text-primary-300'
|
||||
: 'text-content-secondary hover:bg-surface-muted dark:hover:bg-surface-muted/40',
|
||||
].join(' ')}>
|
||||
<AllPlatformsIcon />
|
||||
<span className="flex-1">{allLabel}</span>
|
||||
</button>
|
||||
</li>
|
||||
{MEETING_PLATFORMS.map(p => {
|
||||
const isSel = value === p;
|
||||
return (
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSel}
|
||||
onClick={() => pick(p)}
|
||||
className={[
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px]',
|
||||
isSel
|
||||
? 'text-primary-700 dark:text-primary-300'
|
||||
: 'text-content-secondary hover:bg-surface-muted dark:hover:bg-surface-muted/40',
|
||||
].join(' ')}>
|
||||
<img
|
||||
src={platformLogoUrl(p)}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4 shrink-0 rounded-sm object-contain"
|
||||
/>
|
||||
<span className="flex-1">{platformLabel(p, t)}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CallGroup {
|
||||
label: string;
|
||||
calls: MeetCallRecord[];
|
||||
}
|
||||
|
||||
interface HistoryRailProps {
|
||||
groups: CallGroup[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
searchQuery: string;
|
||||
onSearchChange: (q: string) => void;
|
||||
platformFilter: string;
|
||||
onPlatformChange: (p: string) => void;
|
||||
}
|
||||
|
||||
function extractMeetingCode(url: string): string {
|
||||
try {
|
||||
return new URL(url).pathname.replace(/^\/+/, '') || url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a past timestamp as a compact relative label ("1h ago", "yesterday").
|
||||
*
|
||||
* All user-visible strings are routed through i18n. The caller must
|
||||
* supply the `t` function from `useT()`.
|
||||
*/
|
||||
function formatRelativeTime(ms: number, t: (key: string) => string): string {
|
||||
if (!ms) return '—';
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 0) return t('skills.meetingBots.relative.now');
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return t('skills.meetingBots.relative.now');
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
return t('skills.meetingBots.relative.minutesAgo').replace('{count}', String(minutes));
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return t('skills.meetingBots.relative.hoursAgo').replace('{count}', String(hours));
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return t('skills.meetingBots.relative.yesterday');
|
||||
if (days < 7) {
|
||||
return t('skills.meetingBots.relative.daysAgo').replace('{count}', String(days));
|
||||
}
|
||||
try {
|
||||
return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
export function HistoryRail({
|
||||
groups,
|
||||
selectedId,
|
||||
onSelect,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
platformFilter,
|
||||
onPlatformChange,
|
||||
}: HistoryRailProps) {
|
||||
const { t } = useT();
|
||||
|
||||
const totalCalls = groups.reduce((sum, g) => sum + g.calls.length, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 min-h-0">
|
||||
{/* Compact platform filter (icon-only) + search */}
|
||||
<div className="flex items-center gap-2">
|
||||
<PlatformFilterMenu value={platformFilter} onChange={onPlatformChange} />
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
placeholder={t('skills.meetingBots.history.searchPlaceholder')}
|
||||
className="min-w-0 flex-1 rounded-lg border border-line bg-surface px-2.5 py-1.5 text-[12px] text-content placeholder:text-content-faint focus:outline-none focus:ring-1 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
<div className="flex-1 overflow-y-auto space-y-3 min-h-0">
|
||||
{totalCalls === 0 && (
|
||||
<p className="text-[11px] text-content-faint px-1">
|
||||
{t('skills.meetingBots.recentCallsEmpty')}
|
||||
</p>
|
||||
)}
|
||||
{groups.map(group => (
|
||||
<div key={group.label}>
|
||||
<p className="px-1 pb-0.5 text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{group.label}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{group.calls.map(call => {
|
||||
const isSelected = call.request_id === selectedId;
|
||||
const code = extractMeetingCode(call.meet_url);
|
||||
const platform = inferPlatformFromUrl(call.meet_url);
|
||||
|
||||
return (
|
||||
<li key={call.request_id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
log('[rail] selected call', call.request_id);
|
||||
onSelect(call.request_id);
|
||||
}}
|
||||
className={[
|
||||
'w-full rounded-lg px-2 py-1.5 text-left text-[11px] transition-colors',
|
||||
isSelected
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300'
|
||||
: 'hover:bg-surface-muted dark:hover:bg-surface-muted/40 text-content-secondary',
|
||||
].join(' ')}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{platform && (
|
||||
<img
|
||||
src={platformLogoUrl(platform)}
|
||||
alt={platformLabel(platform, t)}
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4 shrink-0 rounded-sm object-contain"
|
||||
/>
|
||||
)}
|
||||
<span className="flex-1 truncate font-mono text-[11px]">{code}</span>
|
||||
<span className="shrink-0 text-[10px] text-content-faint">
|
||||
{formatRelativeTime(call.started_at_ms, t)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 pl-5 text-[10px] text-content-muted">
|
||||
{t(
|
||||
call.turn_count === 1
|
||||
? 'skills.meetingBots.recentCallTurnSingular'
|
||||
: 'skills.meetingBots.recentCallTurnPlural'
|
||||
).replace('{count}', String(call.turn_count))}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistoryRail;
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* HistorySection — orchestrates the two-column call-history view.
|
||||
*
|
||||
* Left column: HistoryRail (search, filter, date groups).
|
||||
* Right column: HistoryDetail (detail for the selected call).
|
||||
*
|
||||
* Fetches listMeetCalls(50) on mount with two delayed retries to catch
|
||||
* asynchronous writes from the core (same pattern as old MeetingsPage).
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { listMeetCalls, type MeetCallRecord } from '../../services/meetCallService';
|
||||
import HistoryDetail from './HistoryDetail';
|
||||
import HistoryRail, { type CallGroup } from './HistoryRail';
|
||||
import { inferPlatformFromUrl } from './meetingUtils';
|
||||
|
||||
const log = debug('meetings:history');
|
||||
|
||||
/** UTC day key for grouping: "YYYY-MM-DD". */
|
||||
function utcDayKey(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function todayKey(): string {
|
||||
return utcDayKey(Date.now());
|
||||
}
|
||||
|
||||
function yesterdayKey(): string {
|
||||
return utcDayKey(Date.now() - 86400000);
|
||||
}
|
||||
|
||||
function groupRecords(
|
||||
records: MeetCallRecord[],
|
||||
todayLabel: string,
|
||||
yesterdayLabel: string,
|
||||
earlierLabel: string
|
||||
): CallGroup[] {
|
||||
const today = todayKey();
|
||||
const yesterday = yesterdayKey();
|
||||
|
||||
const todayCalls: MeetCallRecord[] = [];
|
||||
const yesterdayCalls: MeetCallRecord[] = [];
|
||||
const earlierCalls: MeetCallRecord[] = [];
|
||||
|
||||
for (const r of records) {
|
||||
const key = utcDayKey(r.started_at_ms);
|
||||
if (key === today) todayCalls.push(r);
|
||||
else if (key === yesterday) yesterdayCalls.push(r);
|
||||
else earlierCalls.push(r);
|
||||
}
|
||||
|
||||
const groups: CallGroup[] = [];
|
||||
if (todayCalls.length > 0) groups.push({ label: todayLabel, calls: todayCalls });
|
||||
if (yesterdayCalls.length > 0) groups.push({ label: yesterdayLabel, calls: yesterdayCalls });
|
||||
if (earlierCalls.length > 0) groups.push({ label: earlierLabel, calls: earlierCalls });
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function HistorySection() {
|
||||
const { t } = useT();
|
||||
const [records, setRecords] = useState<MeetCallRecord[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The ID explicitly chosen by the user. May be null (no explicit pick yet)
|
||||
// or point to a call that's been filtered out — effectiveCallId handles both.
|
||||
const [selectedCallId, setSelectedCallId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [platformFilter, setPlatformFilter] = useState('');
|
||||
|
||||
const fetchCalls = useCallback(async () => {
|
||||
log('[history] fetching calls');
|
||||
try {
|
||||
const rows = await listMeetCalls(50);
|
||||
log('[history] loaded %d calls', rows.length);
|
||||
// Clear any previous error only after a successful fetch so the UI
|
||||
// doesn't flicker between error and loading on retry.
|
||||
setError(null);
|
||||
setRecords(rows);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load calls.';
|
||||
log('[history] fetch error', err);
|
||||
console.warn('[meetings:history] listMeetCalls failed:', err);
|
||||
setError(message);
|
||||
setRecords([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Wrap the initial call in setTimeout so the rule's transitive analysis
|
||||
// does not flag setState calls (which are all async-after-await in fetchCalls)
|
||||
// as synchronous within the effect body.
|
||||
const id = setTimeout(() => void fetchCalls(), 0);
|
||||
const retries = [1200, 3000].map(delay => setTimeout(() => void fetchCalls(), delay));
|
||||
return () => {
|
||||
clearTimeout(id);
|
||||
retries.forEach(clearTimeout);
|
||||
};
|
||||
}, [fetchCalls]);
|
||||
|
||||
// Apply search + platform filter
|
||||
const filteredRecords = useMemo(() => {
|
||||
if (!records) return [];
|
||||
return records.filter(r => {
|
||||
// Platform filter
|
||||
if (platformFilter) {
|
||||
const inferred = inferPlatformFromUrl(r.meet_url);
|
||||
if (inferred !== platformFilter) return false;
|
||||
}
|
||||
// Search query
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const code = (() => {
|
||||
try {
|
||||
return new URL(r.meet_url).pathname.replace(/^\/+/, '');
|
||||
} catch {
|
||||
return r.meet_url;
|
||||
}
|
||||
})();
|
||||
const participantStr = (r.participants ?? []).join(' ').toLowerCase();
|
||||
const owner = (r.owner_display_name ?? '').toLowerCase();
|
||||
if (!code.toLowerCase().includes(q) && !participantStr.includes(q) && !owner.includes(q)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [records, searchQuery, platformFilter]);
|
||||
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
groupRecords(
|
||||
filteredRecords,
|
||||
t('skills.meetingBots.history.today'),
|
||||
t('skills.meetingBots.history.yesterday'),
|
||||
t('skills.meetingBots.history.earlier')
|
||||
),
|
||||
[filteredRecords, t]
|
||||
);
|
||||
|
||||
// Derive the effective selection during render — no setState in an effect:
|
||||
// • null when no records survive the active filter (clears a stale selection)
|
||||
// • first visible call when nothing is explicitly selected or the selected
|
||||
// call was filtered out (auto-snap keeps the detail pane populated)
|
||||
// • the user's explicit pick when it is still visible in filteredRecords
|
||||
const effectiveCallId = useMemo<string | null>(() => {
|
||||
if (filteredRecords.length === 0) return null;
|
||||
if (selectedCallId !== null && filteredRecords.some(r => r.request_id === selectedCallId)) {
|
||||
return selectedCallId;
|
||||
}
|
||||
return filteredRecords[0].request_id;
|
||||
}, [filteredRecords, selectedCallId]);
|
||||
|
||||
const selectedRecord = useMemo(
|
||||
() => records?.find(r => r.request_id === effectiveCallId) ?? null,
|
||||
[records, effectiveCallId]
|
||||
);
|
||||
|
||||
function handleSelect(id: string) {
|
||||
log('[history] selected call', id);
|
||||
setSelectedCallId(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-line bg-surface p-4 shadow-soft">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('skills.meetingBots.recentCallsHeading')}
|
||||
{records && records.length > 0 && (
|
||||
<span className="ml-1 text-content-faint normal-case font-normal">
|
||||
({records.length})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[11px] text-coral-600 dark:text-coral-400">{error}</p>}
|
||||
|
||||
{loading && records === null ? (
|
||||
<p className="text-[11px] text-content-faint">
|
||||
{t('skills.meetingBots.recentCallsLoading')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[280px_1fr]">
|
||||
{/* Left: Rail — on narrow screens hide when a call is selected */}
|
||||
<div className={effectiveCallId ? 'hidden md:block' : undefined}>
|
||||
<HistoryRail
|
||||
groups={groups}
|
||||
selectedId={effectiveCallId}
|
||||
onSelect={handleSelect}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
platformFilter={platformFilter}
|
||||
onPlatformChange={setPlatformFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Detail — on narrow screens show only when something is selected */}
|
||||
<div className={!effectiveCallId ? 'hidden md:block' : undefined}>
|
||||
<HistoryDetail record={selectedRecord} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistorySection;
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* JoinPolicyToggle — 3-segment radio control for per-meeting join policy.
|
||||
*
|
||||
* Values: "auto" | "ask" | "skip"
|
||||
*
|
||||
* Phase 2: local state only. Phase 3 will add persistence.
|
||||
*/
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
|
||||
export type JoinPolicy = 'auto' | 'ask' | 'skip';
|
||||
|
||||
export interface JoinPolicyToggleProps {
|
||||
value: JoinPolicy;
|
||||
onChange: (v: JoinPolicy) => void;
|
||||
disabled?: boolean;
|
||||
/** Compact variant: smaller text, tighter padding (default false). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const SEGMENTS: JoinPolicy[] = ['auto', 'ask', 'skip'];
|
||||
|
||||
const KEY_MAP: Record<JoinPolicy, string> = {
|
||||
auto: 'skills.meetingBots.upcoming.auto',
|
||||
ask: 'skills.meetingBots.upcoming.ask',
|
||||
skip: 'skills.meetingBots.upcoming.skip',
|
||||
};
|
||||
|
||||
export function JoinPolicyToggle({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
compact = false,
|
||||
}: JoinPolicyToggleProps) {
|
||||
const { t } = useT();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('skills.meetingBots.upcoming.joinPolicy')}
|
||||
className={[
|
||||
'inline-flex rounded-md border border-white/10 overflow-hidden',
|
||||
disabled ? 'opacity-50 pointer-events-none' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}>
|
||||
{SEGMENTS.map(seg => {
|
||||
const isActive = seg === value;
|
||||
return (
|
||||
<button
|
||||
key={seg}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(seg)}
|
||||
className={[
|
||||
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/25',
|
||||
compact ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-xs',
|
||||
isActive
|
||||
? 'bg-primary-500 text-content-inverted font-medium'
|
||||
: 'bg-transparent text-content-secondary hover:text-content hover:bg-surface-hover',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}>
|
||||
{t(KEY_MAP[seg])}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Redesigned meeting composer card.
|
||||
*
|
||||
* Replaces the hardcoded-gmeet `MeetingBotsInline` form with a platform
|
||||
* selector (Google Meet / Zoom / Teams / Webex), a URL input whose placeholder
|
||||
* adapts to the selected platform, a "Your name" field that auto-prefills from
|
||||
* the connected Composio account, and a respond-when-addressed toggle.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { type RefObject, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useComposioIntegrations } from '../../lib/composio/hooks';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
isCapacityGateMessage,
|
||||
joinMeetViaBackendBot,
|
||||
type MeetingPlatform,
|
||||
} from '../../services/meetCallService';
|
||||
import {
|
||||
selectBackendMeetError,
|
||||
selectBackendMeetStatus,
|
||||
setBackendMeetJoining,
|
||||
} from '../../store/backendMeetSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import {
|
||||
selectCustomPrimaryColor,
|
||||
selectCustomSecondaryColor,
|
||||
selectMascotColor,
|
||||
selectSelectedMascotId,
|
||||
} from '../../store/mascotSlice';
|
||||
import { selectPersonaDescription, selectPersonaDisplayName } from '../../store/personaSlice';
|
||||
import Button from '../ui/Button';
|
||||
import {
|
||||
platformLabel,
|
||||
platformUrlPlaceholder,
|
||||
resolveMeetingBotMascotId,
|
||||
resolveMeetingDisplayName,
|
||||
} from './meetingUtils';
|
||||
import { PlatformChips } from './PlatformChips';
|
||||
|
||||
const log = debug('meetings:composer');
|
||||
|
||||
type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string };
|
||||
|
||||
export interface MeetComposerProps {
|
||||
onToast?: (toast: Toast) => void;
|
||||
/** Ref owned by the parent (MeetingsPage) so the success toast can fire
|
||||
* after the inline form unmounts on status → 'active'. */
|
||||
hasSubmittedRef: RefObject<boolean>;
|
||||
}
|
||||
|
||||
export function MeetComposer({ onToast, hasSubmittedRef }: MeetComposerProps) {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// ── Platform selector ────────────────────────────────────────────────────
|
||||
const [platform, setPlatform] = useState<MeetingPlatform>('gmeet');
|
||||
|
||||
// ── Form state ───────────────────────────────────────────────────────────
|
||||
const [meetUrl, setMeetUrl] = useState('');
|
||||
// The participant the bot answers to (authorized speaker). Wired to the
|
||||
// backend join payload as `respondToParticipant`.
|
||||
const [respondTo, setRespondTo] = useState('');
|
||||
// Once the user types in the name field we stop auto-prefilling it, so a
|
||||
// late-arriving Composio fetch (it polls) can never clobber manual input.
|
||||
const respondToTouchedRef = useRef(false);
|
||||
// Active (respond when addressed) vs listen-only (transcribe only).
|
||||
const [listenOnly, setListenOnly] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ── Persona / mascot config ──────────────────────────────────────────────
|
||||
const personaDisplayName = useAppSelector(selectPersonaDisplayName);
|
||||
const personaDescription = useAppSelector(selectPersonaDescription);
|
||||
const selectedMascotId = useAppSelector(selectSelectedMascotId);
|
||||
const mascotColor = useAppSelector(selectMascotColor);
|
||||
const customPrimaryColor = useAppSelector(selectCustomPrimaryColor);
|
||||
const customSecondaryColor = useAppSelector(selectCustomSecondaryColor);
|
||||
|
||||
// ── Meet slice ───────────────────────────────────────────────────────────
|
||||
const meetStatus = useAppSelector(selectBackendMeetStatus);
|
||||
const meetError = useAppSelector(selectBackendMeetError);
|
||||
|
||||
// ── Composio name prefill ────────────────────────────────────────────────
|
||||
const { connectionByToolkit } = useComposioIntegrations();
|
||||
const resolvedDisplayName = resolveMeetingDisplayName(platform, connectionByToolkit);
|
||||
|
||||
// Derive the value shown in the "Your name" field during render — no effect
|
||||
// needed (satisfies react-hooks/set-state-in-effect):
|
||||
// • Untouched: use the Composio-resolved name for the current platform.
|
||||
// Re-derives automatically whenever `platform` or `connectionByToolkit`
|
||||
// changes, so late-arriving Composio fetches are reflected immediately.
|
||||
// • Touched: use exactly what the user typed.
|
||||
const displayedRespondTo = !respondToTouchedRef.current ? resolvedDisplayName : respondTo;
|
||||
|
||||
// When the platform changes the displayed name re-derives on the next render
|
||||
// via resolvedDisplayName — no extra setState needed.
|
||||
const handlePlatformChange = (next: MeetingPlatform) => {
|
||||
log('[composer] platform changed from=%s to=%s', platform, next);
|
||||
setPlatform(next);
|
||||
};
|
||||
|
||||
// ── Error path (inline form stays mounted during 'error') ────────────────
|
||||
// setState is deferred via setTimeout so the rule's transitive analysis does
|
||||
// not consider them synchronous within the effect body. A 0-ms timer fires
|
||||
// before the next paint so the visible latency is imperceptible.
|
||||
useEffect(() => {
|
||||
if (!hasSubmittedRef.current) return;
|
||||
if (meetStatus !== 'error') return;
|
||||
|
||||
hasSubmittedRef.current = false;
|
||||
const raw = meetError?.trim() || t('skills.meetingBots.failedToStart');
|
||||
const message = isCapacityGateMessage(raw) ? t('skills.meetingBots.serverOverloaded') : raw;
|
||||
log('[composer] join error: %s', message);
|
||||
onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message });
|
||||
|
||||
const id = setTimeout(() => {
|
||||
setError(message);
|
||||
setSubmitting(false);
|
||||
}, 0);
|
||||
return () => clearTimeout(id);
|
||||
}, [meetStatus, meetError, onToast, t, hasSubmittedRef]);
|
||||
|
||||
// ── Submit ───────────────────────────────────────────────────────────────
|
||||
const agentName = personaDisplayName.trim() || 'Tiny';
|
||||
const systemPrompt = personaDescription.trim() || undefined;
|
||||
const mascotId = resolveMeetingBotMascotId(selectedMascotId, mascotColor);
|
||||
const riveColors =
|
||||
mascotColor === 'custom'
|
||||
? { primaryColor: customPrimaryColor, secondaryColor: customSecondaryColor }
|
||||
: undefined;
|
||||
const wakePhrase = listenOnly ? undefined : `Hey ${agentName}`;
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
hasSubmittedRef.current = true;
|
||||
const meetingId = crypto.randomUUID();
|
||||
log(
|
||||
'[composer] submit platform=%s active=%s correlationId=%s',
|
||||
platform,
|
||||
!listenOnly,
|
||||
meetingId
|
||||
);
|
||||
try {
|
||||
// Await the RPC BEFORE dispatching setBackendMeetJoining so that a
|
||||
// synchronous rejection (bad URL, auth failure) can be shown inline
|
||||
// without unmounting this component. setBackendMeetJoining transitions
|
||||
// status to 'joining' which causes MeetingsPage to swap this composer for
|
||||
// ActiveMeetingBanner — if we did that before the await, a sync throw
|
||||
// would land in the catch block of an already-unmounted component and
|
||||
// the error would never surface.
|
||||
await joinMeetViaBackendBot({
|
||||
meetUrl,
|
||||
displayName: agentName,
|
||||
platform,
|
||||
agentName,
|
||||
systemPrompt,
|
||||
mascotId,
|
||||
riveColors,
|
||||
correlationId: meetingId,
|
||||
respondToParticipant: displayedRespondTo.trim() || undefined,
|
||||
wakePhrase,
|
||||
listenOnly,
|
||||
});
|
||||
// RPC was accepted — transition the UI to the joining / active banner.
|
||||
dispatch(setBackendMeetJoining({ meetUrl: meetUrl.trim(), meetingId, listenOnly }));
|
||||
} catch (err) {
|
||||
const raw = err instanceof Error ? err.message : t('skills.meetingBots.failedToStart');
|
||||
const message = isCapacityGateMessage(raw) ? t('skills.meetingBots.serverOverloaded') : raw;
|
||||
log('[composer] join threw: %s', message);
|
||||
setError(message);
|
||||
setSubmitting(false);
|
||||
hasSubmittedRef.current = false;
|
||||
onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message });
|
||||
}
|
||||
};
|
||||
|
||||
const selectedLabel = platformLabel(platform, t);
|
||||
const urlPlaceholder = platformUrlPlaceholder(platform, t);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-line bg-surface p-4 shadow-soft animate-fade-up">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-semibold text-content">{t('skills.meetingBots.modalTitle')}</h2>
|
||||
<p className="mt-1 text-xs leading-relaxed text-content-secondary">
|
||||
{t('skills.meetingBots.modalDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Platform selector */}
|
||||
<div className="mb-4">
|
||||
<PlatformChips selected={platform} onSelect={handlePlatformChange} disabled={submitting} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{/* Meeting URL */}
|
||||
<label className="block">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-content-muted">
|
||||
{t('skills.meetingBots.meetingLink')}
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={meetUrl}
|
||||
onChange={e => setMeetUrl(e.target.value)}
|
||||
placeholder={urlPlaceholder}
|
||||
disabled={submitting}
|
||||
aria-label={t('skills.meetingBots.meetingLink')}
|
||||
className="mt-1 w-full rounded-xl border border-line bg-surface px-3 py-2 text-sm text-content placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-surface-muted dark:disabled:bg-surface-muted/60"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Your name */}
|
||||
<label className="block">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-content-muted">
|
||||
{t('skills.meetingBots.respondToParticipant')}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={displayedRespondTo}
|
||||
onChange={e => {
|
||||
respondToTouchedRef.current = true;
|
||||
setRespondTo(e.target.value);
|
||||
}}
|
||||
placeholder={t('skills.meetingBots.respondToParticipantHint')}
|
||||
disabled={submitting}
|
||||
required
|
||||
aria-label={t('skills.meetingBots.respondToParticipant')}
|
||||
className="mt-1 w-full rounded-xl border border-line bg-surface px-3 py-2 text-sm text-content placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-surface-muted dark:disabled:bg-surface-muted/60"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-content-faint">
|
||||
{t('skills.meetingBots.respondToParticipantDesc')}
|
||||
</p>
|
||||
</label>
|
||||
|
||||
{/* Respond toggle */}
|
||||
<label className="flex items-start gap-3 rounded-xl border border-line px-3 py-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!listenOnly}
|
||||
onChange={e => setListenOnly(!e.target.checked)}
|
||||
disabled={submitting}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 rounded border-line-strong text-primary-500 focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-content">
|
||||
{t('skills.meetingBots.activeMode')}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[10px] leading-relaxed text-content-faint">
|
||||
{t('skills.meetingBots.activeModeDesc')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Inline error */}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={submitting || !meetUrl.trim() || !displayedRespondTo.trim()}>
|
||||
{submitting
|
||||
? t('skills.meetingBots.starting')
|
||||
: t('skills.meetingBots.sendTo').replace('{label}', selectedLabel)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* MeetDefaultsDrawer — slide-over drawer for global and per-platform meeting defaults.
|
||||
*
|
||||
* Opened via the gear button in MeetingsPage. Uses the same settings primitives
|
||||
* (SettingsSection / SettingsRow / SettingsSelect / SettingsSwitch) as
|
||||
* MeetingSettingsPanel. Saves via the existing config_update_meet_settings RPC.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
isTauri,
|
||||
type MeetAutoJoinPolicy,
|
||||
type MeetAutoSummarizePolicy,
|
||||
openhumanGetMeetSettings,
|
||||
openhumanUpdateMeetSettings,
|
||||
} from '../../utils/tauriCommands';
|
||||
import {
|
||||
SettingsRow,
|
||||
SettingsSection,
|
||||
SettingsSelect,
|
||||
SettingsStatusLine,
|
||||
SettingsSwitch,
|
||||
} from '../settings/controls';
|
||||
|
||||
const log = debug('meetings:defaults-drawer');
|
||||
|
||||
export interface MeetDefaultsDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Platform slugs in display order
|
||||
const PLATFORMS: Array<{ key: string; labelKey: string }> = [
|
||||
{ key: 'gmeet', labelKey: 'skills.meetingBots.platforms.gmeet' },
|
||||
{ key: 'zoom', labelKey: 'skills.meetingBots.platforms.zoom' },
|
||||
{ key: 'teams', labelKey: 'skills.meetingBots.platforms.teams' },
|
||||
{ key: 'webex', labelKey: 'skills.meetingBots.platforms.webex' },
|
||||
];
|
||||
|
||||
// Values for global auto-join select
|
||||
const AUTO_JOIN_OPTIONS: MeetAutoJoinPolicy[] = ['ask_each_time', 'always', 'never'];
|
||||
// Values for per-platform override (includes "default" meaning: use global)
|
||||
type PlatformPolicy = MeetAutoJoinPolicy | 'default';
|
||||
const PLATFORM_OPTIONS: PlatformPolicy[] = ['default', 'ask_each_time', 'always', 'never'];
|
||||
|
||||
const AUTO_JOIN_LABEL_KEY: Record<MeetAutoJoinPolicy, string> = {
|
||||
ask_each_time: 'settings.meetings.autoJoin.askEachTime',
|
||||
always: 'settings.meetings.autoJoin.always',
|
||||
never: 'settings.meetings.autoJoin.never',
|
||||
};
|
||||
|
||||
const AUTO_SUMMARIZE_OPTIONS: MeetAutoSummarizePolicy[] = ['ask', 'always', 'never'];
|
||||
const AUTO_SUMMARIZE_LABEL_KEY: Record<MeetAutoSummarizePolicy, string> = {
|
||||
ask: 'settings.meetings.autoSummarize.ask',
|
||||
always: 'settings.meetings.autoSummarize.always',
|
||||
never: 'settings.meetings.autoSummarize.never',
|
||||
};
|
||||
|
||||
export function MeetDefaultsDrawer({ open, onClose }: MeetDefaultsDrawerProps) {
|
||||
const { t } = useT();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Finding A: track whether the initial load completed successfully
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
// Bumping this triggers a retry of the initial load
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savedNote, setSavedNote] = useState<string | null>(null);
|
||||
|
||||
// Master calendar-watch switch
|
||||
const [watchCalendar, setWatchCalendar] = useState(false);
|
||||
|
||||
// Global settings
|
||||
const [autoJoin, setAutoJoin] = useState<MeetAutoJoinPolicy>('ask_each_time');
|
||||
const [autoSummarize, setAutoSummarize] = useState<MeetAutoSummarizePolicy>('ask');
|
||||
const [listenOnly, setListenOnly] = useState(true);
|
||||
const [ingestTranscripts, setIngestTranscripts] = useState(false);
|
||||
|
||||
// Per-platform overrides: key → MeetAutoJoinPolicy | undefined (undefined = use default)
|
||||
const [platformPolicies, setPlatformPolicies] = useState<Record<string, PlatformPolicy>>({});
|
||||
|
||||
// Finding B: per-setting sequence counters so a failed save for one setting
|
||||
// does not get masked by a successful save for a different setting.
|
||||
const persistSeqRef = useRef<Record<string, number>>({});
|
||||
|
||||
// Load settings when opened (also re-runs when retryCount is bumped)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!isTauri()) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setLoaded(false);
|
||||
setLoadError(null);
|
||||
const load = async () => {
|
||||
log('load start retryCount=%d', retryCount);
|
||||
try {
|
||||
const resp = await openhumanGetMeetSettings();
|
||||
if (cancelled) return;
|
||||
const s = resp.result;
|
||||
log('load ok auto_join=%s watch_calendar=%s', s.auto_join_policy, s.watch_calendar);
|
||||
setWatchCalendar(s.watch_calendar ?? false);
|
||||
setAutoJoin(s.auto_join_policy);
|
||||
setAutoSummarize(s.auto_summarize_policy);
|
||||
setListenOnly(s.listen_only_default);
|
||||
setIngestTranscripts(s.ingest_backend_transcripts);
|
||||
// Build per-platform state: stored as "ask_each_time"|"always"|"never", display as that or "default"
|
||||
const pp: Record<string, PlatformPolicy> = {};
|
||||
const stored = s.platform_auto_join_policies ?? {};
|
||||
for (const plat of PLATFORMS.map(p => p.key)) {
|
||||
pp[plat] = (stored[plat] as MeetAutoJoinPolicy | undefined) ?? 'default';
|
||||
}
|
||||
setPlatformPolicies(pp);
|
||||
setLoaded(true);
|
||||
} catch (e) {
|
||||
log('load failed err=%o', e);
|
||||
if (!cancelled) {
|
||||
setLoadError(e instanceof Error ? e.message : t('settings.meetings.loadError'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, retryCount]);
|
||||
|
||||
// Finding B: settingKey scopes the seq counter to this specific setting so that
|
||||
// a failed save for one setting is not silently dropped because another setting's
|
||||
// save incremented the shared counter in between.
|
||||
const persist = async (
|
||||
settingKey: string,
|
||||
patch: Parameters<typeof openhumanUpdateMeetSettings>[0],
|
||||
onFailure?: () => void
|
||||
) => {
|
||||
const seq = (persistSeqRef.current[settingKey] = (persistSeqRef.current[settingKey] ?? 0) + 1);
|
||||
if (!isTauri()) return;
|
||||
log('persist settingKey=%s patch=%o seq=%d', settingKey, patch, seq);
|
||||
setError(null);
|
||||
setSavedNote(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
await openhumanUpdateMeetSettings(patch);
|
||||
if (seq !== persistSeqRef.current[settingKey]) return;
|
||||
setSavedNote(t('settings.meetings.saved'));
|
||||
} catch (e) {
|
||||
if (seq !== persistSeqRef.current[settingKey]) return;
|
||||
onFailure?.();
|
||||
setError(e instanceof Error ? e.message : t('settings.meetings.saveError'));
|
||||
} finally {
|
||||
if (seq === persistSeqRef.current[settingKey]) setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWatchCalendarChange = (next: boolean) => {
|
||||
const prev = watchCalendar;
|
||||
setWatchCalendar(next);
|
||||
log('watch_calendar change next=%s', next);
|
||||
void persist('watch_calendar', { watch_calendar: next }, () => setWatchCalendar(prev));
|
||||
};
|
||||
|
||||
const handleAutoJoinChange = (next: MeetAutoJoinPolicy) => {
|
||||
const prev = autoJoin;
|
||||
setAutoJoin(next);
|
||||
void persist('auto_join_policy', { auto_join_policy: next }, () => setAutoJoin(prev));
|
||||
};
|
||||
|
||||
const handleAutoSummarizeChange = (next: MeetAutoSummarizePolicy) => {
|
||||
const prev = autoSummarize;
|
||||
setAutoSummarize(next);
|
||||
void persist('auto_summarize_policy', { auto_summarize_policy: next }, () =>
|
||||
setAutoSummarize(prev)
|
||||
);
|
||||
};
|
||||
|
||||
const handleListenOnlyChange = (next: boolean) => {
|
||||
const prev = listenOnly;
|
||||
setListenOnly(next);
|
||||
void persist('listen_only_default', { listen_only_default: next }, () => setListenOnly(prev));
|
||||
};
|
||||
|
||||
const handleIngestChange = (next: boolean) => {
|
||||
const prev = ingestTranscripts;
|
||||
setIngestTranscripts(next);
|
||||
void persist('ingest_backend_transcripts', { ingest_backend_transcripts: next }, () =>
|
||||
setIngestTranscripts(prev)
|
||||
);
|
||||
};
|
||||
|
||||
const handlePlatformPolicyChange = (platform: string, next: PlatformPolicy) => {
|
||||
const prevValue = platformPolicies[platform] ?? 'default';
|
||||
const updated = { ...platformPolicies, [platform]: next };
|
||||
setPlatformPolicies(updated);
|
||||
|
||||
// Build the map to persist: only include non-"default" entries
|
||||
const toSave: Record<string, MeetAutoJoinPolicy> = {};
|
||||
for (const [k, v] of Object.entries(updated)) {
|
||||
if (v !== 'default') {
|
||||
toSave[k] = v as MeetAutoJoinPolicy;
|
||||
}
|
||||
}
|
||||
void persist(
|
||||
`platform_auto_join_policies.${platform}`,
|
||||
{ platform_auto_join_policies: toSave },
|
||||
() => setPlatformPolicies(current => ({ ...current, [platform]: prevValue }))
|
||||
);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-40 bg-black/40" aria-hidden="true" onClick={onClose} />
|
||||
|
||||
{/* Drawer panel */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('skills.meetingBots.defaults.drawerTitle')}
|
||||
className="fixed inset-y-0 right-0 z-50 w-80 bg-surface border-l border-line/50 flex flex-col shadow-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-line/50 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-content-primary">
|
||||
{t('skills.meetingBots.defaults.drawerTitle')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('skills.meetingBots.defaults.closeDrawer')}
|
||||
onClick={onClose}
|
||||
className="text-content-secondary hover:text-content-primary transition-colors p-1 rounded">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 4L4 12M4 4l8 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||
{loading ? (
|
||||
<p className="text-sm text-content-secondary">{t('settings.meetings.loading')}</p>
|
||||
) : !isTauri() ? (
|
||||
<p className="text-sm text-content-secondary">{t('settings.meetings.desktopOnly')}</p>
|
||||
) : !loaded ? (
|
||||
// Finding A: load failed — show error + retry instead of stale-defaults form
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-status-error">
|
||||
{loadError ?? t('settings.meetings.loadError')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
log('retry load');
|
||||
setRetryCount(c => c + 1);
|
||||
}}
|
||||
className="self-start text-sm text-primary-500 hover:text-primary-400 transition-colors underline">
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Master calendar-watch switch */}
|
||||
<SettingsSection>
|
||||
<SettingsRow
|
||||
htmlFor="drawer-switch-watch-calendar"
|
||||
label={t('skills.meetingBots.defaults.watchCalendar')}
|
||||
description={t('skills.meetingBots.defaults.watchCalendarDesc')}
|
||||
control={
|
||||
<SettingsSwitch
|
||||
id="drawer-switch-watch-calendar"
|
||||
checked={watchCalendar}
|
||||
onCheckedChange={handleWatchCalendarChange}
|
||||
aria-label={t('skills.meetingBots.defaults.watchCalendar')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Global auto-join */}
|
||||
<SettingsSection title={t('skills.meetingBots.defaults.globalPolicy')}>
|
||||
<SettingsRow
|
||||
stacked
|
||||
control={
|
||||
<SettingsSelect
|
||||
value={autoJoin}
|
||||
onChange={e => handleAutoJoinChange(e.target.value as MeetAutoJoinPolicy)}
|
||||
aria-label={t('skills.meetingBots.defaults.globalPolicy')}>
|
||||
{AUTO_JOIN_OPTIONS.map(opt => (
|
||||
<option key={opt} value={opt}>
|
||||
{t(AUTO_JOIN_LABEL_KEY[opt])}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Per-platform overrides */}
|
||||
<SettingsSection
|
||||
title={t('skills.meetingBots.defaults.perPlatformTitle')}
|
||||
description={t('skills.meetingBots.defaults.perPlatformDesc')}>
|
||||
{PLATFORMS.map(({ key, labelKey }) => (
|
||||
<SettingsRow
|
||||
key={key}
|
||||
label={t(labelKey)}
|
||||
control={
|
||||
<SettingsSelect
|
||||
value={platformPolicies[key] ?? 'default'}
|
||||
onChange={e =>
|
||||
handlePlatformPolicyChange(key, e.target.value as PlatformPolicy)
|
||||
}
|
||||
aria-label={t(labelKey)}>
|
||||
{PLATFORM_OPTIONS.map(opt => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt === 'default'
|
||||
? t('skills.meetingBots.defaults.useDefault')
|
||||
: t(AUTO_JOIN_LABEL_KEY[opt as MeetAutoJoinPolicy])}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
{/* Other toggles */}
|
||||
<SettingsSection>
|
||||
<SettingsRow
|
||||
htmlFor="drawer-switch-listen-only"
|
||||
label={t('settings.meetings.listenOnly')}
|
||||
description={t('settings.meetings.listenOnlyDesc')}
|
||||
control={
|
||||
<SettingsSwitch
|
||||
id="drawer-switch-listen-only"
|
||||
checked={listenOnly}
|
||||
onCheckedChange={handleListenOnlyChange}
|
||||
aria-label={t('settings.meetings.listenOnly')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
htmlFor="drawer-switch-auto-summarize"
|
||||
label={t('settings.meetings.autoSummarize.title')}
|
||||
control={
|
||||
<SettingsSelect
|
||||
value={autoSummarize}
|
||||
onChange={e =>
|
||||
handleAutoSummarizeChange(e.target.value as MeetAutoSummarizePolicy)
|
||||
}
|
||||
aria-label={t('settings.meetings.autoSummarize.title')}>
|
||||
{AUTO_SUMMARIZE_OPTIONS.map(opt => (
|
||||
<option key={opt} value={opt}>
|
||||
{t(AUTO_SUMMARIZE_LABEL_KEY[opt])}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
htmlFor="drawer-switch-ingest"
|
||||
label={t('settings.meetings.ingestTranscripts')}
|
||||
description={t('settings.meetings.ingestTranscriptsDesc')}
|
||||
control={
|
||||
<SettingsSwitch
|
||||
id="drawer-switch-ingest"
|
||||
checked={ingestTranscripts}
|
||||
onCheckedChange={handleIngestChange}
|
||||
aria-label={t('settings.meetings.ingestTranscripts')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status line */}
|
||||
<div className="px-4 py-2 border-t border-line/50 shrink-0">
|
||||
<SettingsStatusLine
|
||||
saving={saving}
|
||||
savedNote={savedNote}
|
||||
error={error}
|
||||
savingLabel={t('settings.meetings.saving')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Meetings page orchestrator.
|
||||
*
|
||||
* Renders the Beta banner, the active-meeting overlay (when a bot is running),
|
||||
* the meeting composer (when idle), and the recent-calls history below.
|
||||
*
|
||||
* Owns the `hasSubmittedRef` success-toast pattern — the ref lives here so the
|
||||
* toast fires reliably even though the inline composer unmounts when status
|
||||
* flips to 'active' (same pattern as the original `MeetingBotsCard`).
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { selectBackendMeetStatus } from '../../store/backendMeetSlice';
|
||||
import { useAppSelector } from '../../store/hooks';
|
||||
import { isTauri, openhumanGetMeetSettings } from '../../utils/tauriCommands';
|
||||
import BetaBanner from '../ui/BetaBanner';
|
||||
import { ActiveMeetingBanner } from './ActiveMeetingBanner';
|
||||
import HistorySection from './HistorySection';
|
||||
import { MeetComposer } from './MeetComposer';
|
||||
import { MeetDefaultsDrawer } from './MeetDefaultsDrawer';
|
||||
import { UpcomingTable } from './UpcomingTable';
|
||||
|
||||
const log = debug('meetings:page');
|
||||
|
||||
type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string };
|
||||
|
||||
export interface MeetingsPageProps {
|
||||
onToast?: (toast: Toast) => void;
|
||||
}
|
||||
|
||||
export default function MeetingsPage({ onToast }: MeetingsPageProps) {
|
||||
const { t } = useT();
|
||||
const status = useAppSelector(selectBackendMeetStatus);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
// watchCalendar: null = unknown (don't show hint), false = off (show hint when there are meetings)
|
||||
const [watchCalendar, setWatchCalendar] = useState<boolean | null>(null);
|
||||
// Show the live banner while joining or in an active meeting. All other
|
||||
// states ('idle', 'ended', 'error') render the composer so the user can
|
||||
// submit a new join or see the inline error from a failed attempt.
|
||||
const showActive = status === 'joining' || status === 'active';
|
||||
|
||||
// `hasSubmittedRef` lives in this always-mounted parent so the success toast
|
||||
// fires reliably. When a join succeeds, `status` flips to 'active' and this
|
||||
// component swaps `MeetComposer` → `ActiveMeetingBanner`, unmounting the
|
||||
// composer before any effect inside it could observe 'active'. The composer
|
||||
// sets this ref on submit; we fire the success toast here.
|
||||
const hasSubmittedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasSubmittedRef.current) return;
|
||||
if (status === 'active') {
|
||||
hasSubmittedRef.current = false;
|
||||
log('[page] join succeeded → status=active, firing success toast');
|
||||
onToast?.({
|
||||
type: 'success',
|
||||
title: t('skills.meetingBots.joiningTitle'),
|
||||
message: t('skills.meetingBots.joiningMessage'),
|
||||
});
|
||||
}
|
||||
}, [status, onToast, t]);
|
||||
|
||||
// Fetch watch_calendar once on mount (stale-60s is fine; re-fetched when drawer closes).
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
let cancelled = false;
|
||||
openhumanGetMeetSettings()
|
||||
.then(resp => {
|
||||
if (!cancelled) {
|
||||
log('[page] watch_calendar=%s', resp.result.watch_calendar);
|
||||
setWatchCalendar(resp.result.watch_calendar ?? false);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
log('[page] failed to fetch meet settings for watchCalendar: %o', err);
|
||||
// Leave null → no hint shown (fail open, don't nag)
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 animate-fade-up">
|
||||
{/* Page header row: beta badge + gear button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<BetaBanner />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('skills.meetingBots.defaults.openDefaults')}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="p-1.5 rounded text-content-secondary hover:text-content-primary hover:bg-surface-hover transition-colors">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showActive ? (
|
||||
<ActiveMeetingBanner onToast={onToast} />
|
||||
) : (
|
||||
<MeetComposer onToast={onToast} hasSubmittedRef={hasSubmittedRef} />
|
||||
)}
|
||||
|
||||
<UpcomingTable watchCalendar={watchCalendar} />
|
||||
|
||||
<HistorySection />
|
||||
|
||||
<MeetDefaultsDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
// Re-fetch watch_calendar after drawer closes so the hint updates.
|
||||
if (!isTauri()) return;
|
||||
openhumanGetMeetSettings()
|
||||
.then(resp => setWatchCalendar(resp.result.watch_calendar ?? false))
|
||||
.catch(() => {
|
||||
/* leave unchanged */
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Platform selector chip group for the Meetings composer.
|
||||
*
|
||||
* Renders Google Meet / Zoom / Teams / Webex as keyboard-navigable radio
|
||||
* buttons. This is purely a selector: the bot joins via the pasted meeting
|
||||
* link regardless of any Composio account, so no connection is required or
|
||||
* implied here. (A connected account only ever helps silently auto-fill the
|
||||
* display name.)
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { MeetingPlatform } from '../../services/meetCallService';
|
||||
import { MEETING_PLATFORMS, platformLabel, platformLogoUrl } from './meetingUtils';
|
||||
|
||||
const log = debug('meetings:platform-chips');
|
||||
|
||||
export interface PlatformChipsProps {
|
||||
selected: MeetingPlatform;
|
||||
onSelect: (platform: MeetingPlatform) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function PlatformLogo({ platform, label }: { platform: MeetingPlatform; label: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (failed) {
|
||||
// Fallback: first letter of the platform label
|
||||
return (
|
||||
<span
|
||||
className="flex h-4 w-4 items-center justify-center rounded-sm bg-surface-muted text-[9px] font-bold text-content-muted"
|
||||
aria-hidden="true">
|
||||
{label.charAt(0)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={platformLogoUrl(platform)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 object-contain"
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformChips({ selected, onSelect, disabled = false }: PlatformChipsProps) {
|
||||
const { t } = useT();
|
||||
|
||||
function handleClick(platform: MeetingPlatform) {
|
||||
if (disabled) return;
|
||||
log('[platform-chips] selected platform=%s', platform);
|
||||
onSelect(platform);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent, platform: MeetingPlatform) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleClick(platform);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('skills.meetingBots.modalAriaLabel')}
|
||||
className="flex flex-wrap gap-2">
|
||||
{MEETING_PLATFORMS.map(platform => {
|
||||
const isSelected = selected === platform;
|
||||
const label = platformLabel(platform, t);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={platform}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
aria-label={label}
|
||||
onClick={() => handleClick(platform)}
|
||||
onKeyDown={e => handleKeyDown(e, platform)}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
'flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium',
|
||||
'transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2',
|
||||
'focus-visible:ring-primary-500 focus-visible:ring-offset-1',
|
||||
'disabled:cursor-not-allowed disabled:opacity-40',
|
||||
isSelected
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700 dark:bg-primary-500/15 dark:text-primary-300'
|
||||
: 'border-line bg-surface text-content-secondary hover:border-primary-300 hover:bg-primary-50/40 dark:hover:bg-primary-500/10',
|
||||
].join(' ')}>
|
||||
<PlatformLogo platform={platform} label={label} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* TranscriptViewer — renders a list of transcript lines with speaker labels
|
||||
* and timestamps, plus copy-all and download controls.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { type MeetCallTranscriptLine, parseTranscriptLine } from '../../services/meetCallService';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
const log = debug('meetings:transcript');
|
||||
|
||||
interface TranscriptViewerProps {
|
||||
lines: MeetCallTranscriptLine[];
|
||||
}
|
||||
|
||||
function buildPlainText(lines: MeetCallTranscriptLine[]): string {
|
||||
return lines
|
||||
.map(line => {
|
||||
const parsed = parseTranscriptLine(line);
|
||||
const ts = parsed.timestamp ? `${parsed.timestamp} ` : '';
|
||||
const speaker = parsed.speaker ? `${parsed.speaker}: ` : '';
|
||||
return `${ts}${speaker}${parsed.text}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function TranscriptViewer({ lines }: TranscriptViewerProps) {
|
||||
const { t } = useT();
|
||||
|
||||
async function handleCopy() {
|
||||
log('[transcript] copy all clicked, lines=%d', lines.length);
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildPlainText(lines));
|
||||
log('[transcript] copy succeeded');
|
||||
} catch (e) {
|
||||
log('[transcript] copy failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
log('[transcript] download clicked, lines=%d', lines.length);
|
||||
const text = buildPlainText(lines);
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'transcript.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('skills.meetingBots.callTranscriptHeading')}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="tertiary" size="xs" onClick={() => void handleCopy()}>
|
||||
{t('skills.meetingBots.history.copyTranscript')}
|
||||
</Button>
|
||||
<Button variant="tertiary" size="xs" onClick={handleDownload}>
|
||||
{t('skills.meetingBots.history.downloadTranscript')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto rounded-md bg-surface-muted p-2 space-y-0.5">
|
||||
{lines.map((line, i) => {
|
||||
const parsed = parseTranscriptLine(line);
|
||||
const isAssistant = parsed.role === 'assistant';
|
||||
return (
|
||||
<p
|
||||
key={i}
|
||||
className={
|
||||
isAssistant
|
||||
? 'text-[10px] text-primary-600 dark:text-primary-400'
|
||||
: 'text-[10px] text-content-secondary'
|
||||
}>
|
||||
{parsed.timestamp && (
|
||||
<span className="mr-1 text-content-faint">{parsed.timestamp}</span>
|
||||
)}
|
||||
{parsed.speaker && <span className="mr-1 font-medium">{parsed.speaker}:</span>}
|
||||
{parsed.text}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TranscriptViewer;
|
||||
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* UpcomingTable — full-width table of upcoming calendar meetings with
|
||||
* conferencing links.
|
||||
*
|
||||
* Columns: WHEN / MEETING / PLATFORM / PEOPLE / JOIN POLICY / (action)
|
||||
*
|
||||
* Date-group separators: Today / Tomorrow / <date>
|
||||
*
|
||||
* Imminent meetings (≤ 5 min until start) get an accent row and a
|
||||
* "Join now" button. Other rows show a quieter join/open-link affordance.
|
||||
*
|
||||
* JOIN POLICY toggle: local state only (Phase 2). Phase 3 adds persistence.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
joinMeetViaBackendBot,
|
||||
type MeetingPlatform,
|
||||
setEventPolicy,
|
||||
type UpcomingMeeting,
|
||||
} from '../../services/meetCallService';
|
||||
import { useAppSelector } from '../../store/hooks';
|
||||
import {
|
||||
selectCustomPrimaryColor,
|
||||
selectCustomSecondaryColor,
|
||||
selectMascotColor,
|
||||
selectSelectedMascotId,
|
||||
} from '../../store/mascotSlice';
|
||||
import { selectPersonaDescription, selectPersonaDisplayName } from '../../store/personaSlice';
|
||||
import Button from '../ui/Button';
|
||||
import { type JoinPolicy, JoinPolicyToggle } from './JoinPolicyToggle';
|
||||
import { inferPlatformFromUrl, platformLabel, platformLogoUrl } from './meetingUtils';
|
||||
import { useUpcomingMeetings } from './useUpcomingMeetings';
|
||||
|
||||
const log = debug('meetings:upcoming-table');
|
||||
|
||||
const IMMINENT_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function localDayKey(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function todayKey(): string {
|
||||
return localDayKey(Date.now());
|
||||
}
|
||||
|
||||
function tomorrowKey(): string {
|
||||
return localDayKey(Date.now() + 86_400_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a future time as a relative label ("in 5m", "in 2h") plus an
|
||||
* absolute time string (e.g. "14:30").
|
||||
*
|
||||
* All user-visible strings are routed through i18n. The caller must
|
||||
* supply the `t` function from `useT()`.
|
||||
*/
|
||||
function formatWhen(
|
||||
ms: number,
|
||||
t: (key: string) => string
|
||||
): { relative: string; absolute: string } {
|
||||
const diffMs = ms - Date.now();
|
||||
const absolute = new Date(ms).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
if (diffMs < 0) {
|
||||
return { relative: t('skills.meetingBots.relative.now'), absolute };
|
||||
}
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 60) {
|
||||
return {
|
||||
relative: t('skills.meetingBots.relative.inMinutes').replace('{count}', String(minutes)),
|
||||
absolute,
|
||||
};
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return {
|
||||
relative: t('skills.meetingBots.relative.inHours').replace('{count}', String(hours)),
|
||||
absolute,
|
||||
};
|
||||
}
|
||||
|
||||
function isImminent(startTimeMs: number): boolean {
|
||||
return startTimeMs - Date.now() <= IMMINENT_THRESHOLD_MS;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group meetings by local date
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DateGroup {
|
||||
label: string;
|
||||
meetings: UpcomingMeeting[];
|
||||
}
|
||||
|
||||
function groupByDate(
|
||||
meetings: UpcomingMeeting[],
|
||||
todayLabel: string,
|
||||
tomorrowLabel: string
|
||||
): DateGroup[] {
|
||||
const today = todayKey();
|
||||
const tomorrow = tomorrowKey();
|
||||
const buckets = new Map<string, { label: string; meetings: UpcomingMeeting[] }>();
|
||||
|
||||
for (const m of meetings) {
|
||||
const key = localDayKey(m.start_time_ms);
|
||||
if (!buckets.has(key)) {
|
||||
let label: string;
|
||||
if (key === today) label = todayLabel;
|
||||
else if (key === tomorrow) label = tomorrowLabel;
|
||||
else {
|
||||
label = new Date(m.start_time_ms).toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
buckets.set(key, { label, meetings: [] });
|
||||
}
|
||||
buckets.get(key)!.meetings.push(m);
|
||||
}
|
||||
|
||||
return Array.from(buckets.values());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PlatformFilter = MeetingPlatform | 'all';
|
||||
|
||||
/**
|
||||
* Resolve the platform that a meeting row actually displays: the explicit
|
||||
* `platform` field first, then inferred from the conferencing URL. This must
|
||||
* match exactly what `MeetingRow` renders so that filtering is consistent
|
||||
* with what the user sees.
|
||||
*/
|
||||
function effectivePlatform(m: UpcomingMeeting): MeetingPlatform | null {
|
||||
if (m.platform) return m.platform as MeetingPlatform;
|
||||
return m.meet_url ? inferPlatformFromUrl(m.meet_url) : null;
|
||||
}
|
||||
|
||||
function filterMeetings(meetings: UpcomingMeeting[], filter: PlatformFilter): UpcomingMeeting[] {
|
||||
if (filter === 'all') return meetings;
|
||||
return meetings.filter(m => effectivePlatform(m) === filter);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MeetingRowProps {
|
||||
meeting: UpcomingMeeting;
|
||||
joinPolicy: JoinPolicy;
|
||||
onJoinPolicyChange: (v: JoinPolicy) => void;
|
||||
onJoin: (m: UpcomingMeeting) => void;
|
||||
joining: boolean;
|
||||
}
|
||||
|
||||
function MeetingRow({ meeting, joinPolicy, onJoinPolicyChange, onJoin, joining }: MeetingRowProps) {
|
||||
const { t } = useT();
|
||||
const imminent = isImminent(meeting.start_time_ms);
|
||||
const { relative, absolute } = formatWhen(meeting.start_time_ms, t);
|
||||
|
||||
const platform = (meeting.platform ??
|
||||
(meeting.meet_url
|
||||
? (inferPlatformFromUrl(meeting.meet_url) ?? null)
|
||||
: null)) as MeetingPlatform | null;
|
||||
const logoUrl = platform ? platformLogoUrl(platform) : null;
|
||||
const platformName = platform ? platformLabel(platform, t) : '—';
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={[
|
||||
'border-b border-line/50 transition-colors',
|
||||
imminent ? 'bg-amber-500/5 hover:bg-amber-500/10' : 'hover:bg-surface-hover',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}>
|
||||
{/* WHEN */}
|
||||
<td className="py-2 px-3 whitespace-nowrap">
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
className={[
|
||||
'text-xs font-medium',
|
||||
imminent ? 'text-amber-400' : 'text-content-primary',
|
||||
].join(' ')}>
|
||||
{relative}
|
||||
</span>
|
||||
<span className="text-xs text-content-secondary">{absolute}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* MEETING */}
|
||||
<td className="py-2 px-3 min-w-0 max-w-xs">
|
||||
<span
|
||||
className="block truncate text-sm text-content-primary font-medium"
|
||||
title={meeting.title}>
|
||||
{meeting.title}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* PLATFORM */}
|
||||
<td className="py-2 px-3 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 rounded-sm shrink-0"
|
||||
onError={e => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-content-secondary">{platformName}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* PEOPLE */}
|
||||
<td className="py-2 px-3 whitespace-nowrap text-xs text-content-secondary">
|
||||
{meeting.participant_count != null
|
||||
? t('skills.meetingBots.upcoming.participants').replace(
|
||||
'{count}',
|
||||
String(meeting.participant_count)
|
||||
)
|
||||
: '—'}
|
||||
</td>
|
||||
|
||||
{/* JOIN POLICY */}
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<JoinPolicyToggle value={joinPolicy} onChange={onJoinPolicyChange} compact />
|
||||
{joinPolicy === 'auto' && (
|
||||
<span className="text-[10px] text-content-secondary/60 whitespace-nowrap">
|
||||
{t('skills.meetingBots.upcoming.autoJoinsAt').replace(
|
||||
'{time}',
|
||||
new Date(meeting.start_time_ms).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{joinPolicy === 'ask' && (
|
||||
<span className="text-[10px] text-content-secondary/60 whitespace-nowrap">
|
||||
{t('skills.meetingBots.upcoming.asksAtStart')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* ACTION */}
|
||||
<td className="py-2 px-3 whitespace-nowrap">
|
||||
{imminent ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="xs"
|
||||
disabled={joining || !meeting.meet_url}
|
||||
aria-label={t('skills.meetingBots.upcoming.joinNowAriaLabel').replace(
|
||||
'{title}',
|
||||
meeting.title
|
||||
)}
|
||||
onClick={() => onJoin(meeting)}>
|
||||
{joining ? '…' : t('skills.meetingBots.upcoming.joinNow')}
|
||||
</Button>
|
||||
) : meeting.meet_url ? (
|
||||
<Button variant="tertiary" size="xs" disabled={joining} onClick={() => onJoin(meeting)}>
|
||||
{t('skills.meetingBots.upcoming.join')}
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading skeleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<tr className="border-b border-line/50 animate-pulse">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<td key={i} className="py-2 px-3">
|
||||
<div className="h-4 bg-surface-hover rounded w-16" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UpcomingTableProps {
|
||||
lookaheadMinutes?: number;
|
||||
limit?: number;
|
||||
/**
|
||||
* Master calendar-watch switch value from meet settings.
|
||||
* `null` = unknown (fetch in progress / failed) — hint is suppressed.
|
||||
* `false` = off — show hint when there are upcoming meetings.
|
||||
* `true` = on — no hint needed.
|
||||
*/
|
||||
watchCalendar?: boolean | null;
|
||||
}
|
||||
|
||||
export function UpcomingTable({
|
||||
lookaheadMinutes,
|
||||
limit,
|
||||
watchCalendar = null,
|
||||
}: UpcomingTableProps) {
|
||||
const { t } = useT();
|
||||
const { meetings, loading, error, refresh } = useUpcomingMeetings(lookaheadMinutes, limit);
|
||||
|
||||
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
|
||||
const [joinPolicies, setJoinPolicies] = useState<Record<string, JoinPolicy>>({});
|
||||
const [joiningId, setJoiningId] = useState<string | null>(null);
|
||||
|
||||
// Persona / mascot selectors for bot join params — mirrors MeetComposer.tsx.
|
||||
const personaDisplayName = useAppSelector(selectPersonaDisplayName);
|
||||
const personaDescription = useAppSelector(selectPersonaDescription);
|
||||
const selectedMascotId = useAppSelector(selectSelectedMascotId);
|
||||
const mascotColor = useAppSelector(selectMascotColor);
|
||||
const customPrimaryColor = useAppSelector(selectCustomPrimaryColor);
|
||||
const customSecondaryColor = useAppSelector(selectCustomSecondaryColor);
|
||||
|
||||
// Resolve bot join params the same way MeetComposer does.
|
||||
const mascotId = selectedMascotId ?? (mascotColor === 'custom' ? undefined : mascotColor);
|
||||
const riveColors =
|
||||
mascotColor === 'custom'
|
||||
? { primaryColor: customPrimaryColor, secondaryColor: customSecondaryColor }
|
||||
: undefined;
|
||||
|
||||
const handleJoin = async (meeting: UpcomingMeeting) => {
|
||||
if (!meeting.meet_url) return;
|
||||
const platform = meeting.platform ?? inferPlatformFromUrl(meeting.meet_url) ?? undefined;
|
||||
log('[upcoming] joining %s platform=%s', meeting.calendar_event_id, platform);
|
||||
setJoiningId(meeting.calendar_event_id);
|
||||
try {
|
||||
await joinMeetViaBackendBot({
|
||||
meetUrl: meeting.meet_url,
|
||||
platform: platform as MeetingPlatform | undefined,
|
||||
agentName: personaDisplayName || undefined,
|
||||
systemPrompt: personaDescription || undefined,
|
||||
mascotId: mascotId || undefined,
|
||||
listenOnly: true,
|
||||
correlationId: meeting.calendar_event_id,
|
||||
riveColors,
|
||||
});
|
||||
} catch (err) {
|
||||
log('[upcoming] join error: %s', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setJoiningId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoinPolicyChange = async (id: string, v: JoinPolicy) => {
|
||||
const prev =
|
||||
joinPolicies[id] ??
|
||||
(meetings.find(m => m.calendar_event_id === id)?.join_policy as JoinPolicy | undefined) ??
|
||||
'ask';
|
||||
// Optimistic update
|
||||
setJoinPolicies(current => ({ ...current, [id]: v }));
|
||||
log('[upcoming] set event policy id=%s policy=%s', id, v);
|
||||
try {
|
||||
await setEventPolicy(id, v);
|
||||
} catch (err) {
|
||||
// Revert on failure — but ONLY if the value still matches what THIS call
|
||||
// optimistically set. A newer concurrent call may have already moved the
|
||||
// value to something else; clobbering it would discard that change.
|
||||
log(
|
||||
'[upcoming] set event policy failed, reverting: %s',
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
setJoinPolicies(curr => (curr[id] === v ? { ...curr, [id]: prev } : curr));
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = filterMeetings(meetings, platformFilter);
|
||||
const groups = groupByDate(
|
||||
filtered,
|
||||
t('skills.meetingBots.upcoming.today'),
|
||||
t('skills.meetingBots.upcoming.tomorrow')
|
||||
);
|
||||
|
||||
// Collect unique effective platforms for the filter dropdown — uses the same
|
||||
// effectivePlatform() helper as filterMeetings() so the options are consistent
|
||||
// with what each row displays (including inferred-from-URL platforms).
|
||||
const presentPlatforms = Array.from(
|
||||
new Set(meetings.map(m => effectivePlatform(m)).filter((p): p is MeetingPlatform => p != null))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-2xl border border-line bg-surface shadow-soft overflow-hidden">
|
||||
{/* Table header bar */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-line/50">
|
||||
<h3 className="text-sm font-semibold text-content-primary">
|
||||
{t('skills.meetingBots.upcoming.heading')}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Platform filter */}
|
||||
{presentPlatforms.length > 1 && (
|
||||
<select
|
||||
className="text-xs bg-transparent text-content-secondary border border-line/50 rounded px-1.5 py-0.5 focus:outline-none"
|
||||
value={platformFilter}
|
||||
onChange={e => setPlatformFilter(e.target.value as PlatformFilter)}
|
||||
aria-label={t('skills.meetingBots.upcoming.filterAll')}>
|
||||
<option value="all">{t('skills.meetingBots.upcoming.filterAll')}</option>
|
||||
{presentPlatforms.map(p => (
|
||||
<option key={p} value={p}>
|
||||
{platformLabel(p, t)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Refresh button */}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="xs"
|
||||
iconOnly
|
||||
aria-label={t('skills.meetingBots.upcoming.refresh')}
|
||||
onClick={refresh}
|
||||
disabled={loading}>
|
||||
{/* Minimal SVG refresh icon */}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M10 6a4 4 0 1 1-1.17-2.83M10 2v3H7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Watch-calendar off hint — only when there are meetings and watchCalendar is explicitly false */}
|
||||
{meetings.length > 0 && watchCalendar === false && (
|
||||
<div
|
||||
role="note"
|
||||
className="flex items-start gap-2 px-3 py-2 bg-amber-500/10 border-b border-amber-500/20 text-xs text-amber-600 dark:text-amber-400">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 shrink-0">
|
||||
<path
|
||||
d="M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2Zm0 3.5v3m0 2h.01"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>{t('skills.meetingBots.upcoming.watchCalendarHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-content-secondary border-b border-line/50">
|
||||
<th className="py-1.5 px-3 text-left font-medium">
|
||||
{t('skills.meetingBots.upcoming.when')}
|
||||
</th>
|
||||
<th className="py-1.5 px-3 text-left font-medium">
|
||||
{t('skills.meetingBots.upcoming.meeting')}
|
||||
</th>
|
||||
<th className="py-1.5 px-3 text-left font-medium">
|
||||
{t('skills.meetingBots.upcoming.platform')}
|
||||
</th>
|
||||
<th className="py-1.5 px-3 text-left font-medium">
|
||||
{t('skills.meetingBots.upcoming.people')}
|
||||
</th>
|
||||
<th className="py-1.5 px-3 text-left font-medium">
|
||||
{t('skills.meetingBots.upcoming.joinPolicy')}
|
||||
</th>
|
||||
<th className="py-1.5 px-3 text-left font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && meetings.length === 0 && (
|
||||
<>
|
||||
<SkeletonRow />
|
||||
<SkeletonRow />
|
||||
<SkeletonRow />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-6 px-3 text-center">
|
||||
<p className="text-sm text-coral-400 mb-2">
|
||||
{t('skills.meetingBots.upcoming.error')}
|
||||
</p>
|
||||
<Button variant="secondary" size="xs" onClick={refresh}>
|
||||
{t('skills.meetingBots.upcoming.retry')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 px-3 text-center">
|
||||
<p className="text-sm text-content-secondary">
|
||||
{t('skills.meetingBots.upcoming.empty')}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{groups.flatMap(group => [
|
||||
/* Date-group separator row */
|
||||
<tr key={`gh-${group.label}`}>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="py-1 px-3 text-xs font-semibold text-content-secondary uppercase tracking-wide bg-surface-hover/50 border-b border-line/30">
|
||||
{group.label}
|
||||
</td>
|
||||
</tr>,
|
||||
/* Meeting rows for this group */
|
||||
...group.meetings.map(m => {
|
||||
const policy: JoinPolicy =
|
||||
(joinPolicies[m.calendar_event_id] as JoinPolicy | undefined) ??
|
||||
(m.join_policy as JoinPolicy) ??
|
||||
'ask';
|
||||
return (
|
||||
<MeetingRow
|
||||
key={m.calendar_event_id}
|
||||
meeting={m}
|
||||
joinPolicy={policy}
|
||||
onJoinPolicyChange={v => handleJoinPolicyChange(m.calendar_event_id, v)}
|
||||
onJoin={handleJoin}
|
||||
joining={joiningId === m.calendar_event_id}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
])}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { cleanup, fireEvent, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MeetCallActionItem } from '../../../services/meetCallService';
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import ActionItemChecklist from '../ActionItemChecklist';
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const executableItem: MeetCallActionItem = {
|
||||
description: 'Schedule follow-up meeting',
|
||||
kind: 'executable',
|
||||
tool_name: 'calendar',
|
||||
assignee: 'Alice',
|
||||
};
|
||||
|
||||
const advisoryItem: MeetCallActionItem = {
|
||||
description: 'Review the proposal',
|
||||
kind: 'advisory',
|
||||
tool_name: null,
|
||||
assignee: 'Bob',
|
||||
};
|
||||
|
||||
describe('ActionItemChecklist', () => {
|
||||
it('renders nothing when items list is empty', () => {
|
||||
const { container } = renderWithProviders(<ActionItemChecklist items={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Run with OpenHuman button for executable items', () => {
|
||||
renderWithProviders(<ActionItemChecklist items={[executableItem]} />);
|
||||
expect(screen.getByText('Run with OpenHuman')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show Run with OpenHuman button for advisory items', () => {
|
||||
renderWithProviders(<ActionItemChecklist items={[advisoryItem]} />);
|
||||
expect(screen.queryByText('Run with OpenHuman')).toBeNull();
|
||||
});
|
||||
|
||||
it('navigates to /chat when Run with OpenHuman is clicked', () => {
|
||||
renderWithProviders(<ActionItemChecklist items={[executableItem]} />);
|
||||
fireEvent.click(screen.getByText('Run with OpenHuman'));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/chat');
|
||||
});
|
||||
|
||||
it('toggles checkbox on click (cosmetic only)', () => {
|
||||
renderWithProviders(<ActionItemChecklist items={[executableItem]} />);
|
||||
const checkbox = screen.getByRole('checkbox', { name: executableItem.description });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('renders assignee and tool_name metadata for executable items', () => {
|
||||
renderWithProviders(<ActionItemChecklist items={[executableItem]} />);
|
||||
expect(screen.getByText(/Alice/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/calendar/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders assignee metadata for advisory items but no tool_name', () => {
|
||||
renderWithProviders(<ActionItemChecklist items={[advisoryItem]} />);
|
||||
expect(screen.getByText(/Bob/)).toBeInTheDocument();
|
||||
// Advisory items don't show tool_name in metadata even if present
|
||||
expect(screen.queryByText('Run with OpenHuman')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders description text', () => {
|
||||
renderWithProviders(<ActionItemChecklist items={[executableItem, advisoryItem]} />);
|
||||
expect(screen.getByText(executableItem.description)).toBeInTheDocument();
|
||||
expect(screen.getByText(advisoryItem.description)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { setBackendMeetJoined, setBackendMeetLeft } from '../../../store/backendMeetSlice';
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import { ActiveMeetingBanner } from '../ActiveMeetingBanner';
|
||||
|
||||
const leaveMock = vi.fn();
|
||||
|
||||
vi.mock('../../../services/meetCallService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../services/meetCallService')>(
|
||||
'../../../services/meetCallService'
|
||||
);
|
||||
return { ...actual, leaveBackendMeetBot: (...args: unknown[]) => leaveMock(...args) };
|
||||
});
|
||||
|
||||
// RiveMascot is heavy — stub it out
|
||||
vi.mock('../../../features/human/Mascot', () => ({
|
||||
RiveMascot: ({ face }: { face: string }) => <div data-testid="rive-mascot" data-face={face} />,
|
||||
}));
|
||||
|
||||
const joiningState = {
|
||||
backendMeet: {
|
||||
status: 'joining' as const,
|
||||
meetUrl: 'https://meet.google.com/abc-defg-hij',
|
||||
meetingId: 'test-id',
|
||||
listenOnly: false,
|
||||
lastReply: null,
|
||||
lastHarness: null,
|
||||
transcript: null,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
|
||||
const activeState = {
|
||||
backendMeet: {
|
||||
status: 'active' as const,
|
||||
meetUrl: 'https://meet.google.com/abc-defg-hij',
|
||||
meetingId: 'test-id',
|
||||
listenOnly: false,
|
||||
lastReply: null,
|
||||
lastHarness: null,
|
||||
transcript: null,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe('ActiveMeetingBanner', () => {
|
||||
beforeEach(() => {
|
||||
leaveMock.mockReset();
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders joining state with LIVE badge', () => {
|
||||
renderWithProviders(<ActiveMeetingBanner />, { preloadedState: joiningState });
|
||||
|
||||
expect(screen.getByText(/live/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/joining/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders active state with Leave button', () => {
|
||||
renderWithProviders(<ActiveMeetingBanner />, { preloadedState: activeState });
|
||||
|
||||
expect(screen.getByRole('button', { name: /leave/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows meeting code in active state', () => {
|
||||
renderWithProviders(<ActiveMeetingBanner />, { preloadedState: activeState });
|
||||
|
||||
expect(screen.getByText(/abc-defg-hij/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls leaveBackendMeetBot when Leave is clicked', async () => {
|
||||
leaveMock.mockResolvedValueOnce(undefined);
|
||||
renderWithProviders(<ActiveMeetingBanner />, { preloadedState: activeState });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /leave/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(leaveMock).toHaveBeenCalledWith('user-requested');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders ended state with Close button', () => {
|
||||
const { store } = renderWithProviders(<ActiveMeetingBanner />, { preloadedState: activeState });
|
||||
|
||||
store.dispatch(setBackendMeetLeft({ reason: 'done' }));
|
||||
|
||||
// After ended, "Leave" disappears and "Close" appears
|
||||
waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /leave/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders error state with Close button', () => {
|
||||
const errorState = {
|
||||
backendMeet: {
|
||||
status: 'error' as const,
|
||||
meetUrl: 'https://meet.google.com/abc-defg-hij',
|
||||
meetingId: 'test-id',
|
||||
listenOnly: false,
|
||||
lastReply: null,
|
||||
lastHarness: null,
|
||||
transcript: null,
|
||||
error: 'Failed to connect.',
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(<ActiveMeetingBanner />, { preloadedState: errorState });
|
||||
|
||||
expect(screen.getByText(/failed to join/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows listening status when listenOnly is active', () => {
|
||||
const listenState = {
|
||||
backendMeet: {
|
||||
status: 'active' as const,
|
||||
meetUrl: 'https://meet.google.com/abc-defg-hij',
|
||||
meetingId: 'test-id',
|
||||
listenOnly: true,
|
||||
lastReply: null,
|
||||
lastHarness: null,
|
||||
transcript: null,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(<ActiveMeetingBanner />, { preloadedState: listenState });
|
||||
|
||||
expect(screen.getByText(/listening/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onToast with error when leave fails', async () => {
|
||||
leaveMock.mockRejectedValueOnce(new Error('Network error'));
|
||||
const onToast = vi.fn();
|
||||
|
||||
renderWithProviders(<ActiveMeetingBanner onToast={onToast} />, { preloadedState: activeState });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /leave/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
|
||||
});
|
||||
});
|
||||
|
||||
it('transitions mascot face based on state', () => {
|
||||
const { store } = renderWithProviders(<ActiveMeetingBanner />, {
|
||||
preloadedState: joiningState,
|
||||
});
|
||||
|
||||
// Joining → thinking face
|
||||
expect(screen.getByTestId('rive-mascot')).toHaveAttribute('data-face', 'thinking');
|
||||
|
||||
// Active → idle (no replies yet)
|
||||
store.dispatch(setBackendMeetJoined({ meetUrl: 'https://meet.google.com/abc-defg-hij' }));
|
||||
waitFor(() => {
|
||||
expect(screen.getByTestId('rive-mascot')).toHaveAttribute('data-face', 'idle');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import { act, cleanup, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MeetCallDetail, MeetCallRecord } from '../../../services/meetCallService';
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import HistoryDetail from '../HistoryDetail';
|
||||
|
||||
const getMeetCallDetailMock = vi.fn();
|
||||
|
||||
vi.mock('../../../services/meetCallService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../services/meetCallService')>(
|
||||
'../../../services/meetCallService'
|
||||
);
|
||||
return { ...actual, getMeetCallDetail: (...args: unknown[]) => getMeetCallDetailMock(...args) };
|
||||
});
|
||||
|
||||
// Also mock ActionItemChecklist and TranscriptViewer for isolation
|
||||
vi.mock('../ActionItemChecklist', () => ({
|
||||
default: ({ items }: { items: unknown[] }) => (
|
||||
<div data-testid="action-items">action-items:{items.length}</div>
|
||||
),
|
||||
ActionItemChecklist: ({ items }: { items: unknown[] }) => (
|
||||
<div data-testid="action-items">action-items:{items.length}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../TranscriptViewer', () => ({
|
||||
default: ({ lines }: { lines: unknown[] }) => (
|
||||
<div data-testid="transcript">transcript:{lines.length}</div>
|
||||
),
|
||||
TranscriptViewer: ({ lines }: { lines: unknown[] }) => (
|
||||
<div data-testid="transcript">transcript:{lines.length}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const record: MeetCallRecord = {
|
||||
request_id: 'req-1',
|
||||
meet_url: 'https://meet.google.com/abc-def-ghi',
|
||||
bot_display_name: 'OpenHuman',
|
||||
owner_display_name: 'Alice',
|
||||
started_at_ms: 1700000000000,
|
||||
ended_at_ms: 1700000600000,
|
||||
listened_seconds: 300,
|
||||
spoken_seconds: 300,
|
||||
turn_count: 5,
|
||||
participants: ['Alice', 'Bob'],
|
||||
};
|
||||
|
||||
const detailWithSummary: MeetCallDetail = {
|
||||
request_id: 'req-1',
|
||||
summary: {
|
||||
headline: 'Great meeting',
|
||||
key_points: ['Point 1', 'Point 2'],
|
||||
action_items: [
|
||||
{ description: 'Do something', kind: 'executable', tool_name: 'calendar', assignee: 'Alice' },
|
||||
],
|
||||
},
|
||||
transcript: [
|
||||
{ role: 'participant', content: '[0:01] [Alice] Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
],
|
||||
};
|
||||
|
||||
const detailNoSummary: MeetCallDetail = {
|
||||
request_id: 'req-1',
|
||||
summary: null,
|
||||
transcript: [{ role: 'participant', content: 'Plain transcript line' }],
|
||||
};
|
||||
|
||||
describe('HistoryDetail', () => {
|
||||
it('shows select prompt when record is null', () => {
|
||||
renderWithProviders(<HistoryDetail record={null} />);
|
||||
expect(
|
||||
screen.getByText('Select a call to see its summary and transcript.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state while detail is being fetched', async () => {
|
||||
// Never resolve
|
||||
getMeetCallDetailMock.mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<HistoryDetail record={record} />);
|
||||
expect(await screen.findByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders summary and transcript on success', async () => {
|
||||
getMeetCallDetailMock.mockResolvedValue(detailWithSummary);
|
||||
renderWithProviders(<HistoryDetail record={record} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('action-items')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('transcript')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders key points and headline from summary', async () => {
|
||||
getMeetCallDetailMock.mockResolvedValue(detailWithSummary);
|
||||
renderWithProviders(<HistoryDetail record={record} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Great meeting')).toBeInTheDocument();
|
||||
expect(screen.getByText('Point 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error state when fetch fails', async () => {
|
||||
getMeetCallDetailMock.mockRejectedValue(new Error('network error'));
|
||||
renderWithProviders(<HistoryDetail record={record} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/retry/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when detail has no summary or transcript', async () => {
|
||||
const emptyDetail: MeetCallDetail = { request_id: 'req-1', summary: null, transcript: [] };
|
||||
getMeetCallDetailMock.mockResolvedValue(emptyDetail);
|
||||
renderWithProviders(<HistoryDetail record={record} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/nothing captured|no transcript|nothing|empty/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders meeting code from URL', async () => {
|
||||
getMeetCallDetailMock.mockResolvedValue(detailWithSummary);
|
||||
renderWithProviders(<HistoryDetail record={record} />);
|
||||
// meeting code = pathname stripped of leading slash
|
||||
expect(screen.getByText('abc-def-ghi')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders participant count', async () => {
|
||||
getMeetCallDetailMock.mockResolvedValue(detailWithSummary);
|
||||
renderWithProviders(<HistoryDetail record={record} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/2 participants/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows transcript without summary when summary is null', async () => {
|
||||
getMeetCallDetailMock.mockResolvedValue(detailNoSummary);
|
||||
renderWithProviders(<HistoryDetail record={record} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('transcript')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('action-items')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('reloads when record changes', async () => {
|
||||
getMeetCallDetailMock.mockResolvedValue(detailWithSummary);
|
||||
const { rerender } = renderWithProviders(<HistoryDetail record={record} />);
|
||||
await waitFor(() => expect(getMeetCallDetailMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const record2: MeetCallRecord = { ...record, request_id: 'req-2' };
|
||||
getMeetCallDetailMock.mockResolvedValue({ ...detailWithSummary, request_id: 'req-2' });
|
||||
rerender(<HistoryDetail record={record2} />);
|
||||
await waitFor(() => expect(getMeetCallDetailMock).toHaveBeenCalledWith('req-2'));
|
||||
});
|
||||
|
||||
// ── Stale response guard (#1) ──────────────────────────────────────────────
|
||||
|
||||
it('ignores a stale getMeetCallDetail response when the user switches records quickly', async () => {
|
||||
// record1's fetch is intentionally slow so it resolves AFTER record2.
|
||||
let resolveRecord1!: (v: MeetCallDetail) => void;
|
||||
const record1Detail: MeetCallDetail = {
|
||||
request_id: 'req-1',
|
||||
summary: { headline: 'Record 1 Stale', key_points: [], action_items: [] },
|
||||
transcript: [],
|
||||
};
|
||||
const record2Detail: MeetCallDetail = {
|
||||
request_id: 'req-2',
|
||||
summary: { headline: 'Record 2 Fresh', key_points: [], action_items: [] },
|
||||
transcript: [],
|
||||
};
|
||||
|
||||
// Route mock responses by request_id so call order doesn't matter.
|
||||
getMeetCallDetailMock.mockImplementation((reqId: string) => {
|
||||
if (reqId === 'req-1') {
|
||||
return new Promise<MeetCallDetail>(r => {
|
||||
resolveRecord1 = r;
|
||||
});
|
||||
}
|
||||
return Promise.resolve(record2Detail);
|
||||
});
|
||||
|
||||
const record1: MeetCallRecord = { ...record, request_id: 'req-1' };
|
||||
const record2: MeetCallRecord = { ...record, request_id: 'req-2' };
|
||||
|
||||
const { rerender } = renderWithProviders(<HistoryDetail record={record1} />);
|
||||
|
||||
// Wait for the 0ms timer to fire so the req-1 fetch is actually in-flight.
|
||||
await waitFor(() => {
|
||||
expect(getMeetCallDetailMock).toHaveBeenCalledWith('req-1');
|
||||
});
|
||||
|
||||
// Switch to record2 while record1's fetch is still pending.
|
||||
rerender(<HistoryDetail record={record2} />);
|
||||
|
||||
// record2's fetch resolves immediately — wait for its headline to appear.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Record 2 Fresh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Now deliver the stale record1 response.
|
||||
await act(async () => {
|
||||
resolveRecord1(record1Detail);
|
||||
await Promise.resolve(); // flush microtasks
|
||||
});
|
||||
|
||||
// Stale data must not overwrite the current record2 display.
|
||||
expect(screen.queryByText('Record 1 Stale')).toBeNull();
|
||||
expect(screen.getByText('Record 2 Fresh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Retry-once guard (#2) ──────────────────────────────────────────────────
|
||||
|
||||
it('auto-retry fires at most once per record when detail loads with no summary', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const noSummaryDetail: MeetCallDetail = {
|
||||
request_id: 'req-1',
|
||||
summary: null,
|
||||
transcript: [],
|
||||
};
|
||||
getMeetCallDetailMock.mockResolvedValue(noSummaryDetail);
|
||||
|
||||
renderWithProviders(<HistoryDetail record={record} />);
|
||||
|
||||
// Advance past the 0ms initial-fetch timer and flush the async resolution.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
expect(getMeetCallDetailMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance 2 s to trigger the one auto-retry.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
expect(getMeetCallDetailMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Advance another 2 s — retry must NOT fire again.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
expect(getMeetCallDetailMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Fast switching shows correct transcript (#1 / #3) ─────────────────────
|
||||
|
||||
it('shows the correct transcript and ignores stale content after rapid record switch', async () => {
|
||||
let resolveEarly!: (v: MeetCallDetail) => void;
|
||||
|
||||
const earlyDetail: MeetCallDetail = {
|
||||
request_id: 'req-early',
|
||||
summary: null,
|
||||
transcript: [{ role: 'participant', content: 'EARLY STALE CONTENT' }],
|
||||
};
|
||||
const lateDetail: MeetCallDetail = {
|
||||
request_id: 'req-late',
|
||||
summary: null,
|
||||
transcript: [{ role: 'participant', content: 'LATE FRESH CONTENT' }],
|
||||
};
|
||||
|
||||
getMeetCallDetailMock.mockImplementation((reqId: string) => {
|
||||
if (reqId === 'req-early') {
|
||||
return new Promise(r => {
|
||||
resolveEarly = r;
|
||||
});
|
||||
}
|
||||
return Promise.resolve(lateDetail);
|
||||
});
|
||||
|
||||
const earlyRecord: MeetCallRecord = {
|
||||
...record,
|
||||
request_id: 'req-early',
|
||||
meet_url: 'https://meet.google.com/early-abc',
|
||||
};
|
||||
const lateRecord: MeetCallRecord = {
|
||||
...record,
|
||||
request_id: 'req-late',
|
||||
meet_url: 'https://meet.google.com/late-xyz',
|
||||
};
|
||||
|
||||
const { rerender } = renderWithProviders(<HistoryDetail record={earlyRecord} />);
|
||||
|
||||
// Wait for the early fetch to start.
|
||||
await waitFor(() => expect(getMeetCallDetailMock).toHaveBeenCalledWith('req-early'));
|
||||
|
||||
// Switch while early is still in-flight.
|
||||
rerender(<HistoryDetail record={lateRecord} />);
|
||||
|
||||
// Late (current) record's transcript should appear.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('transcript')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Deliver the stale early response.
|
||||
await act(async () => {
|
||||
resolveEarly(earlyDetail);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Stale early content must not replace the live late content.
|
||||
expect(screen.queryByText('EARLY STALE CONTENT')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { cleanup, fireEvent, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MeetCallRecord } from '../../../services/meetCallService';
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import HistoryRail, { type CallGroup } from '../HistoryRail';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const call1: MeetCallRecord = {
|
||||
request_id: 'req-1',
|
||||
meet_url: 'https://meet.google.com/abc-def-ghi',
|
||||
bot_display_name: 'OpenHuman',
|
||||
owner_display_name: 'Alice',
|
||||
started_at_ms: Date.now() - 3600000,
|
||||
ended_at_ms: Date.now() - 3000000,
|
||||
listened_seconds: 300,
|
||||
spoken_seconds: 60,
|
||||
turn_count: 5,
|
||||
participants: ['Alice', 'Bob'],
|
||||
};
|
||||
|
||||
const call2: MeetCallRecord = {
|
||||
request_id: 'req-2',
|
||||
meet_url: 'https://zoom.us/j/123456',
|
||||
bot_display_name: 'OpenHuman',
|
||||
owner_display_name: 'Carol',
|
||||
started_at_ms: Date.now() - 86400000 - 3600000,
|
||||
ended_at_ms: Date.now() - 86400000 - 3000000,
|
||||
listened_seconds: 120,
|
||||
spoken_seconds: 30,
|
||||
turn_count: 2,
|
||||
participants: ['Carol'],
|
||||
};
|
||||
|
||||
const groups: CallGroup[] = [
|
||||
{ label: 'Today', calls: [call1] },
|
||||
{ label: 'Yesterday', calls: [call2] },
|
||||
];
|
||||
|
||||
function renderRail(overrides?: Partial<Parameters<typeof HistoryRail>[0]>) {
|
||||
const props = {
|
||||
groups,
|
||||
selectedId: null,
|
||||
onSelect: vi.fn(),
|
||||
searchQuery: '',
|
||||
onSearchChange: vi.fn(),
|
||||
platformFilter: '',
|
||||
onPlatformChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return { ...renderWithProviders(<HistoryRail {...props} />), props };
|
||||
}
|
||||
|
||||
describe('HistoryRail', () => {
|
||||
it('renders group labels', () => {
|
||||
renderRail();
|
||||
expect(screen.getByText('Today')).toBeInTheDocument();
|
||||
expect(screen.getByText('Yesterday')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders meeting codes for each call', () => {
|
||||
renderRail();
|
||||
expect(screen.getByText('abc-def-ghi')).toBeInTheDocument();
|
||||
expect(screen.getByText('j/123456')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders platform logo for google meet', () => {
|
||||
renderRail();
|
||||
const imgs = screen.getAllByRole('img');
|
||||
const gmeetImg = imgs.find(img => (img as HTMLImageElement).alt === 'Google Meet');
|
||||
expect(gmeetImg).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders turn count for each call', () => {
|
||||
renderRail();
|
||||
// turn_count=5 should show plural "5 turns"
|
||||
expect(screen.getByText(/5 turn/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onSelect when a row is clicked', () => {
|
||||
const onSelect = vi.fn();
|
||||
renderRail({ onSelect });
|
||||
// The first button is now the platform-filter toggle; pick the call row by
|
||||
// its meeting code instead.
|
||||
const rowButton = screen
|
||||
.getAllByRole('button')
|
||||
.find(b => b.textContent?.includes('abc-def-ghi'));
|
||||
expect(rowButton).toBeDefined();
|
||||
fireEvent.click(rowButton!);
|
||||
expect(onSelect).toHaveBeenCalledWith('req-1');
|
||||
});
|
||||
|
||||
it('highlights the selected row', () => {
|
||||
const { container } = renderRail({ selectedId: 'req-1' });
|
||||
const selectedBtn = container.querySelector('button.bg-primary-50');
|
||||
expect(selectedBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it('reflects search query in the input', () => {
|
||||
renderRail({ searchQuery: 'abc' });
|
||||
const input = screen.getByRole('searchbox') as HTMLInputElement;
|
||||
expect(input.value).toBe('abc');
|
||||
});
|
||||
|
||||
it('calls onSearchChange when search input changes', () => {
|
||||
const onSearchChange = vi.fn();
|
||||
renderRail({ onSearchChange });
|
||||
const input = screen.getByRole('searchbox');
|
||||
fireEvent.change(input, { target: { value: 'zoom' } });
|
||||
expect(onSearchChange).toHaveBeenCalledWith('zoom');
|
||||
});
|
||||
|
||||
it('shows platform options (with names) only after opening the filter menu', () => {
|
||||
renderRail();
|
||||
// Collapsed: the filter is icon-only — names are not shown yet.
|
||||
expect(screen.queryByText('All platforms')).not.toBeInTheDocument();
|
||||
// Open the menu via the filter button.
|
||||
fireEvent.click(screen.getByRole('button', { name: /all platforms/i }));
|
||||
expect(screen.getByText('All platforms')).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /zoom/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onPlatformChange when a platform is picked from the menu', () => {
|
||||
const onPlatformChange = vi.fn();
|
||||
renderRail({ onPlatformChange });
|
||||
fireEvent.click(screen.getByRole('button', { name: /all platforms/i }));
|
||||
fireEvent.click(screen.getByRole('option', { name: /zoom/i }));
|
||||
expect(onPlatformChange).toHaveBeenCalledWith('zoom');
|
||||
});
|
||||
|
||||
it('renders empty state when no groups have calls', () => {
|
||||
renderRail({ groups: [{ label: 'Today', calls: [] }] });
|
||||
expect(screen.getByText(/no.*call/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MeetCallDetail, MeetCallRecord } from '../../../services/meetCallService';
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import HistorySection from '../HistorySection';
|
||||
|
||||
const listMeetCallsMock = vi.fn();
|
||||
const getMeetCallDetailMock = vi.fn();
|
||||
|
||||
vi.mock('../../../services/meetCallService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../services/meetCallService')>(
|
||||
'../../../services/meetCallService'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listMeetCalls: (...args: unknown[]) => listMeetCallsMock(...args),
|
||||
getMeetCallDetail: (...args: unknown[]) => getMeetCallDetailMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// A call is now auto-selected by default, so the detail pane mounts and calls
|
||||
// getMeetCallDetail in every populated test — give it a benign default.
|
||||
beforeEach(() => {
|
||||
getMeetCallDetailMock.mockResolvedValue(detail);
|
||||
});
|
||||
|
||||
const NOW = Date.now();
|
||||
|
||||
const todayCall: MeetCallRecord = {
|
||||
request_id: 'req-today',
|
||||
meet_url: 'https://meet.google.com/abc-def-ghi',
|
||||
bot_display_name: 'OpenHuman',
|
||||
owner_display_name: 'Alice',
|
||||
started_at_ms: NOW - 3600000,
|
||||
ended_at_ms: NOW - 3000000,
|
||||
listened_seconds: 300,
|
||||
spoken_seconds: 60,
|
||||
turn_count: 5,
|
||||
participants: ['Alice'],
|
||||
};
|
||||
|
||||
const yesterdayCall: MeetCallRecord = {
|
||||
request_id: 'req-yesterday',
|
||||
meet_url: 'https://zoom.us/j/999888',
|
||||
bot_display_name: 'OpenHuman',
|
||||
owner_display_name: 'Bob',
|
||||
started_at_ms: NOW - 86400000 - 3600000,
|
||||
ended_at_ms: NOW - 86400000 - 3000000,
|
||||
listened_seconds: 120,
|
||||
spoken_seconds: 30,
|
||||
turn_count: 2,
|
||||
participants: ['Bob'],
|
||||
};
|
||||
|
||||
const detail: MeetCallDetail = {
|
||||
request_id: 'req-today',
|
||||
summary: { headline: 'Sync meeting', key_points: [], action_items: [] },
|
||||
transcript: [{ role: 'participant', content: 'Hello' }],
|
||||
};
|
||||
|
||||
describe('HistorySection', () => {
|
||||
it('shows loading state while fetching', async () => {
|
||||
listMeetCallsMock.mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<HistorySection />);
|
||||
expect(await screen.findByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no calls returned', async () => {
|
||||
listMeetCallsMock.mockResolvedValue([]);
|
||||
renderWithProviders(<HistorySection />);
|
||||
await waitFor(() => {
|
||||
// HistoryRail shows the i18n empty text when all groups have no calls
|
||||
expect(
|
||||
screen.getByText(/no previous calls yet|your meeting history will appear|no.*call/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders grouped calls', async () => {
|
||||
listMeetCallsMock.mockResolvedValue([todayCall, yesterdayCall]);
|
||||
renderWithProviders(<HistorySection />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Today')).toBeInTheDocument();
|
||||
expect(screen.getByText('Yesterday')).toBeInTheDocument();
|
||||
// abc-def-ghi is auto-selected, so it appears in both the rail and the
|
||||
// detail header — assert at least one occurrence.
|
||||
expect(screen.getAllByText('abc-def-ghi').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('j/999888')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows detail pane when a call is selected', async () => {
|
||||
listMeetCallsMock.mockResolvedValue([todayCall]);
|
||||
getMeetCallDetailMock.mockResolvedValue(detail);
|
||||
renderWithProviders(<HistorySection />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Auto-selected → detail pane fetches it without a manual click.
|
||||
expect(getMeetCallDetailMock).toHaveBeenCalledWith('req-today');
|
||||
});
|
||||
});
|
||||
|
||||
it('filters calls by search query', async () => {
|
||||
listMeetCallsMock.mockResolvedValue([todayCall, yesterdayCall]);
|
||||
renderWithProviders(<HistorySection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('abc-def-ghi').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Search for the Zoom meeting's code (part of the URL path)
|
||||
const searchInput = screen.getByRole('searchbox');
|
||||
fireEvent.change(searchInput, { target: { value: '999888' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('abc-def-ghi')).toBeNull();
|
||||
expect(screen.getAllByText('j/999888').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('filters calls by platform', async () => {
|
||||
listMeetCallsMock.mockResolvedValue([todayCall, yesterdayCall]);
|
||||
renderWithProviders(<HistorySection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('abc-def-ghi').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Open the compact platform filter menu and pick Google Meet.
|
||||
fireEvent.click(screen.getByRole('button', { name: /all platforms/i }));
|
||||
fireEvent.click(screen.getByRole('option', { name: /google meet/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('abc-def-ghi').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('j/999888')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error state when listMeetCalls throws', async () => {
|
||||
listMeetCallsMock.mockRejectedValue(new Error('network error'));
|
||||
renderWithProviders(<HistorySection />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/network error/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Selection clearing when filter empties list (#4) ──────────────────────
|
||||
|
||||
it('clears the selection when a search query matches no records', async () => {
|
||||
listMeetCallsMock.mockResolvedValue([todayCall]);
|
||||
renderWithProviders(<HistorySection />);
|
||||
|
||||
// Wait for auto-selection and detail render.
|
||||
await waitFor(() => {
|
||||
// abc-def-ghi appears in the rail and/or detail pane header.
|
||||
expect(screen.getAllByText('abc-def-ghi').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Search for a term that matches nothing.
|
||||
const searchInput = screen.getByRole('searchbox');
|
||||
fireEvent.change(searchInput, { target: { value: 'no-match-xyz-999' } });
|
||||
|
||||
// Detail pane should show the "select a call" placeholder when selection clears.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Select a call to see its summary and transcript.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cleanup, fireEvent, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import { JoinPolicyToggle } from '../JoinPolicyToggle';
|
||||
|
||||
describe('JoinPolicyToggle', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders three segments: Auto, Ask, Skip', () => {
|
||||
renderWithProviders(<JoinPolicyToggle value="ask" onChange={vi.fn()} />);
|
||||
expect(screen.getByRole('radio', { name: /auto/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /ask/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /skip/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the active segment as checked', () => {
|
||||
renderWithProviders(<JoinPolicyToggle value="auto" onChange={vi.fn()} />);
|
||||
expect(screen.getByRole('radio', { name: /auto/i })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('radio', { name: /ask/i })).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByRole('radio', { name: /skip/i })).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('calls onChange with the new value when a segment is clicked', () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<JoinPolicyToggle value="ask" onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole('radio', { name: /skip/i }));
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
expect(onChange).toHaveBeenCalledWith('skip');
|
||||
});
|
||||
|
||||
it('does not call onChange when clicking the already-active segment', () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<JoinPolicyToggle value="auto" onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole('radio', { name: /auto/i }));
|
||||
// Still fires — the parent can decide to ignore same-value changes.
|
||||
expect(onChange).toHaveBeenCalledWith('auto');
|
||||
});
|
||||
|
||||
it('disables all buttons when disabled=true', () => {
|
||||
renderWithProviders(<JoinPolicyToggle value="ask" onChange={vi.fn()} disabled />);
|
||||
const buttons = screen.getAllByRole('radio');
|
||||
expect(buttons).toHaveLength(3);
|
||||
buttons.forEach(btn => expect(btn).toBeDisabled());
|
||||
});
|
||||
|
||||
it('renders the radiogroup with a label', () => {
|
||||
renderWithProviders(<JoinPolicyToggle value="ask" onChange={vi.fn()} />);
|
||||
// The radiogroup aria-label comes from the i18n key.
|
||||
const group = screen.getByRole('radiogroup');
|
||||
expect(group).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { createRef } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { setBackendMeetError } from '../../../store/backendMeetSlice';
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import { MeetComposer } from '../MeetComposer';
|
||||
|
||||
type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string };
|
||||
|
||||
const joinMock = vi.fn();
|
||||
const listMock = vi.fn();
|
||||
|
||||
vi.mock('../../../services/meetCallService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../services/meetCallService')>(
|
||||
'../../../services/meetCallService'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
joinMeetViaBackendBot: (...args: unknown[]) => joinMock(...args),
|
||||
listMeetCalls: (...args: unknown[]) => listMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const mockConnectionByToolkit = vi.fn(
|
||||
() => new Map<string, { id: string; toolkit: string; status: string; accountEmail?: string }>()
|
||||
);
|
||||
|
||||
vi.mock('../../../lib/composio/hooks', () => ({
|
||||
useComposioIntegrations: () => ({
|
||||
toolkits: [],
|
||||
connectionByToolkit: mockConnectionByToolkit(),
|
||||
connectionsByToolkit: new Map(),
|
||||
catalogByToolkit: new Map(),
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderComposer(props: { onToast?: ReturnType<typeof vi.fn> } = {}) {
|
||||
const hasSubmittedRef = createRef<boolean>() as React.MutableRefObject<boolean>;
|
||||
hasSubmittedRef.current = false;
|
||||
// Cast the vitest mock to the expected callback type — vi.fn() returns a
|
||||
// broad Mock type that TypeScript doesn't automatically narrow to the
|
||||
// specific callback signature the component expects.
|
||||
const onToast = props.onToast as unknown as ((toast: Toast) => void) | undefined;
|
||||
const result = renderWithProviders(
|
||||
<MeetComposer hasSubmittedRef={hasSubmittedRef} onToast={onToast} />
|
||||
);
|
||||
return { ...result, hasSubmittedRef };
|
||||
}
|
||||
|
||||
describe('MeetComposer', () => {
|
||||
beforeEach(() => {
|
||||
joinMock.mockReset();
|
||||
listMock.mockReset();
|
||||
listMock.mockResolvedValue([]);
|
||||
mockConnectionByToolkit.mockReturnValue(new Map());
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders the join form with meeting link and name inputs', () => {
|
||||
renderComposer();
|
||||
expect(screen.getByLabelText(/meeting link/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/your name in this meeting/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to Google Meet and shows the correct submit label', () => {
|
||||
renderComposer();
|
||||
// Submit button label starts with "Send to Google Meet"
|
||||
expect(screen.getByRole('button', { name: /send to google meet/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates submit label and URL placeholder when switching platforms', async () => {
|
||||
renderComposer();
|
||||
|
||||
// Switch to Zoom
|
||||
const zoomChip = screen.getByRole('radio', { name: /zoom/i });
|
||||
fireEvent.click(zoomChip);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /send to zoom/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const urlInput = screen.getByLabelText(/meeting link/i);
|
||||
expect(urlInput).toHaveAttribute('placeholder', 'zoom.us/j/...');
|
||||
});
|
||||
|
||||
it('updates submit label and URL placeholder for Webex', async () => {
|
||||
renderComposer();
|
||||
|
||||
const webexChip = screen.getByRole('radio', { name: /webex/i });
|
||||
fireEvent.click(webexChip);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /send to webex/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const urlInput = screen.getByLabelText(/meeting link/i);
|
||||
expect(urlInput).toHaveAttribute('placeholder', 'webex.com/meet/...');
|
||||
});
|
||||
|
||||
it('prefills the name field from a connected Composio account', async () => {
|
||||
mockConnectionByToolkit.mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
'googlemeet',
|
||||
{
|
||||
id: 'c1',
|
||||
toolkit: 'googlemeet',
|
||||
status: 'ACTIVE',
|
||||
accountEmail: 'alice.smith@gmail.com',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
renderComposer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/your name in this meeting/i)).toHaveValue('Alice Smith');
|
||||
});
|
||||
});
|
||||
|
||||
it('re-derives name from platform-specific account when platform changes (untouched)', async () => {
|
||||
mockConnectionByToolkit.mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
'googlemeet',
|
||||
{
|
||||
id: 'c1',
|
||||
toolkit: 'googlemeet',
|
||||
status: 'ACTIVE',
|
||||
accountEmail: 'alice.gmeet@company.com',
|
||||
},
|
||||
],
|
||||
[
|
||||
'zoom',
|
||||
{ id: 'c2', toolkit: 'zoom', status: 'ACTIVE', accountEmail: 'alice.zoom@company.com' },
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
renderComposer();
|
||||
|
||||
// Initially fills from gmeet
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/your name in this meeting/i)).toHaveValue('Alice Gmeet');
|
||||
});
|
||||
|
||||
// Switch to zoom — should re-derive from zoom account
|
||||
fireEvent.click(screen.getByRole('radio', { name: /zoom/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/your name in this meeting/i)).toHaveValue('Alice Zoom');
|
||||
});
|
||||
});
|
||||
|
||||
it('never overwrites a manually typed name when platform changes', async () => {
|
||||
renderComposer();
|
||||
|
||||
const nameInput = screen.getByLabelText(/your name in this meeting/i);
|
||||
|
||||
// User types manually
|
||||
fireEvent.change(nameInput, { target: { value: 'Custom Name' } });
|
||||
|
||||
// Switch platform
|
||||
fireEvent.click(screen.getByRole('radio', { name: /zoom/i }));
|
||||
|
||||
// Manually typed value must not be overwritten
|
||||
expect(nameInput).toHaveValue('Custom Name');
|
||||
});
|
||||
|
||||
it('submits with the SELECTED platform (not hardcoded gmeet)', async () => {
|
||||
joinMock.mockResolvedValueOnce({ meetUrl: 'https://zoom.us/j/123', platform: 'zoom' });
|
||||
|
||||
renderComposer();
|
||||
|
||||
// Switch to Zoom
|
||||
fireEvent.click(screen.getByRole('radio', { name: /zoom/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/meeting link/i), {
|
||||
target: { value: 'https://zoom.us/j/123' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/your name in this meeting/i), {
|
||||
target: { value: 'Alice' },
|
||||
});
|
||||
const form = document.querySelector('form')!;
|
||||
fireEvent.submit(form);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(joinMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
meetUrl: 'https://zoom.us/j/123',
|
||||
platform: 'zoom',
|
||||
respondToParticipant: 'Alice',
|
||||
listenOnly: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('submits with gmeet by default (no platform switch)', async () => {
|
||||
joinMock.mockResolvedValueOnce({
|
||||
meetUrl: 'https://meet.google.com/abc-defg-hij',
|
||||
platform: 'gmeet',
|
||||
});
|
||||
|
||||
renderComposer();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/meeting link/i), {
|
||||
target: { value: 'https://meet.google.com/abc-defg-hij' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/your name in this meeting/i), {
|
||||
target: { value: 'Alice' },
|
||||
});
|
||||
const form = document.querySelector('form')!;
|
||||
fireEvent.submit(form);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(joinMock).toHaveBeenCalledWith(expect.objectContaining({ platform: 'gmeet' }));
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an inline error when the backend returns an error via Redux', async () => {
|
||||
const onToast = vi.fn();
|
||||
const { hasSubmittedRef, store } = renderComposer({ onToast });
|
||||
|
||||
// Simulate submission having been started
|
||||
hasSubmittedRef.current = true;
|
||||
|
||||
// Backend fires an error event
|
||||
store.dispatch(setBackendMeetError({ error: 'Bot failed to join.' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Bot failed to join.');
|
||||
});
|
||||
|
||||
expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
|
||||
});
|
||||
|
||||
it('shows the capacity-gate message when the server is overloaded', async () => {
|
||||
const onToast = vi.fn();
|
||||
const { hasSubmittedRef, store } = renderComposer({ onToast });
|
||||
|
||||
hasSubmittedRef.current = true;
|
||||
|
||||
store.dispatch(
|
||||
setBackendMeetError({
|
||||
error: 'Mascot streaming capacity is exhausted. Please try again later.',
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/heavy load/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an inline error when joinMeetViaBackendBot throws synchronously', async () => {
|
||||
joinMock.mockRejectedValueOnce(new Error('Network error.'));
|
||||
const onToast = vi.fn();
|
||||
const { hasSubmittedRef } = renderComposer({ onToast });
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/meeting link/i), {
|
||||
target: { value: 'https://meet.google.com/abc-defg-hij' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/your name in this meeting/i), {
|
||||
target: { value: 'Alice' },
|
||||
});
|
||||
const form = document.querySelector('form')!;
|
||||
fireEvent.submit(form);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Network error.');
|
||||
});
|
||||
|
||||
// hasSubmittedRef should be reset so a second attempt works
|
||||
expect(hasSubmittedRef.current).toBe(false);
|
||||
expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
|
||||
});
|
||||
|
||||
it('disables submit when meetUrl or name is empty', () => {
|
||||
renderComposer();
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /send to google meet/i });
|
||||
|
||||
// Both empty — disabled
|
||||
expect(submitBtn).toBeDisabled();
|
||||
|
||||
// Fill URL only
|
||||
fireEvent.change(screen.getByLabelText(/meeting link/i), {
|
||||
target: { value: 'https://meet.google.com/abc' },
|
||||
});
|
||||
expect(submitBtn).toBeDisabled();
|
||||
|
||||
// Fill name too — enabled
|
||||
fireEvent.change(screen.getByLabelText(/your name in this meeting/i), {
|
||||
target: { value: 'Alice' },
|
||||
});
|
||||
expect(submitBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import { MeetDefaultsDrawer } from '../MeetDefaultsDrawer';
|
||||
|
||||
const getMock = vi.fn();
|
||||
const updateMock = vi.fn();
|
||||
|
||||
vi.mock('../../../utils/tauriCommands', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../utils/tauriCommands')>(
|
||||
'../../../utils/tauriCommands'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
isTauri: () => true,
|
||||
openhumanGetMeetSettings: (...args: unknown[]) => getMock(...args),
|
||||
openhumanUpdateMeetSettings: (...args: unknown[]) => updateMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
result: {
|
||||
auto_orchestrator_handoff: false,
|
||||
auto_join_policy: 'ask_each_time' as const,
|
||||
auto_summarize_policy: 'ask' as const,
|
||||
listen_only_default: true,
|
||||
ingest_backend_transcripts: false,
|
||||
platform_auto_join_policies: {},
|
||||
watch_calendar: false,
|
||||
},
|
||||
};
|
||||
|
||||
describe('MeetDefaultsDrawer', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset();
|
||||
updateMock.mockReset();
|
||||
getMock.mockResolvedValue(DEFAULT_SETTINGS);
|
||||
updateMock.mockResolvedValue({ result: {} });
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('does not render when closed', () => {
|
||||
renderWithProviders(<MeetDefaultsDrawer open={false} onClose={vi.fn()} />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the drawer when open', async () => {
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(getMock).toHaveBeenCalled());
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads and displays current settings', async () => {
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument());
|
||||
// Should render global policy select and platform sections
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls update when global policy changes', async () => {
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument());
|
||||
|
||||
// Find the global auto-join select (first select) and change it
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
fireEvent.change(selects[0], { target: { value: 'always' } });
|
||||
await waitFor(() =>
|
||||
expect(updateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auto_join_policy: 'always' })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('closes via the close button', async () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={onClose} />);
|
||||
await waitFor(() => expect(getMock).toHaveBeenCalled());
|
||||
|
||||
const closeBtn = screen.getByRole('button', { name: /close/i });
|
||||
fireEvent.click(closeBtn);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls update with platform policies when platform override changes', async () => {
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument());
|
||||
|
||||
// Find the platform selects (after the global select)
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
// selects[1] is the first platform (gmeet) override
|
||||
if (selects.length > 1) {
|
||||
fireEvent.change(selects[1], { target: { value: 'always' } });
|
||||
await waitFor(() =>
|
||||
expect(updateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ platform_auto_join_policies: expect.any(Object) })
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes when backdrop is clicked', async () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={onClose} />);
|
||||
await waitFor(() => expect(getMock).toHaveBeenCalled());
|
||||
|
||||
// The backdrop div has aria-hidden="true" but should have onClick
|
||||
const backdrop = document.querySelector('[aria-hidden="true"]');
|
||||
if (backdrop) {
|
||||
fireEvent.click(backdrop);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
// ── watch_calendar master switch ──────────────────────────────────────────
|
||||
|
||||
it('renders the watch-calendar switch', async () => {
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument());
|
||||
expect(screen.getByRole('switch', { name: /watch my calendar/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reflects watch_calendar=false as unchecked', async () => {
|
||||
getMock.mockResolvedValueOnce({ ...DEFAULT_SETTINGS });
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
const sw = await screen.findByRole('switch', { name: /watch my calendar/i });
|
||||
expect(sw).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('reflects watch_calendar=true as checked', async () => {
|
||||
getMock.mockResolvedValueOnce({ result: { ...DEFAULT_SETTINGS.result, watch_calendar: true } });
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
const sw = await screen.findByRole('switch', { name: /watch my calendar/i });
|
||||
expect(sw).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('toggling the switch calls updateMeetSettings with watch_calendar', async () => {
|
||||
getMock.mockResolvedValueOnce({ ...DEFAULT_SETTINGS });
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
const sw = await screen.findByRole('switch', { name: /watch my calendar/i });
|
||||
|
||||
fireEvent.click(sw);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateMock).toHaveBeenCalledWith(expect.objectContaining({ watch_calendar: true }))
|
||||
);
|
||||
});
|
||||
|
||||
it('reverts optimistic state when update fails', async () => {
|
||||
getMock.mockResolvedValueOnce({ ...DEFAULT_SETTINGS });
|
||||
updateMock.mockReset();
|
||||
updateMock.mockRejectedValueOnce(new Error('network error'));
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
const sw = await screen.findByRole('switch', { name: /watch my calendar/i });
|
||||
|
||||
// Toggle from false → true (optimistic update)
|
||||
fireEvent.click(sw);
|
||||
// Wait for the rejection to revert the switch back to false
|
||||
await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'false'));
|
||||
});
|
||||
|
||||
// ── Finding A: load-failure state ─────────────────────────────────────────
|
||||
|
||||
it('shows error + retry when initial load fails — does NOT render controls', async () => {
|
||||
getMock.mockRejectedValueOnce(new Error('RPC timeout'));
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
|
||||
// Should eventually show error (not loading, not controls)
|
||||
await waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument());
|
||||
|
||||
// Error message is visible (the thrown error's message is surfaced directly)
|
||||
expect(screen.getByText(/rpc timeout/i)).toBeInTheDocument();
|
||||
|
||||
// Retry button is shown
|
||||
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
|
||||
|
||||
// No editable controls — no comboboxes or switches
|
||||
expect(screen.queryAllByRole('combobox')).toHaveLength(0);
|
||||
expect(screen.queryAllByRole('switch')).toHaveLength(0);
|
||||
|
||||
// No save was ever attempted
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retry re-runs the load and renders the form on success', async () => {
|
||||
// First load fails, second (retry) succeeds
|
||||
getMock
|
||||
.mockRejectedValueOnce(new Error('transient error'))
|
||||
.mockResolvedValueOnce(DEFAULT_SETTINGS);
|
||||
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
|
||||
// Wait for error state
|
||||
const retryBtn = await screen.findByRole('button', { name: /try again/i });
|
||||
expect(screen.queryAllByRole('combobox')).toHaveLength(0);
|
||||
|
||||
// Click retry
|
||||
fireEvent.click(retryBtn);
|
||||
|
||||
// After retry the form should appear
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getAllByRole('combobox').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('switch', { name: /watch my calendar/i })).toBeInTheDocument();
|
||||
|
||||
// No save was fired during the error phase
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Finding B: per-setting sequence isolation ──────────────────────────────
|
||||
|
||||
it('per-setting revert: failed save for A is not masked by succeeded save for B', async () => {
|
||||
getMock.mockResolvedValueOnce({ ...DEFAULT_SETTINGS });
|
||||
|
||||
// Save A (auto_join_policy) is a controlled promise that we will reject later
|
||||
let rejectAutoJoin!: (e: Error) => void;
|
||||
const autoJoinPromise = new Promise<{ result: object }>((_, rej) => {
|
||||
rejectAutoJoin = rej;
|
||||
});
|
||||
|
||||
// First updateMock call (auto_join_policy) → controlled; subsequent calls resolve immediately
|
||||
updateMock.mockReturnValueOnce(autoJoinPromise).mockResolvedValue({ result: {} });
|
||||
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument());
|
||||
|
||||
// Optimistically change setting A (auto_join_policy: 'ask_each_time' → 'always')
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
fireEvent.change(selects[0], { target: { value: 'always' } });
|
||||
|
||||
// Optimistically change setting B (listen_only: true → false)
|
||||
const listenSwitch = screen.getByRole('switch', { name: /listen.only/i });
|
||||
fireEvent.click(listenSwitch);
|
||||
|
||||
// Wait for both saves to have been dispatched
|
||||
await waitFor(() => expect(updateMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
// Save B (listen_only) has already resolved successfully; now reject save A
|
||||
rejectAutoJoin(new Error('network error'));
|
||||
|
||||
// Setting A should revert to its original value
|
||||
await waitFor(() => {
|
||||
const autoJoinSelect = screen.getAllByRole('combobox')[0];
|
||||
expect(autoJoinSelect).toHaveValue('ask_each_time');
|
||||
});
|
||||
|
||||
// Setting B must keep the new value (was true, clicked → false)
|
||||
const listenSwitchAfter = screen.getByRole('switch', { name: /listen.only/i });
|
||||
expect(listenSwitchAfter).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('superseded response for the same setting is silently ignored', async () => {
|
||||
getMock.mockResolvedValueOnce({ ...DEFAULT_SETTINGS });
|
||||
|
||||
// First change (→ 'always') returns a controlled promise; second (→ 'never') resolves fast
|
||||
let resolveFirstSave!: (v: { result: object }) => void;
|
||||
const firstSavePromise = new Promise<{ result: object }>(res => {
|
||||
resolveFirstSave = res;
|
||||
});
|
||||
updateMock.mockReturnValueOnce(firstSavePromise).mockResolvedValue({ result: {} });
|
||||
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument());
|
||||
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
|
||||
// First rapid change: ask_each_time → always
|
||||
fireEvent.change(selects[0], { target: { value: 'always' } });
|
||||
// Second rapid change for the SAME setting: always → never (supersedes the first)
|
||||
fireEvent.change(selects[0], { target: { value: 'never' } });
|
||||
|
||||
// Wait for the second (fast) save to complete
|
||||
await waitFor(() => expect(updateMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
// Resolve the first (now-superseded) save
|
||||
resolveFirstSave({ result: {} });
|
||||
|
||||
// The select value should remain 'never' (the current committed value)
|
||||
// A short wait ensures the first save's then() handler has run
|
||||
await waitFor(() => {
|
||||
const autoJoinSelect = screen.getAllByRole('combobox')[0];
|
||||
expect(autoJoinSelect).toHaveValue('never');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { cleanup, fireEvent, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import { PlatformChips } from '../PlatformChips';
|
||||
|
||||
describe('PlatformChips', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders all four platform chips', () => {
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(<PlatformChips selected="gmeet" onSelect={onSelect} />);
|
||||
|
||||
expect(screen.getByRole('radio', { name: /google meet/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /zoom/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /microsoft teams/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /webex/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the selected platform as checked', () => {
|
||||
renderWithProviders(<PlatformChips selected="zoom" onSelect={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('radio', { name: /zoom/i })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('radio', { name: /google meet/i })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onSelect with the clicked platform', () => {
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(<PlatformChips selected="gmeet" onSelect={onSelect} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /zoom/i }));
|
||||
expect(onSelect).toHaveBeenCalledWith('zoom');
|
||||
});
|
||||
|
||||
it('does not call onSelect when disabled', () => {
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(<PlatformChips selected="gmeet" onSelect={onSelect} disabled />);
|
||||
|
||||
const zoomChip = screen.getByRole('radio', { name: /zoom/i });
|
||||
expect(zoomChip).toBeDisabled();
|
||||
fireEvent.click(zoomChip);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a pure selector — never renders a Connect badge', () => {
|
||||
renderWithProviders(<PlatformChips selected="gmeet" onSelect={vi.fn()} />);
|
||||
|
||||
// The chips do not gate on or imply any account connection.
|
||||
expect(screen.queryByText(/^connect$/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is keyboard accessible via Enter key', () => {
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(<PlatformChips selected="gmeet" onSelect={onSelect} />);
|
||||
|
||||
const teamsChip = screen.getByRole('radio', { name: /microsoft teams/i });
|
||||
fireEvent.keyDown(teamsChip, { key: 'Enter' });
|
||||
expect(onSelect).toHaveBeenCalledWith('teams');
|
||||
});
|
||||
|
||||
it('is keyboard accessible via Space key', () => {
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(<PlatformChips selected="gmeet" onSelect={onSelect} />);
|
||||
|
||||
const webexChip = screen.getByRole('radio', { name: /webex/i });
|
||||
fireEvent.keyDown(webexChip, { key: ' ' });
|
||||
expect(onSelect).toHaveBeenCalledWith('webex');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { cleanup, fireEvent, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
type MeetCallTranscriptLine,
|
||||
parseTranscriptLine,
|
||||
} from '../../../services/meetCallService';
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import TranscriptViewer from '../TranscriptViewer';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const lineWithPrefix: MeetCallTranscriptLine = {
|
||||
role: 'participant',
|
||||
content: '[1:23] [Alice] Hello there!',
|
||||
};
|
||||
|
||||
const lineWithoutPrefix: MeetCallTranscriptLine = {
|
||||
role: 'assistant',
|
||||
content: 'How can I help you?',
|
||||
};
|
||||
|
||||
// ── parseTranscriptLine unit tests ──────────────────────────────────────────
|
||||
|
||||
describe('parseTranscriptLine', () => {
|
||||
it('parses a line with [MM:SS] [Name] prefix', () => {
|
||||
const result = parseTranscriptLine(lineWithPrefix);
|
||||
expect(result.timestamp).toBe('1:23');
|
||||
expect(result.speaker).toBe('Alice');
|
||||
expect(result.text).toBe('Hello there!');
|
||||
expect(result.role).toBe('participant');
|
||||
});
|
||||
|
||||
it('returns null timestamp and speaker when prefix is absent', () => {
|
||||
const result = parseTranscriptLine(lineWithoutPrefix);
|
||||
expect(result.timestamp).toBeNull();
|
||||
expect(result.speaker).toBeNull();
|
||||
expect(result.text).toBe('How can I help you?');
|
||||
expect(result.role).toBe('assistant');
|
||||
});
|
||||
|
||||
it('handles partial brackets — no match, returns full content', () => {
|
||||
const line: MeetCallTranscriptLine = {
|
||||
role: 'participant',
|
||||
content: '[1:23] missing second bracket',
|
||||
};
|
||||
const result = parseTranscriptLine(line);
|
||||
expect(result.timestamp).toBeNull();
|
||||
expect(result.speaker).toBeNull();
|
||||
expect(result.text).toBe('[1:23] missing second bracket');
|
||||
});
|
||||
|
||||
it('handles malformed content gracefully', () => {
|
||||
const line: MeetCallTranscriptLine = { role: 'participant', content: 'just plain text' };
|
||||
const result = parseTranscriptLine(line);
|
||||
expect(result.timestamp).toBeNull();
|
||||
expect(result.speaker).toBeNull();
|
||||
expect(result.text).toBe('just plain text');
|
||||
});
|
||||
});
|
||||
|
||||
// ── TranscriptViewer component tests ────────────────────────────────────────
|
||||
|
||||
describe('TranscriptViewer', () => {
|
||||
it('renders speaker label and timestamp when prefix present', () => {
|
||||
renderWithProviders(<TranscriptViewer lines={[lineWithPrefix]} />);
|
||||
expect(screen.getByText('1:23')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alice:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hello there!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders plain content when no prefix', () => {
|
||||
renderWithProviders(<TranscriptViewer lines={[lineWithoutPrefix]} />);
|
||||
expect(screen.getByText('How can I help you?')).toBeInTheDocument();
|
||||
// No timestamp or speaker
|
||||
expect(screen.queryByText(':')).toBeNull();
|
||||
});
|
||||
|
||||
it('applies primary color class to assistant lines', () => {
|
||||
const { container } = renderWithProviders(<TranscriptViewer lines={[lineWithoutPrefix]} />);
|
||||
const p = container.querySelector('p.text-primary-600');
|
||||
expect(p).not.toBeNull();
|
||||
});
|
||||
|
||||
it('applies secondary color class to non-assistant lines', () => {
|
||||
const { container } = renderWithProviders(<TranscriptViewer lines={[lineWithPrefix]} />);
|
||||
const p = container.querySelector('p.text-content-secondary');
|
||||
expect(p).not.toBeNull();
|
||||
});
|
||||
|
||||
it('copy button calls navigator.clipboard.writeText with plain text', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
renderWithProviders(<TranscriptViewer lines={[lineWithPrefix]} />);
|
||||
fireEvent.click(screen.getByText('Copy'));
|
||||
|
||||
// Wait for async clipboard write
|
||||
await vi.waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalledWith('1:23 Alice: Hello there!');
|
||||
});
|
||||
});
|
||||
|
||||
it('download button creates a blob and triggers download', () => {
|
||||
const createObjectURL = vi.fn().mockReturnValue('blob:test');
|
||||
const revokeObjectURL = vi.fn();
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
value: createObjectURL,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
value: revokeObjectURL,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Save original before mocking to avoid infinite recursion
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const clickSpy = vi.fn();
|
||||
const createElementSpy = vi
|
||||
.spyOn(document, 'createElement')
|
||||
.mockImplementation((tag: string) => {
|
||||
const el = originalCreateElement(tag);
|
||||
if (tag === 'a') {
|
||||
el.click = clickSpy;
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
renderWithProviders(<TranscriptViewer lines={[lineWithPrefix]} />);
|
||||
fireEvent.click(screen.getByText('Download'));
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalled();
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:test');
|
||||
|
||||
createElementSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import { UpcomingTable } from '../UpcomingTable';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock the service so we control what meetings are returned.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const listMock = vi.fn();
|
||||
const joinMock = vi.fn();
|
||||
const setEventPolicyMock = vi.fn();
|
||||
|
||||
vi.mock('../../../services/meetCallService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../services/meetCallService')>(
|
||||
'../../../services/meetCallService'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listUpcomingMeetings: (...args: unknown[]) => listMock(...args),
|
||||
joinMeetViaBackendBot: (...args: unknown[]) => joinMock(...args),
|
||||
setEventPolicy: (...args: unknown[]) => setEventPolicyMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NOW = Date.now();
|
||||
|
||||
function makeMeeting(
|
||||
overrides: Partial<{
|
||||
calendar_event_id: string;
|
||||
title: string;
|
||||
start_time_ms: number;
|
||||
end_time_ms: number;
|
||||
meet_url: string | null;
|
||||
platform: string | null;
|
||||
participant_count: number | null;
|
||||
organizer: string | null;
|
||||
join_policy: string;
|
||||
calendar_source: string;
|
||||
}> = {}
|
||||
) {
|
||||
return {
|
||||
calendar_event_id: 'evt-1',
|
||||
title: 'Weekly Sync',
|
||||
start_time_ms: NOW + 60 * 60 * 1000, // 1 hour from now
|
||||
end_time_ms: NOW + 90 * 60 * 1000,
|
||||
meet_url: 'https://meet.google.com/abc-def-ghi',
|
||||
platform: 'gmeet',
|
||||
participant_count: 4,
|
||||
organizer: 'alice@example.com',
|
||||
join_policy: 'ask',
|
||||
calendar_source: 'google:alice@example.com',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('UpcomingTable', () => {
|
||||
beforeEach(() => {
|
||||
listMock.mockReset();
|
||||
joinMock.mockReset();
|
||||
setEventPolicyMock.mockReset();
|
||||
setEventPolicyMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('shows loading skeletons while fetching', () => {
|
||||
// Let listMock hang indefinitely.
|
||||
listMock.mockImplementation(() => new Promise(() => {}));
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
// Skeletons are animate-pulse rows — table is present.
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the table heading', async () => {
|
||||
listMock.mockResolvedValueOnce([]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
// heading key resolves to "Upcoming" in en locale
|
||||
await waitFor(() => expect(screen.getByText(/upcoming/i)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows empty state when no meetings are returned', async () => {
|
||||
listMock.mockResolvedValueOnce([]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
await waitFor(() => expect(screen.getByText(/no upcoming meetings/i)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders a meeting row with title, platform, and participant count', async () => {
|
||||
listMock.mockResolvedValueOnce([makeMeeting({ title: 'Design Review', participant_count: 7 })]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
await waitFor(() => expect(screen.getByText('Design Review')).toBeInTheDocument());
|
||||
// Platform label for 'gmeet' → 'Google Meet'
|
||||
expect(screen.getByText(/google meet/i)).toBeInTheDocument();
|
||||
// participant count
|
||||
expect(screen.getByText(/7 participants/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a date-group separator (Today)', async () => {
|
||||
listMock.mockResolvedValueOnce([makeMeeting()]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
await waitFor(() => expect(screen.getByText(/today/i)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders the JoinPolicyToggle for each meeting row', async () => {
|
||||
listMock.mockResolvedValueOnce([makeMeeting()]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
await waitFor(() => expect(screen.getByRole('radiogroup')).toBeInTheDocument());
|
||||
expect(screen.getByRole('radio', { name: /ask/i })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('shows a "Join" button (not "Join now") for non-imminent meetings', async () => {
|
||||
listMock.mockResolvedValueOnce([
|
||||
makeMeeting({ start_time_ms: NOW + 60 * 60 * 1000 }), // 1 hour away
|
||||
]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
await waitFor(() => {
|
||||
const btn = screen.queryByRole('button', { name: /^join$/i });
|
||||
expect(btn).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: /join now/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a "Join now" primary button for imminent meetings (< 5 min)', async () => {
|
||||
listMock.mockResolvedValueOnce([
|
||||
makeMeeting({ start_time_ms: NOW + 2 * 60 * 1000 }), // 2 min away
|
||||
]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
// The button has an aria-label for screen readers ("Join {title}") so
|
||||
// we query by visible text content instead of accessible name.
|
||||
await waitFor(() => expect(screen.getByText('Join now')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error state and retry button when fetch fails', async () => {
|
||||
listMock.mockRejectedValueOnce(new Error('Network fail'));
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
// Wait for the error state: the retry button is the reliable indicator
|
||||
// (the error text uses a curly apostrophe that a straight-quote regex won't match).
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument());
|
||||
// The error message is also present in the DOM (accept any apostrophe variant).
|
||||
expect(screen.getByText(/load upcoming meetings/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('retries on retry button click', async () => {
|
||||
listMock
|
||||
.mockRejectedValueOnce(new Error('Network fail'))
|
||||
.mockResolvedValueOnce([makeMeeting({ title: 'After Retry' })]);
|
||||
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /retry/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('After Retry')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders a refresh button in the header', async () => {
|
||||
listMock.mockResolvedValueOnce([]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /refresh/i })).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('calls joinMeetViaBackendBot when Join button is clicked', async () => {
|
||||
joinMock.mockResolvedValueOnce({
|
||||
meetUrl: 'https://meet.google.com/abc-def-ghi',
|
||||
platform: 'gmeet',
|
||||
});
|
||||
listMock.mockResolvedValueOnce([makeMeeting()]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
|
||||
const joinBtn = await screen.findByRole('button', { name: /^join$/i });
|
||||
fireEvent.click(joinBtn);
|
||||
|
||||
await waitFor(() => expect(joinMock).toHaveBeenCalledOnce());
|
||||
expect(joinMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
meetUrl: 'https://meet.google.com/abc-def-ghi',
|
||||
listenOnly: true,
|
||||
correlationId: 'evt-1',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show a join button for meetings without a conferencing URL', async () => {
|
||||
listMock.mockResolvedValueOnce([makeMeeting({ meet_url: null })]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
await waitFor(() => expect(screen.getByText('Weekly Sync')).toBeInTheDocument());
|
||||
expect(screen.queryByRole('button', { name: /^join/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls setEventPolicy when join policy toggle changes', async () => {
|
||||
const meeting = makeMeeting({ join_policy: 'ask' });
|
||||
listMock.mockResolvedValueOnce([meeting]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
await waitFor(() => expect(screen.queryByRole('table')).toBeInTheDocument());
|
||||
await waitFor(() => expect(listMock).toHaveBeenCalled());
|
||||
|
||||
// Find the Auto segment radio button
|
||||
const autoBtn = screen.getByRole('radio', { name: /auto/i });
|
||||
fireEvent.click(autoBtn);
|
||||
await waitFor(() => expect(setEventPolicyMock).toHaveBeenCalledWith('evt-1', 'auto'));
|
||||
});
|
||||
|
||||
it('reverts join policy on setEventPolicy failure', async () => {
|
||||
const meeting = makeMeeting({ join_policy: 'ask' });
|
||||
listMock.mockResolvedValueOnce([meeting]);
|
||||
setEventPolicyMock.mockRejectedValueOnce(new Error('network error'));
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
await waitFor(() => expect(listMock).toHaveBeenCalled());
|
||||
|
||||
const autoBtn = screen.getByRole('radio', { name: /auto/i });
|
||||
fireEvent.click(autoBtn);
|
||||
// Wait for rejection and revert
|
||||
await waitFor(() => expect(setEventPolicyMock).toHaveBeenCalled());
|
||||
// After revert, the "Ask" segment should be active again
|
||||
await waitFor(() => {
|
||||
const askBtn = screen.getByRole('radio', { name: /ask/i });
|
||||
expect(askBtn).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
it('failed slow request does not clobber a newer successful change (optimistic race)', async () => {
|
||||
// The first setEventPolicy call (ask→auto) is slow and will fail.
|
||||
// The second call (auto→skip) is fast and succeeds.
|
||||
// After the slow failure is finally rejected, the UI must remain on 'skip'
|
||||
// — NOT revert back to 'ask'.
|
||||
let rejectSlowCall!: (err: Error) => void;
|
||||
const slowFailure = new Promise<void>((_, reject) => {
|
||||
rejectSlowCall = reject;
|
||||
});
|
||||
|
||||
setEventPolicyMock
|
||||
.mockImplementationOnce(() => slowFailure) // ask → auto: slow failure
|
||||
.mockResolvedValueOnce(undefined); // auto → skip: fast success
|
||||
|
||||
listMock.mockResolvedValueOnce([makeMeeting({ join_policy: 'ask' })]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
await waitFor(() => expect(listMock).toHaveBeenCalled());
|
||||
|
||||
// First change: ask → auto (triggers slow in-flight RPC call)
|
||||
const autoBtn = await screen.findByRole('radio', { name: /auto/i });
|
||||
fireEvent.click(autoBtn);
|
||||
|
||||
// Second change while first is still in-flight: auto → skip (fast success)
|
||||
const skipBtn = screen.getByRole('radio', { name: /skip/i });
|
||||
fireEvent.click(skipBtn);
|
||||
|
||||
// Both RPCs were issued
|
||||
await waitFor(() => expect(setEventPolicyMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
// Skip should be the active policy now (second change settled)
|
||||
expect(screen.getByRole('radio', { name: /skip/i })).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
// Now the slow first call rejects — with the bug this would revert to 'ask'
|
||||
rejectSlowCall(new Error('network timeout'));
|
||||
|
||||
// After rejection settles, skip must STILL be active
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('radio', { name: /skip/i })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
expect(screen.getByRole('radio', { name: /ask/i })).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
// ── watch_calendar hint ────────────────────────────────────────────────────
|
||||
|
||||
it('shows the watch-calendar hint when watchCalendar=false and there are meetings', async () => {
|
||||
listMock.mockResolvedValueOnce([makeMeeting()]);
|
||||
renderWithProviders(<UpcomingTable watchCalendar={false} />);
|
||||
// Wait for meetings to render
|
||||
await waitFor(() => expect(screen.getByText('Weekly Sync')).toBeInTheDocument());
|
||||
// Hint text from i18n key 'skills.meetingBots.upcoming.watchCalendarHint'
|
||||
expect(screen.getByRole('note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the watch-calendar hint when watchCalendar=true', async () => {
|
||||
listMock.mockResolvedValueOnce([makeMeeting()]);
|
||||
renderWithProviders(<UpcomingTable watchCalendar={true} />);
|
||||
await waitFor(() => expect(screen.getByText('Weekly Sync')).toBeInTheDocument());
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the watch-calendar hint when watchCalendar=null (unknown)', async () => {
|
||||
listMock.mockResolvedValueOnce([makeMeeting()]);
|
||||
renderWithProviders(<UpcomingTable watchCalendar={null} />);
|
||||
await waitFor(() => expect(screen.getByText('Weekly Sync')).toBeInTheDocument());
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the watch-calendar hint when there are no meetings even if watchCalendar=false', async () => {
|
||||
listMock.mockResolvedValueOnce([]);
|
||||
renderWithProviders(<UpcomingTable watchCalendar={false} />);
|
||||
await waitFor(() => expect(screen.queryByText(/no upcoming meetings/i)).toBeInTheDocument());
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Platform filter uses effective (inferred) platform (#8) ───────────────
|
||||
|
||||
it('filters out a meeting whose effective platform (inferred from URL) does not match', async () => {
|
||||
// Meeting has no explicit platform but the URL implies gmeet.
|
||||
const gmeetInferred = makeMeeting({
|
||||
calendar_event_id: 'evt-gmeet',
|
||||
title: 'GMeet Inferred',
|
||||
platform: null,
|
||||
meet_url: 'https://meet.google.com/abc-def-ghi',
|
||||
});
|
||||
const zoomExplicit = makeMeeting({
|
||||
calendar_event_id: 'evt-zoom',
|
||||
title: 'Zoom Explicit',
|
||||
platform: 'zoom',
|
||||
meet_url: 'https://zoom.us/j/456',
|
||||
});
|
||||
listMock.mockResolvedValueOnce([gmeetInferred, zoomExplicit]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GMeet Inferred')).toBeInTheDocument();
|
||||
expect(screen.getByText('Zoom Explicit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The platform filter dropdown should appear (two distinct effective platforms).
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
// Filter to Google Meet — inferred-gmeet meeting must remain, zoom must go.
|
||||
fireEvent.change(select, { target: { value: 'gmeet' } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GMeet Inferred')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Zoom Explicit')).toBeNull();
|
||||
});
|
||||
|
||||
// Filter to Zoom — zoom must appear, inferred-gmeet must go.
|
||||
fireEvent.change(select, { target: { value: 'zoom' } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Zoom Explicit')).toBeInTheDocument();
|
||||
expect(screen.queryByText('GMeet Inferred')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('relative time strings come from i18n (default en locale)', async () => {
|
||||
// A meeting ~30 minutes and 30 seconds away → formatWhen should produce
|
||||
// "in 30m" via the 'skills.meetingBots.relative.inMinutes' key.
|
||||
// The extra 30 s gives headroom so minor test-execution timing drift
|
||||
// doesn't push the floor() result down by one.
|
||||
listMock.mockResolvedValueOnce([
|
||||
makeMeeting({ start_time_ms: Date.now() + 30 * 60 * 1000 + 30 * 1000 }),
|
||||
]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
// Match the en-locale pattern "in Xm" — proves the string came from i18n,
|
||||
// not a hardcoded English fallback.
|
||||
await waitFor(() => expect(screen.getByText(/^in \d+m$/)).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ComposioConnection } from '../../../lib/composio/types';
|
||||
import {
|
||||
deriveDisplayNameFromEmail,
|
||||
inferPlatformFromUrl,
|
||||
MEETING_PLATFORMS,
|
||||
platformLabel,
|
||||
platformPrimaryToolkit,
|
||||
platformUrlPlaceholder,
|
||||
resolveMeetingBotMascotId,
|
||||
resolveMeetingDisplayName,
|
||||
} from '../meetingUtils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deriveDisplayNameFromEmail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('deriveDisplayNameFromEmail', () => {
|
||||
it('converts first.last to First Last', () => {
|
||||
expect(deriveDisplayNameFromEmail('first.last@example.com')).toBe('First Last');
|
||||
});
|
||||
|
||||
it('handles underscore separator', () => {
|
||||
expect(deriveDisplayNameFromEmail('alice_smith@example.com')).toBe('Alice Smith');
|
||||
});
|
||||
|
||||
it('handles hyphen separator', () => {
|
||||
expect(deriveDisplayNameFromEmail('alice-smith@example.com')).toBe('Alice Smith');
|
||||
});
|
||||
|
||||
it('handles single-word local part', () => {
|
||||
expect(deriveDisplayNameFromEmail('alice@example.com')).toBe('Alice');
|
||||
});
|
||||
|
||||
it('returns empty string for undefined', () => {
|
||||
expect(deriveDisplayNameFromEmail(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for empty email', () => {
|
||||
expect(deriveDisplayNameFromEmail('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles email with no local part', () => {
|
||||
expect(deriveDisplayNameFromEmail('@example.com')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveMeetingDisplayName — per-platform priority
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeConn(email: string): ComposioConnection {
|
||||
return {
|
||||
id: `conn-${email}`,
|
||||
toolkit: 'unknown',
|
||||
status: 'ACTIVE',
|
||||
accountEmail: email,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveMeetingDisplayName', () => {
|
||||
it('prefers the platform-native toolkit for gmeet over gmail', () => {
|
||||
const map = new Map([
|
||||
['googlemeet', makeConn('alice.native@google.com')],
|
||||
['gmail', makeConn('alice.mail@gmail.com')],
|
||||
]);
|
||||
expect(resolveMeetingDisplayName('gmeet', map)).toBe('Alice Native');
|
||||
});
|
||||
|
||||
it('falls through to gmail for gmeet when platform toolkit missing', () => {
|
||||
const map = new Map([['gmail', makeConn('alice.mail@gmail.com')]]);
|
||||
expect(resolveMeetingDisplayName('gmeet', map)).toBe('Alice Mail');
|
||||
});
|
||||
|
||||
it('prefers zoom over gmail', () => {
|
||||
const map = new Map([
|
||||
['zoom', makeConn('bob.zoom@company.com')],
|
||||
['gmail', makeConn('bob.gmail@gmail.com')],
|
||||
]);
|
||||
expect(resolveMeetingDisplayName('zoom', map)).toBe('Bob Zoom');
|
||||
});
|
||||
|
||||
it('prefers microsoft_teams over outlook and gmail for teams', () => {
|
||||
const map = new Map([
|
||||
['microsoft_teams', makeConn('carol.teams@company.com')],
|
||||
['outlook', makeConn('carol.outlook@company.com')],
|
||||
['gmail', makeConn('carol.gmail@gmail.com')],
|
||||
]);
|
||||
expect(resolveMeetingDisplayName('teams', map)).toBe('Carol Teams');
|
||||
});
|
||||
|
||||
it('falls through to outlook for teams when ms_teams missing', () => {
|
||||
const map = new Map([
|
||||
['outlook', makeConn('carol.outlook@company.com')],
|
||||
['gmail', makeConn('carol.gmail@gmail.com')],
|
||||
]);
|
||||
expect(resolveMeetingDisplayName('teams', map)).toBe('Carol Outlook');
|
||||
});
|
||||
|
||||
it('returns blank when no toolkits are connected', () => {
|
||||
expect(resolveMeetingDisplayName('gmeet', new Map())).toBe('');
|
||||
});
|
||||
|
||||
it('skips entries whose email yields an empty name', () => {
|
||||
const map = new Map([
|
||||
['googlemeet', makeConn('@no-local-part.com')],
|
||||
['gmail', makeConn('alice.gmail@gmail.com')],
|
||||
]);
|
||||
expect(resolveMeetingDisplayName('gmeet', map)).toBe('Alice Gmail');
|
||||
});
|
||||
|
||||
it('prefers webex over gmail for webex platform', () => {
|
||||
const map = new Map([
|
||||
['webex', makeConn('dave.webex@cisco.com')],
|
||||
['gmail', makeConn('dave.gmail@gmail.com')],
|
||||
]);
|
||||
expect(resolveMeetingDisplayName('webex', map)).toBe('Dave Webex');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MEETING_PLATFORMS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('MEETING_PLATFORMS', () => {
|
||||
it('includes all four platforms', () => {
|
||||
expect(MEETING_PLATFORMS).toEqual(expect.arrayContaining(['gmeet', 'zoom', 'teams', 'webex']));
|
||||
expect(MEETING_PLATFORMS).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// platformPrimaryToolkit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('platformPrimaryToolkit', () => {
|
||||
it('returns googlemeet for gmeet', () => {
|
||||
expect(platformPrimaryToolkit('gmeet')).toBe('googlemeet');
|
||||
});
|
||||
|
||||
it('returns zoom for zoom', () => {
|
||||
expect(platformPrimaryToolkit('zoom')).toBe('zoom');
|
||||
});
|
||||
|
||||
it('returns microsoft_teams for teams', () => {
|
||||
expect(platformPrimaryToolkit('teams')).toBe('microsoft_teams');
|
||||
});
|
||||
|
||||
it('returns webex for webex', () => {
|
||||
expect(platformPrimaryToolkit('webex')).toBe('webex');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// platformLabel / platformUrlPlaceholder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('platformLabel', () => {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'skills.meetingBots.platforms.gmeet': 'Google Meet',
|
||||
'skills.meetingBots.platforms.zoom': 'Zoom',
|
||||
'skills.meetingBots.platforms.teams': 'Microsoft Teams',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
};
|
||||
|
||||
it('returns Google Meet for gmeet', () => {
|
||||
expect(platformLabel('gmeet', t)).toBe('Google Meet');
|
||||
});
|
||||
|
||||
it('returns Webex for webex', () => {
|
||||
expect(platformLabel('webex', t)).toBe('Webex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('platformUrlPlaceholder', () => {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...',
|
||||
'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
};
|
||||
|
||||
it('returns the gmeet URL hint', () => {
|
||||
expect(platformUrlPlaceholder('gmeet', t)).toBe('meet.google.com/abc-defg-hij');
|
||||
});
|
||||
|
||||
it('returns the webex URL hint', () => {
|
||||
expect(platformUrlPlaceholder('webex', t)).toBe('webex.com/meet/...');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// inferPlatformFromUrl — strict host matching (#6 security fix)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('inferPlatformFromUrl', () => {
|
||||
it('returns gmeet for a standard Google Meet URL', () => {
|
||||
expect(inferPlatformFromUrl('https://meet.google.com/abc-def-ghi')).toBe('gmeet');
|
||||
});
|
||||
|
||||
it('returns gmeet for a subdomain of meet.google.com', () => {
|
||||
expect(inferPlatformFromUrl('https://sub.meet.google.com/abc-def-ghi')).toBe('gmeet');
|
||||
});
|
||||
|
||||
it('rejects a host that contains meet.google.com as a suffix of a label (spoofed)', () => {
|
||||
// meet.google.com.attacker.com must NOT match
|
||||
expect(inferPlatformFromUrl('https://meet.google.com.attacker.com/abc')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns zoom for zoom.us', () => {
|
||||
expect(inferPlatformFromUrl('https://zoom.us/j/123456')).toBe('zoom');
|
||||
});
|
||||
|
||||
it('returns zoom for a subdomain of zoom.us (e.g. my.zoom.us)', () => {
|
||||
expect(inferPlatformFromUrl('https://my.zoom.us/j/123')).toBe('zoom');
|
||||
});
|
||||
|
||||
it('rejects a host that ends in a word that happens to contain zoom.us', () => {
|
||||
expect(inferPlatformFromUrl('https://evil-zoom.us/j/123')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns teams for teams.microsoft.com', () => {
|
||||
expect(inferPlatformFromUrl('https://teams.microsoft.com/l/meetup-join/123')).toBe('teams');
|
||||
});
|
||||
|
||||
it('rejects a spoofed teams host', () => {
|
||||
expect(inferPlatformFromUrl('https://teams.microsoft.com.evil.org/meeting')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns webex for webex.com', () => {
|
||||
expect(inferPlatformFromUrl('https://webex.com/meet/abc')).toBe('webex');
|
||||
});
|
||||
|
||||
it('returns webex for a subdomain of webex.com', () => {
|
||||
expect(inferPlatformFromUrl('https://cisco.webex.com/meet/abc')).toBe('webex');
|
||||
});
|
||||
|
||||
it('returns null for an unrecognized URL', () => {
|
||||
expect(inferPlatformFromUrl('https://example.com/meeting')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an invalid (unparseable) URL string', () => {
|
||||
expect(inferPlatformFromUrl('not-a-url')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMeetingBotMascotId', () => {
|
||||
it('keeps a selected mascot id the backend recognizes', () => {
|
||||
expect(resolveMeetingBotMascotId('navy', 'yellow')).toBe('navy');
|
||||
});
|
||||
|
||||
it('falls back to the legacy mascot color for a manifest-only mascot id', () => {
|
||||
expect(resolveMeetingBotMascotId('river-guide', 'yellow')).toBe('yellow');
|
||||
});
|
||||
|
||||
it('uses the mascot color when no mascot id is selected', () => {
|
||||
expect(resolveMeetingBotMascotId(null, 'burgundy')).toBe('burgundy');
|
||||
});
|
||||
|
||||
it('returns undefined for custom color with an unrecognized mascot id', () => {
|
||||
expect(resolveMeetingBotMascotId('river-guide', 'custom')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Tests for useUpcomingMeetings hook.
|
||||
*
|
||||
* Timer tests wrap vi.advanceTimersByTimeAsync in act() — this is the
|
||||
* established project pattern (see CoreStateProvider.test.tsx) for settling
|
||||
* async operations under fake timers without using waitFor (which uses real
|
||||
* setTimeout internally and would hang when fake timers are active).
|
||||
*/
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useUpcomingMeetings } from '../useUpcomingMeetings';
|
||||
|
||||
const listMock = vi.fn();
|
||||
|
||||
vi.mock('../../../services/meetCallService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../services/meetCallService')>(
|
||||
'../../../services/meetCallService'
|
||||
);
|
||||
return { ...actual, listUpcomingMeetings: (...args: unknown[]) => listMock(...args) };
|
||||
});
|
||||
|
||||
const MEETING = {
|
||||
calendar_event_id: 'evt-1',
|
||||
title: 'Daily Standup',
|
||||
start_time_ms: Date.now() + 20 * 60 * 1000,
|
||||
end_time_ms: Date.now() + 50 * 60 * 1000,
|
||||
meet_url: 'https://meet.google.com/abc-def-ghi',
|
||||
platform: 'gmeet',
|
||||
participant_count: 3,
|
||||
organizer: 'alice@example.com',
|
||||
join_policy: 'ask',
|
||||
calendar_source: 'google:alice@example.com',
|
||||
};
|
||||
|
||||
describe('useUpcomingMeetings', () => {
|
||||
beforeEach(() => {
|
||||
listMock.mockReset();
|
||||
});
|
||||
|
||||
it('starts in loading=true, meetings=[] state', async () => {
|
||||
let resolve: (v: unknown) => void;
|
||||
listMock.mockImplementation(
|
||||
() =>
|
||||
new Promise(r => {
|
||||
resolve = r;
|
||||
})
|
||||
);
|
||||
|
||||
const { result, unmount } = renderHook(() => useUpcomingMeetings());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.meetings).toEqual([]);
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolve!([]);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('populates meetings on successful fetch', async () => {
|
||||
listMock.mockResolvedValueOnce([MEETING]);
|
||||
|
||||
const { result, unmount } = renderHook(() => useUpcomingMeetings());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.meetings).toEqual([MEETING]);
|
||||
expect(result.current.error).toBeNull();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('sets error when the fetch rejects', async () => {
|
||||
listMock.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result, unmount } = renderHook(() => useUpcomingMeetings());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe('Network error');
|
||||
expect(result.current.meetings).toEqual([]);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('re-fetches on refresh()', async () => {
|
||||
listMock.mockResolvedValueOnce([]).mockResolvedValueOnce([MEETING]);
|
||||
|
||||
const { result, unmount } = renderHook(() => useUpcomingMeetings());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.meetings).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
result.current.refresh();
|
||||
});
|
||||
await waitFor(() => expect(result.current.meetings).toEqual([MEETING]));
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('passes lookaheadMinutes and limit to the service', async () => {
|
||||
listMock.mockResolvedValueOnce([]);
|
||||
|
||||
const { unmount } = renderHook(() => useUpcomingMeetings(120, 5));
|
||||
await waitFor(() => expect(listMock).toHaveBeenCalledWith(120, 5));
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('polls again after 60 seconds', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
listMock.mockResolvedValue([]);
|
||||
|
||||
const { unmount } = renderHook(() => useUpcomingMeetings());
|
||||
|
||||
// Flush initial fetch
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance 60 seconds to trigger the poll
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
unmount();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('background poll does not re-enter loading state once loaded', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
listMock.mockResolvedValue([MEETING]);
|
||||
|
||||
const { result, unmount } = renderHook(() => useUpcomingMeetings());
|
||||
|
||||
// Flush initial fetch — loading goes true then false.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.meetings).toEqual([MEETING]);
|
||||
|
||||
// Advance 60 s to trigger the background poll.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
});
|
||||
|
||||
// Loading must NOT have flipped back to true — no skeleton flicker.
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
unmount();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the interval on unmount', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
listMock.mockResolvedValue([]);
|
||||
|
||||
const { unmount } = renderHook(() => useUpcomingMeetings());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
listMock.mockClear();
|
||||
|
||||
// Advance past the poll interval — no further calls expected.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
});
|
||||
expect(listMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Stale-fetch guard (#7) ─────────────────────────────────────────────────
|
||||
|
||||
it('ignores a stale fetch result when a newer fetch completes first', async () => {
|
||||
const STALE = [{ ...MEETING, title: 'Stale Result' }];
|
||||
const FRESH = [{ ...MEETING, title: 'Fresh Result' }];
|
||||
|
||||
// First fetch (initial mount) is slow.
|
||||
let resolveFirst!: (v: typeof STALE) => void;
|
||||
listMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<typeof STALE>(r => {
|
||||
resolveFirst = r;
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(FRESH); // Second fetch (manual refresh) resolves fast.
|
||||
|
||||
const { result, unmount } = renderHook(() => useUpcomingMeetings());
|
||||
|
||||
// Trigger the manual refresh while the first fetch is still in-flight.
|
||||
await act(async () => {
|
||||
result.current.refresh();
|
||||
});
|
||||
|
||||
// Wait for the second (faster) fetch to complete and populate meetings.
|
||||
await waitFor(() => expect(result.current.meetings).toEqual(FRESH));
|
||||
|
||||
// Now deliver the stale first response.
|
||||
await act(async () => {
|
||||
resolveFirst(STALE);
|
||||
});
|
||||
|
||||
// Stale result must NOT overwrite the already-applied fresh result.
|
||||
expect(result.current.meetings).toEqual(FRESH);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Shared utilities for the Meetings composer.
|
||||
*
|
||||
* Centralises the platform metadata, display-name resolution helpers that were
|
||||
* previously embedded inside MeetingBotsCard so they can be unit-tested in
|
||||
* isolation and shared across the split composer components.
|
||||
*/
|
||||
import type { MascotColor } from '../../features/human/Mascot/mascotPalette';
|
||||
import type { ComposioConnection } from '../../lib/composio/types';
|
||||
import type { MeetingPlatform } from '../../services/meetCallService';
|
||||
import { composioLogoUrl } from '../composio/toolkitMeta';
|
||||
|
||||
/**
|
||||
* Mascot ids the meeting-bot backend recognizes. Newer manifest-only mascot
|
||||
* ids (e.g. "river-guide") aren't supported there, so the bot falls back to the
|
||||
* legacy mascot color for them.
|
||||
*/
|
||||
const MEETING_BOT_MASCOT_IDS = new Set(['yellow', 'blue', 'burgundy', 'black', 'navy']);
|
||||
|
||||
/**
|
||||
* Resolve the mascot id to send to the meeting bot: the selected mascot id when
|
||||
* the backend recognizes it, otherwise the legacy mascot color (or undefined
|
||||
* for `custom`, which has no backend mascot id).
|
||||
*/
|
||||
export function resolveMeetingBotMascotId(
|
||||
selectedMascotId: string | null,
|
||||
mascotColor: MascotColor
|
||||
): string | undefined {
|
||||
if (selectedMascotId && MEETING_BOT_MASCOT_IDS.has(selectedMascotId)) return selectedMascotId;
|
||||
if (mascotColor !== 'custom') return mascotColor;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Ordered list of all supported meeting platforms. */
|
||||
export const MEETING_PLATFORMS: MeetingPlatform[] = ['gmeet', 'zoom', 'teams', 'webex'];
|
||||
|
||||
/**
|
||||
* Return the Composio toolkit slug whose connection is the primary identifier
|
||||
* for a given platform — used to decide whether a platform chip shows as
|
||||
* "connected" or "needs connecting".
|
||||
*/
|
||||
export function platformPrimaryToolkit(platform: MeetingPlatform): string {
|
||||
switch (platform) {
|
||||
case 'gmeet':
|
||||
return 'googlemeet';
|
||||
case 'zoom':
|
||||
return 'zoom';
|
||||
case 'teams':
|
||||
return 'microsoft_teams';
|
||||
case 'webex':
|
||||
return 'webex';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composio logo CDN URL for a meeting platform.
|
||||
* Delegates to the canonical {@link composioLogoUrl} from toolkitMeta.tsx
|
||||
* so there is a single source of truth for the logo CDN path.
|
||||
*/
|
||||
export function platformLogoUrl(platform: MeetingPlatform): string {
|
||||
return composioLogoUrl(platformPrimaryToolkit(platform));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the localised display label for a meeting platform.
|
||||
* Delegates to `skills.meetingBots.platforms.*` i18n keys.
|
||||
*/
|
||||
export function platformLabel(platform: MeetingPlatform, t: (key: string) => string): string {
|
||||
return t(`skills.meetingBots.platforms.${platform}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the URL placeholder string for the meeting-link input.
|
||||
* Delegates to `skills.meetingBots.platformHints.*` i18n keys.
|
||||
*/
|
||||
export function platformUrlPlaceholder(
|
||||
platform: MeetingPlatform,
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
return t(`skills.meetingBots.platformHints.${platform}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display-name resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Composio only hands back a connected account's email — there is no separate
|
||||
* display-name field on `ComposioConnection`. A meeting display name is almost
|
||||
* always "First Last" derived from that account, so we best-effort humanize the
|
||||
* email's local part (`first.last` → `First Last`).
|
||||
*/
|
||||
export function deriveDisplayNameFromEmail(email: string | undefined): string {
|
||||
const localPart = email?.split('@')[0]?.trim();
|
||||
if (!localPart) return '';
|
||||
return localPart
|
||||
.split(/[._-]+/)
|
||||
.filter(Boolean)
|
||||
.map(token => token.charAt(0).toUpperCase() + token.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-platform priority of Composio toolkits to source the user's in-call
|
||||
* display name from: the platform's own connected account first, then the
|
||||
* mailbox, then blank. Slugs are canonical Composio slugs (see
|
||||
* `canonicalizeComposioToolkitSlug`).
|
||||
*/
|
||||
export const NAME_SOURCE_TOOLKITS: Record<MeetingPlatform, string[]> = {
|
||||
gmeet: ['googlemeet', 'gmail'],
|
||||
zoom: ['zoom', 'gmail'],
|
||||
teams: ['microsoft_teams', 'outlook', 'gmail'],
|
||||
webex: ['webex', 'gmail'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a default "Your name in this meeting" for the given platform: walk
|
||||
* that platform's toolkit priority (own account → mail → blank) and return the
|
||||
* first connected account whose email yields a usable name; blank if none are
|
||||
* connected. The single entry point the form calls.
|
||||
*/
|
||||
export function resolveMeetingDisplayName(
|
||||
platform: MeetingPlatform,
|
||||
connectionByToolkit: Map<string, ComposioConnection>
|
||||
): string {
|
||||
for (const slug of NAME_SOURCE_TOOLKITS[platform]) {
|
||||
const name = deriveDisplayNameFromEmail(connectionByToolkit.get(slug)?.accountEmail);
|
||||
if (name) return name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the meeting platform from a URL's hostname.
|
||||
* Returns null when the host doesn't match any known platform or the URL is
|
||||
* unparseable.
|
||||
*
|
||||
* Uses exact-match or proper dot-suffix (e.g. `sub.zoom.us`) to prevent
|
||||
* spoofed hosts such as `meet.google.com.attacker.com` from matching.
|
||||
*/
|
||||
export function inferPlatformFromUrl(url: string): MeetingPlatform | null {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(url).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (host === 'meet.google.com' || host.endsWith('.meet.google.com')) return 'gmeet';
|
||||
if (host === 'zoom.us' || host.endsWith('.zoom.us')) return 'zoom';
|
||||
if (host === 'teams.microsoft.com' || host.endsWith('.teams.microsoft.com')) return 'teams';
|
||||
if (host === 'webex.com' || host.endsWith('.webex.com')) return 'webex';
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Hook: fetch and periodically refresh upcoming calendar meetings.
|
||||
*
|
||||
* Polls every 60 s so the table stays fresh without manual refresh.
|
||||
* Cleans up the poll interval on unmount and guards against setState
|
||||
* after unmount.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { listUpcomingMeetings, type UpcomingMeeting } from '../../services/meetCallService';
|
||||
|
||||
const log = debug('meetings:upcoming');
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
export interface UseUpcomingMeetingsResult {
|
||||
meetings: UpcomingMeeting[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useUpcomingMeetings(
|
||||
lookaheadMinutes?: number,
|
||||
limit?: number
|
||||
): UseUpcomingMeetingsResult {
|
||||
const [meetings, setMeetings] = useState<UpcomingMeeting[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
// Monotonically increasing request counter — used to ignore stale responses
|
||||
// when overlapping fetches (poll + manual refresh) resolve out of order.
|
||||
const fetchCounterRef = useRef(0);
|
||||
|
||||
/**
|
||||
* Fetch meetings from the core.
|
||||
*
|
||||
* @param showLoading - When true, sets `loading=true` before the fetch so
|
||||
* the caller can show a skeleton. Background poll ticks pass `false` to
|
||||
* avoid the skeleton re-appearing every 60 s; the initial mount and the
|
||||
* manual refresh button pass `true`.
|
||||
*/
|
||||
const fetchMeetings = useCallback(
|
||||
async (showLoading: boolean) => {
|
||||
// Claim a sequence number before the await so concurrent callers each get
|
||||
// a unique stamp and only the latest response is applied.
|
||||
const requestId = ++fetchCounterRef.current;
|
||||
log(
|
||||
'[useUpcomingMeetings] fetching upcoming meetings showLoading=%s requestId=%d',
|
||||
showLoading,
|
||||
requestId
|
||||
);
|
||||
if (showLoading) setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listUpcomingMeetings(lookaheadMinutes, limit);
|
||||
if (!mountedRef.current || fetchCounterRef.current !== requestId) return;
|
||||
log('[useUpcomingMeetings] fetched %d meetings (requestId=%d)', data.length, requestId);
|
||||
setMeetings(data);
|
||||
} catch (err) {
|
||||
if (!mountedRef.current || fetchCounterRef.current !== requestId) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('[useUpcomingMeetings] fetch error: %s (requestId=%d)', msg, requestId);
|
||||
setError(msg);
|
||||
} finally {
|
||||
if (mountedRef.current && fetchCounterRef.current === requestId) setLoading(false);
|
||||
}
|
||||
},
|
||||
[lookaheadMinutes, limit]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
// Initial load: show the skeleton.
|
||||
fetchMeetings(true);
|
||||
|
||||
const id = setInterval(() => {
|
||||
log('[useUpcomingMeetings] poll tick');
|
||||
// Background refresh: no skeleton to avoid table flicker every 60 s.
|
||||
fetchMeetings(false);
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [fetchMeetings]);
|
||||
|
||||
// Manual refresh (the refresh button) shows the loading state so the user
|
||||
// gets clear visual feedback that data is being reloaded.
|
||||
return { meetings, loading, error, refresh: () => fetchMeetings(true) };
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const meetSettings = (overrides: Partial<MeetSettings> = {}): MeetSettings => ({
|
||||
auto_summarize_policy: 'ask',
|
||||
listen_only_default: true,
|
||||
ingest_backend_transcripts: false,
|
||||
watch_calendar: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,484 +1,10 @@
|
||||
import debug from 'debug';
|
||||
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { type MascotFace, RiveMascot } from '../../features/human/Mascot';
|
||||
import type { MascotColor } from '../../features/human/Mascot/mascotPalette';
|
||||
import { useComposioIntegrations } from '../../lib/composio/hooks';
|
||||
import type { ComposioConnection } from '../../lib/composio/types';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
isCapacityGateMessage,
|
||||
joinMeetViaBackendBot,
|
||||
leaveBackendMeetBot,
|
||||
listMeetCalls,
|
||||
type MeetCallRecord,
|
||||
type MeetingPlatform,
|
||||
} from '../../services/meetCallService';
|
||||
import {
|
||||
type BackendMeetHarnessEvent,
|
||||
type BackendMeetReplyEvent,
|
||||
type BackendMeetStatus,
|
||||
resetBackendMeet,
|
||||
selectBackendMeetError,
|
||||
selectBackendMeetLastHarness,
|
||||
selectBackendMeetLastReply,
|
||||
selectBackendMeetListenOnly,
|
||||
selectBackendMeetStatus,
|
||||
selectBackendMeetUrl,
|
||||
setBackendMeetJoining,
|
||||
} from '../../store/backendMeetSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import {
|
||||
selectCustomPrimaryColor,
|
||||
selectCustomSecondaryColor,
|
||||
selectMascotColor,
|
||||
selectSelectedMascotId,
|
||||
} from '../../store/mascotSlice';
|
||||
import { selectPersonaDescription, selectPersonaDisplayName } from '../../store/personaSlice';
|
||||
import Button from '../ui/Button';
|
||||
import { RecentCallsSection } from './RecentCallsSection';
|
||||
|
||||
type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string };
|
||||
|
||||
const log = debug('meeting-bots');
|
||||
const MEETING_BOT_MASCOT_IDS = new Set(['yellow', 'blue', 'burgundy', 'black', 'navy']);
|
||||
|
||||
function resolveMeetingBotMascotId(
|
||||
selectedMascotId: string | null,
|
||||
mascotColor: MascotColor
|
||||
): string | undefined {
|
||||
if (selectedMascotId && MEETING_BOT_MASCOT_IDS.has(selectedMascotId)) return selectedMascotId;
|
||||
if (mascotColor !== 'custom') return mascotColor;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Composio only hands back a connected account's email — there is no separate
|
||||
// display-name field on `ComposioConnection`. A meeting display name is almost
|
||||
// always "First Last" derived from that account, so we best-effort humanize the
|
||||
// email's local part (`first.last` → `First Last`).
|
||||
function deriveDisplayNameFromEmail(email: string | undefined): string {
|
||||
const localPart = email?.split('@')[0]?.trim();
|
||||
if (!localPart) return '';
|
||||
return localPart
|
||||
.split(/[._-]+/)
|
||||
.filter(Boolean)
|
||||
.map(token => token.charAt(0).toUpperCase() + token.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Per-platform priority of Composio toolkits to source the user's in-call
|
||||
// display name from: the platform's own connected account first, then the
|
||||
// mailbox, then blank. Slugs are canonical Composio slugs (see
|
||||
// `canonicalizeComposioToolkitSlug`).
|
||||
const NAME_SOURCE_TOOLKITS: Record<MeetingPlatform, string[]> = {
|
||||
gmeet: ['googlemeet', 'gmail'],
|
||||
zoom: ['zoom', 'gmail'],
|
||||
teams: ['microsoft_teams', 'outlook', 'gmail'],
|
||||
webex: ['webex', 'gmail'],
|
||||
};
|
||||
|
||||
// Resolve a default "Your name in this meeting" for the given platform: walk
|
||||
// that platform's toolkit priority (own account → mail → blank) and return the
|
||||
// first connected account whose email yields a usable name; blank if none are
|
||||
// connected. The single entry point the form calls.
|
||||
function resolveMeetingDisplayName(
|
||||
platform: MeetingPlatform,
|
||||
connectionByToolkit: Map<string, ComposioConnection>
|
||||
): string {
|
||||
for (const slug of NAME_SOURCE_TOOLKITS[platform]) {
|
||||
const name = deriveDisplayNameFromEmail(connectionByToolkit.get(slug)?.accountEmail);
|
||||
if (name) return name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onToast?: (toast: Toast) => void;
|
||||
}
|
||||
|
||||
interface MeetingBotsInlineProps extends Props {
|
||||
hasSubmittedRef: RefObject<boolean>;
|
||||
}
|
||||
|
||||
export default function MeetingBotsCard({ onToast }: Props) {
|
||||
const { t } = useT();
|
||||
const status = useAppSelector(selectBackendMeetStatus);
|
||||
const showActive = status === 'active';
|
||||
|
||||
// `hasSubmittedRef` lives in this always-mounted parent so the success toast
|
||||
// fires reliably. When a join succeeds, `status` flips to 'active' and this
|
||||
// component swaps `MeetingBotsInline` → `ActiveMeetingView`, unmounting the
|
||||
// inline form before any effect inside it could observe 'active' (#3611
|
||||
// flattened these into a mutually-exclusive ternary). The inline form sets
|
||||
// this ref on submit; we fire the success toast here. The error path stays in
|
||||
// the inline form, which remains mounted during the 'error' state.
|
||||
const hasSubmittedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasSubmittedRef.current) return;
|
||||
if (status === 'active') {
|
||||
hasSubmittedRef.current = false;
|
||||
onToast?.({
|
||||
type: 'success',
|
||||
title: t('skills.meetingBots.joiningTitle'),
|
||||
message: t('skills.meetingBots.joiningMessage'),
|
||||
});
|
||||
}
|
||||
}, [status, onToast, t]);
|
||||
|
||||
return showActive ? (
|
||||
<ActiveMeetingView onToast={onToast} />
|
||||
) : (
|
||||
<MeetingBotsInline onToast={onToast} hasSubmittedRef={hasSubmittedRef} />
|
||||
);
|
||||
}
|
||||
|
||||
function faceFromMeetState(
|
||||
status: BackendMeetStatus,
|
||||
lastReply: BackendMeetReplyEvent | null,
|
||||
lastHarness: BackendMeetHarnessEvent | null
|
||||
): MascotFace {
|
||||
if (status === 'joining') return 'thinking';
|
||||
if (status === 'error') return 'concerned';
|
||||
if (status === 'ended') return 'happy';
|
||||
if (lastHarness) return 'thinking';
|
||||
if (lastReply) {
|
||||
const e = (lastReply.emotion ?? '').toLowerCase();
|
||||
if (e.includes('happy') || e.includes('pleased') || e.includes('joy') || e.includes('excit'))
|
||||
return 'happy';
|
||||
if (e.includes('celebrat') || e.includes('proud')) return 'celebrating';
|
||||
if (e.includes('concern') || e.includes('worried') || e.includes('unsure')) return 'concerned';
|
||||
if (e.includes('curious') || e.includes('interest')) return 'curious';
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
function ActiveMeetingView({ onToast }: Props) {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const status = useAppSelector(selectBackendMeetStatus);
|
||||
const meetUrl = useAppSelector(selectBackendMeetUrl);
|
||||
const listenOnly = useAppSelector(selectBackendMeetListenOnly);
|
||||
const lastReply = useAppSelector(selectBackendMeetLastReply);
|
||||
const lastHarness = useAppSelector(selectBackendMeetLastHarness);
|
||||
const face = faceFromMeetState(status, lastReply, lastHarness);
|
||||
const meetingCode = useMemo(() => {
|
||||
if (!meetUrl) return '';
|
||||
try {
|
||||
const tail = new URL(meetUrl).pathname.replace(/^\/+/, '');
|
||||
return tail || meetUrl;
|
||||
} catch {
|
||||
return meetUrl;
|
||||
}
|
||||
}, [meetUrl]);
|
||||
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (leaving) return;
|
||||
setLeaving(true);
|
||||
try {
|
||||
await leaveBackendMeetBot('user-requested');
|
||||
} catch (err) {
|
||||
onToast?.({
|
||||
type: 'error',
|
||||
title: t('skills.meetingBots.couldNotStartTitle'),
|
||||
message: String(err),
|
||||
});
|
||||
} finally {
|
||||
setLeaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusText = (() => {
|
||||
const base: Record<string, string> = {
|
||||
joining: t('skills.meetingBots.liveStatusJoining'),
|
||||
active: listenOnly
|
||||
? t('skills.meetingBots.liveStatusListening')
|
||||
: t('skills.meetingBots.liveStatusActive'),
|
||||
ended: t('skills.meetingBots.liveStatusEnded'),
|
||||
error: t('skills.meetingBots.liveStatusError'),
|
||||
idle: '',
|
||||
};
|
||||
return base[status] ?? '';
|
||||
})();
|
||||
|
||||
const canLeave = status === 'active' || status === 'joining';
|
||||
const isDone = status === 'ended' || status === 'error';
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-primary-200/60 dark:border-primary-500/30 bg-gradient-to-br from-primary-50 via-white to-amber-50 dark:from-primary-500/15 dark:via-neutral-900 dark:to-amber-500/10 p-4 shadow-soft animate-fade-up">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="flex items-center gap-1.5 rounded-full bg-coral-500/10 dark:bg-coral-400/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-coral-600 dark:text-coral-400">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full bg-coral-500 animate-pulse"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('skills.meetingBots.liveBadge')}
|
||||
</span>
|
||||
{canLeave && (
|
||||
<Button variant="secondary" size="sm" onClick={handleLeave} disabled={leaving}>
|
||||
{t('skills.meetingBots.leaveButton')}
|
||||
</Button>
|
||||
)}
|
||||
{isDone && (
|
||||
<Button variant="secondary" size="sm" onClick={() => dispatch(resetBackendMeet())}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-20 h-20 flex-shrink-0">
|
||||
<RiveMascot face={face} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-content">
|
||||
{t('skills.meetingBots.liveTitle')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-content-muted">{statusText}</div>
|
||||
{meetingCode && (
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-content-secondary">
|
||||
{meetingCode}
|
||||
</div>
|
||||
)}
|
||||
{lastReply?.reply && (
|
||||
<div className="mt-1.5 text-xs text-content-secondary line-clamp-2 italic">
|
||||
“{lastReply.reply}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps) {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const [meetUrl, setMeetUrl] = useState('');
|
||||
// The participant the bot answers to (authorized speaker). Wired to the
|
||||
// backend join payload as `respondToParticipant` → `respondTo`, which the
|
||||
// meeting stream uses to gate in-call requests to this speaker only.
|
||||
const [respondTo, setRespondTo] = useState('');
|
||||
// Once the user types in the name field we stop auto-prefilling it, so a
|
||||
// late-arriving Composio fetch (it polls) can never clobber manual input.
|
||||
const respondToTouchedRef = useRef(false);
|
||||
// Active (respond when addressed) vs listen-only (transcribe only). Defaults
|
||||
// to active; the bot still only replies after being addressed by the wake
|
||||
// phrase. Forwarded to the backend as `listenOnly` and mirrored into the
|
||||
// meet slice so the active view shows the right status.
|
||||
const [listenOnly, setListenOnly] = useState(false);
|
||||
const personaDisplayName = useAppSelector(selectPersonaDisplayName);
|
||||
const personaDescription = useAppSelector(selectPersonaDescription);
|
||||
const selectedMascotId = useAppSelector(selectSelectedMascotId);
|
||||
const mascotColor = useAppSelector(selectMascotColor);
|
||||
const customPrimaryColor = useAppSelector(selectCustomPrimaryColor);
|
||||
const customSecondaryColor = useAppSelector(selectCustomSecondaryColor);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const meetStatus = useAppSelector(selectBackendMeetStatus);
|
||||
const meetError = useAppSelector(selectBackendMeetError);
|
||||
const [recentCalls, setRecentCalls] = useState<MeetCallRecord[] | null>(null);
|
||||
const [recentError, setRecentError] = useState<string | null>(null);
|
||||
// The meeting platform this form sends to (only Google Meet is wired up for
|
||||
// now). Drives both the join payload and which connected accounts we source
|
||||
// the default display name from.
|
||||
const platform: MeetingPlatform = 'gmeet';
|
||||
// Prefill "Your name in this meeting" from the connected account that best
|
||||
// matches this platform (calendar → mail → platform-native).
|
||||
const { connectionByToolkit } = useComposioIntegrations();
|
||||
const resolvedDisplayName = resolveMeetingDisplayName(platform, connectionByToolkit);
|
||||
|
||||
useEffect(() => {
|
||||
if (respondToTouchedRef.current || !resolvedDisplayName) return;
|
||||
setRespondTo(prev => (prev.trim() ? prev : resolvedDisplayName));
|
||||
}, [resolvedDisplayName]);
|
||||
|
||||
const refreshRecentCalls = useCallback(async () => {
|
||||
setRecentError(null);
|
||||
try {
|
||||
const rows = await listMeetCalls(20);
|
||||
setRecentCalls(rows);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load recent calls.';
|
||||
console.warn('[meeting-bots] listMeetCalls failed:', err);
|
||||
setRecentError(message);
|
||||
setRecentCalls([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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');
|
||||
const agentName = personaDisplayName.trim() || 'Tiny';
|
||||
const systemPrompt = personaDescription.trim() || undefined;
|
||||
const mascotId = resolveMeetingBotMascotId(selectedMascotId, mascotColor);
|
||||
const riveColors =
|
||||
mascotColor === 'custom'
|
||||
? { primaryColor: customPrimaryColor, secondaryColor: customSecondaryColor }
|
||||
: undefined;
|
||||
const wakePhrase = listenOnly ? undefined : `Hey ${agentName}`;
|
||||
|
||||
// Success ('active') is handled by the parent MeetingBotsCard, which stays
|
||||
// mounted across the inline→active view swap. The error path lives here
|
||||
// because the inline form remains mounted during the 'error' state and needs
|
||||
// to surface the failure inline (setError/setSubmitting) alongside the toast.
|
||||
useEffect(() => {
|
||||
if (!hasSubmittedRef.current) return;
|
||||
if (meetStatus === 'error') {
|
||||
hasSubmittedRef.current = false;
|
||||
const raw = meetError?.trim() || t('skills.meetingBots.failedToStart');
|
||||
// A capacity-gate error carries the backend's terse "…try again later."
|
||||
// wording; show the tailored, actionable (and localized) copy instead (#4151).
|
||||
const message = isCapacityGateMessage(raw)
|
||||
? t('skills.meetingBots.serverOverloaded')
|
||||
: raw;
|
||||
setError(message);
|
||||
setSubmitting(false);
|
||||
onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message });
|
||||
}
|
||||
}, [meetStatus, meetError, onToast, t, hasSubmittedRef]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
hasSubmittedRef.current = true;
|
||||
try {
|
||||
const meetingId = crypto.randomUUID();
|
||||
log('join submit %o', {
|
||||
active: !listenOnly,
|
||||
agentChars: agentName.length,
|
||||
ownerChars: respondTo.trim().length,
|
||||
wakeChars: wakePhrase?.length ?? 0,
|
||||
correlationId: meetingId,
|
||||
});
|
||||
dispatch(setBackendMeetJoining({ meetUrl: meetUrl.trim(), meetingId, listenOnly }));
|
||||
await joinMeetViaBackendBot({
|
||||
meetUrl,
|
||||
displayName: agentName,
|
||||
platform,
|
||||
agentName,
|
||||
systemPrompt,
|
||||
mascotId,
|
||||
riveColors,
|
||||
correlationId: meetingId,
|
||||
respondToParticipant: respondTo.trim() || undefined,
|
||||
wakePhrase,
|
||||
listenOnly,
|
||||
});
|
||||
} catch (err) {
|
||||
const raw = err instanceof Error ? err.message : t('skills.meetingBots.failedToStart');
|
||||
const message = isCapacityGateMessage(raw)
|
||||
? t('skills.meetingBots.serverOverloaded')
|
||||
: raw;
|
||||
setError(message);
|
||||
setSubmitting(false);
|
||||
hasSubmittedRef.current = false;
|
||||
onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-line bg-surface p-4 shadow-soft animate-fade-up">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-semibold text-content">
|
||||
{t('skills.meetingBots.modalTitle')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs leading-relaxed text-content-secondary">
|
||||
{t('skills.meetingBots.modalDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-content-muted">
|
||||
{t('skills.meetingBots.meetingLink')}
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={meetUrl}
|
||||
onChange={e => setMeetUrl(e.target.value)}
|
||||
placeholder={t('skills.meetingBots.platformHints.gmeet')}
|
||||
disabled={submitting}
|
||||
className="mt-1 w-full rounded-xl border border-line bg-surface px-3 py-2 text-sm text-content placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-surface-muted dark:disabled:bg-surface-muted/60"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-content-muted">
|
||||
{t('skills.meetingBots.respondToParticipant')}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={respondTo}
|
||||
onChange={e => {
|
||||
respondToTouchedRef.current = true;
|
||||
setRespondTo(e.target.value);
|
||||
}}
|
||||
placeholder={t('skills.meetingBots.respondToParticipantHint')}
|
||||
disabled={submitting}
|
||||
required
|
||||
className="mt-1 w-full rounded-xl border border-line bg-surface px-3 py-2 text-sm text-content placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-surface-muted dark:disabled:bg-surface-muted/60"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-content-faint">
|
||||
{t('skills.meetingBots.respondToParticipantDesc')}
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 rounded-xl border border-line px-3 py-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!listenOnly}
|
||||
onChange={e => setListenOnly(!e.target.checked)}
|
||||
disabled={submitting}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 rounded border-line-strong text-primary-500 focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-content">
|
||||
{t('skills.meetingBots.activeMode')}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[10px] leading-relaxed text-content-faint">
|
||||
{t('skills.meetingBots.activeModeDesc')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={submitting || !meetUrl.trim() || !respondTo.trim()}>
|
||||
{submitting
|
||||
? t('skills.meetingBots.starting')
|
||||
: t('skills.meetingBots.sendTo').replace('{label}', selectedLabel)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<RecentCallsSection rows={recentCalls} error={recentError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Backward-compatible re-export of the redesigned Meetings page.
|
||||
*
|
||||
* Skills.tsx now imports and renders `MeetingsPage` directly; this shim
|
||||
* keeps existing test mocks and any other importers working without
|
||||
* requiring a search-and-replace across the codebase.
|
||||
*
|
||||
* New code should import from `components/meetings/MeetingsPage` directly.
|
||||
*/
|
||||
export { default } from '../meetings/MeetingsPage';
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Tests for the recent-calls panel's expandable rows.
|
||||
*
|
||||
* Confirms a row lazily fetches its transcript + summary on first expand,
|
||||
* renders both, and degrades gracefully when the core has no detail or the
|
||||
* fetch fails (with a working retry).
|
||||
*/
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { I18nProvider } from '../../lib/i18n/I18nContext';
|
||||
import type { MeetCallDetail, MeetCallRecord } from '../../services/meetCallService';
|
||||
import localeReducer from '../../store/localeSlice';
|
||||
import { RecentCallsSection } from './RecentCallsSection';
|
||||
|
||||
const getMeetCallDetail = vi.fn<(requestId: string) => Promise<MeetCallDetail | null>>();
|
||||
|
||||
vi.mock('../../services/meetCallService', () => ({
|
||||
getMeetCallDetail: (requestId: string) => getMeetCallDetail(requestId),
|
||||
}));
|
||||
|
||||
function call(overrides: Partial<MeetCallRecord> = {}): MeetCallRecord {
|
||||
return {
|
||||
request_id: 'corr-1',
|
||||
meet_url: 'https://meet.google.com/yfj-hcek-zyv',
|
||||
bot_display_name: 'Tiny',
|
||||
owner_display_name: 'Shanu',
|
||||
started_at_ms: Date.now() - 60_000,
|
||||
ended_at_ms: Date.now(),
|
||||
listened_seconds: 100,
|
||||
spoken_seconds: 20,
|
||||
turn_count: 3,
|
||||
participants: ['Shanu', 'Alan'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSection(rows: MeetCallRecord[]) {
|
||||
const store = configureStore({
|
||||
reducer: { locale: localeReducer },
|
||||
preloadedState: { locale: { current: 'en' as const } },
|
||||
});
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<I18nProvider>
|
||||
<RecentCallsSection rows={rows} error={null} />
|
||||
</I18nProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('RecentCallsSection', () => {
|
||||
beforeEach(() => {
|
||||
getMeetCallDetail.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('lazily loads and renders summary + transcript on expand', async () => {
|
||||
getMeetCallDetail.mockResolvedValue({
|
||||
request_id: 'corr-1',
|
||||
summary: {
|
||||
headline: 'Agreed to ship Friday.',
|
||||
key_points: ['Ship Friday', 'QA owns sign-off'],
|
||||
action_items: [
|
||||
{ description: 'Send release notes', kind: 'executable', tool_name: 'gmail', assignee: 'Sam' },
|
||||
],
|
||||
},
|
||||
transcript: [
|
||||
{ role: 'participant', content: '[00:51] [Shanu] your time' },
|
||||
{ role: 'assistant', content: '[00:55] [Tiny] On it.' },
|
||||
],
|
||||
});
|
||||
|
||||
renderSection([call()]);
|
||||
// Not fetched until the row is expanded.
|
||||
expect(getMeetCallDetail).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(getMeetCallDetail).toHaveBeenCalledExactlyOnceWith('corr-1');
|
||||
await waitFor(() => expect(screen.getByText('Agreed to ship Friday.')).toBeInTheDocument());
|
||||
expect(screen.getByText('Summary')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ship Friday')).toBeInTheDocument();
|
||||
expect(screen.getByText('Transcript')).toBeInTheDocument();
|
||||
expect(screen.getByText('[00:55] [Tiny] On it.')).toBeInTheDocument();
|
||||
// Action item description + assignee/tool meta.
|
||||
expect(screen.getByText('Send release notes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty state when the call has no recorded detail', async () => {
|
||||
getMeetCallDetail.mockResolvedValue(null);
|
||||
renderSection([call()]);
|
||||
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText('No transcript or summary was captured for this call.')
|
||||
).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces an error with a working retry', async () => {
|
||||
getMeetCallDetail.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce({
|
||||
request_id: 'corr-1',
|
||||
summary: null,
|
||||
transcript: [{ role: 'participant', content: 'recovered line' }],
|
||||
});
|
||||
|
||||
renderSection([call()]);
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
|
||||
const retry = await screen.findByRole('button', { name: 'Retry' });
|
||||
await userEvent.click(retry);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('recovered line')).toBeInTheDocument());
|
||||
expect(getMeetCallDetail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not refetch when collapsing and re-expanding a fully-loaded call', async () => {
|
||||
getMeetCallDetail.mockResolvedValue({
|
||||
request_id: 'corr-1',
|
||||
summary: { headline: 'All set.', key_points: [], action_items: [] },
|
||||
transcript: [{ role: 'participant', content: 'cached line' }],
|
||||
});
|
||||
|
||||
renderSection([call()]);
|
||||
const toggle = screen.getByRole('button');
|
||||
await userEvent.click(toggle); // expand → fetch
|
||||
await waitFor(() => expect(screen.getByText('cached line')).toBeInTheDocument());
|
||||
await userEvent.click(toggle); // collapse
|
||||
await userEvent.click(toggle); // re-expand → reuse cache (summary already present)
|
||||
|
||||
expect(getMeetCallDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refetches on re-expand to pick up a summary generated after call-end', async () => {
|
||||
// First load: transcript persisted, summary still generating (null).
|
||||
getMeetCallDetail
|
||||
.mockResolvedValueOnce({
|
||||
request_id: 'corr-1',
|
||||
summary: null,
|
||||
transcript: [{ role: 'participant', content: 'early line' }],
|
||||
})
|
||||
// Second load: summary has since landed.
|
||||
.mockResolvedValueOnce({
|
||||
request_id: 'corr-1',
|
||||
summary: { headline: 'Summary arrived.', key_points: [], action_items: [] },
|
||||
transcript: [{ role: 'participant', content: 'early line' }],
|
||||
});
|
||||
|
||||
renderSection([call()]);
|
||||
const toggle = screen.getByRole('button');
|
||||
await userEvent.click(toggle); // expand → first fetch (no summary yet)
|
||||
await waitFor(() => expect(screen.getByText('early line')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Summary arrived.')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(toggle); // collapse
|
||||
await userEvent.click(toggle); // re-expand → refetch (summary was missing)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Summary arrived.')).toBeInTheDocument());
|
||||
expect(getMeetCallDetail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,355 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
getMeetCallDetail,
|
||||
type MeetCallDetail,
|
||||
type MeetCallRecord,
|
||||
type MeetCallSummary,
|
||||
type MeetCallTranscriptLine,
|
||||
} 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).
|
||||
*
|
||||
* Each row is expandable: on first expand it lazily fetches the call's
|
||||
* transcript + summary via `meet_agent_get_call_detail` so the list payload
|
||||
* stays lean. Older calls recorded before the feature have no detail and show
|
||||
* a "nothing captured" state.
|
||||
*
|
||||
* 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-line pt-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('skills.meetingBots.recentCallsHeading')}
|
||||
{rows && rows.length > 0 && (
|
||||
<span className="ml-1 text-content-faint 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-content-faint">
|
||||
{t('skills.meetingBots.recentCallsLoading')}
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="mt-2 text-[11px] text-content-faint">
|
||||
{t('skills.meetingBots.recentCallsEmpty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-2 max-h-72 space-y-1 overflow-y-auto pr-1">
|
||||
{rows.map(call => (
|
||||
<RecentCallRow key={call.request_id} call={call} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type DetailStatus = 'idle' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
/**
|
||||
* True when `detail` carries a non-empty generated summary. The summary lands
|
||||
* asynchronously after the transcript at call-end, so this gates whether a
|
||||
* re-expand should refetch (still pending) or reuse the cache (already present).
|
||||
*/
|
||||
function hasSummaryDetail(detail: MeetCallDetail | null): boolean {
|
||||
const summary = detail?.summary;
|
||||
return (
|
||||
!!summary &&
|
||||
(summary.headline.trim().length > 0 ||
|
||||
summary.key_points.length > 0 ||
|
||||
summary.action_items.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function RecentCallRow({ call }: { call: MeetCallRecord }) {
|
||||
const { t } = useT();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [status, setStatus] = useState<DetailStatus>('idle');
|
||||
const [detail, setDetail] = useState<MeetCallDetail | null>(null);
|
||||
|
||||
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);
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
setStatus('loading');
|
||||
try {
|
||||
const result = await getMeetCallDetail(call.request_id);
|
||||
setDetail(result);
|
||||
setStatus('loaded');
|
||||
} catch (err) {
|
||||
console.error('[recent-calls] failed to load call detail', call.request_id, err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [call.request_id]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setExpanded(prev => {
|
||||
const next = !prev;
|
||||
// Lazy-load on first expand. The transcript is persisted at call-end but
|
||||
// the summary is generated asynchronously and patched in moments later, so
|
||||
// re-expanding a row whose cached detail still lacks a summary refetches to
|
||||
// pick it up. A complete (summary-present) detail is reused as-is.
|
||||
if (next && (status === 'idle' || (status === 'loaded' && !hasSummaryDetail(detail)))) {
|
||||
void loadDetail();
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [status, detail, loadDetail]);
|
||||
|
||||
return (
|
||||
<li className="rounded-lg text-[11px] text-content-secondary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded}
|
||||
className="w-full rounded-lg px-2 py-1.5 text-left hover:bg-surface-muted dark:hover:bg-surface-muted/40">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<Chevron expanded={expanded} />
|
||||
<span className="truncate font-mono text-content">
|
||||
{meetingCode}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-content-faint">
|
||||
{formatRelativeTime(call.started_at_ms)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-3 pl-4 text-[10px] text-content-muted">
|
||||
<span>
|
||||
{t(
|
||||
call.turn_count === 1
|
||||
? 'skills.meetingBots.recentCallTurnSingular'
|
||||
: 'skills.meetingBots.recentCallTurnPlural'
|
||||
).replace('{count}', String(call.turn_count))}
|
||||
</span>
|
||||
<span>
|
||||
{t('skills.meetingBots.recentCallDuration').replace('{seconds}', String(duration))}
|
||||
</span>
|
||||
{owner && (
|
||||
<span className="truncate">
|
||||
{t('skills.meetingBots.recentCallAddedBy').replace('{name}', owner)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{participants.length > 0 && (
|
||||
<div className="mt-0.5 truncate pl-4 text-[10px] text-content-muted">
|
||||
{t('skills.meetingBots.recentCallParticipants').replace(
|
||||
'{names}',
|
||||
participants.join(', ')
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-2 pb-2 pl-6">
|
||||
<RecentCallDetailBody status={status} detail={detail} onRetry={loadDetail} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentCallDetailBody({
|
||||
status,
|
||||
detail,
|
||||
onRetry,
|
||||
}: {
|
||||
status: DetailStatus;
|
||||
detail: MeetCallDetail | null;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
|
||||
if (status === 'idle' || status === 'loading') {
|
||||
return (
|
||||
<p className="text-[10px] text-content-faint">
|
||||
{t('skills.meetingBots.callDetailLoading')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<p className="text-[10px] text-coral-600 dark:text-coral-400">
|
||||
{t('skills.meetingBots.callDetailError')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="underline underline-offset-2 hover:text-coral-700 dark:hover:text-coral-300">
|
||||
{t('skills.meetingBots.callDetailRetry')}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = detail?.summary ?? null;
|
||||
const transcript = detail?.transcript ?? [];
|
||||
const hasSummary = hasSummaryDetail(detail);
|
||||
|
||||
if (!hasSummary && transcript.length === 0) {
|
||||
return (
|
||||
<p className="text-[10px] text-content-faint">
|
||||
{t('skills.meetingBots.callDetailEmpty')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{hasSummary && summary && <CallSummary summary={summary} />}
|
||||
{transcript.length > 0 && <CallTranscript lines={transcript} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CallSummary({ summary }: { summary: MeetCallSummary }) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<SectionLabel>{t('skills.meetingBots.callSummaryHeading')}</SectionLabel>
|
||||
{summary.headline.trim() && (
|
||||
<p className="text-[11px] text-content-secondary">{summary.headline}</p>
|
||||
)}
|
||||
{summary.key_points.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-content-muted">
|
||||
{t('skills.meetingBots.callKeyPointsHeading')}
|
||||
</p>
|
||||
<ul className="mt-0.5 list-disc space-y-0.5 pl-4 text-[10px] text-content-secondary">
|
||||
{summary.key_points.map((point, i) => (
|
||||
<li key={i}>{point}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{summary.action_items.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-content-muted">
|
||||
{t('skills.meetingBots.callActionItemsHeading')}
|
||||
</p>
|
||||
<ul className="mt-0.5 space-y-0.5 text-[10px] text-content-secondary">
|
||||
{summary.action_items.map((item, i) => {
|
||||
const meta = [
|
||||
item.assignee?.trim() || undefined,
|
||||
item.kind === 'executable' ? item.tool_name?.trim() || undefined : undefined,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<li key={i} className="flex gap-1">
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>
|
||||
{item.description}
|
||||
{meta.length > 0 && (
|
||||
<span className="text-content-faint">
|
||||
{' '}
|
||||
({meta.join(' · ')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CallTranscript({ lines }: { lines: MeetCallTranscriptLine[] }) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<SectionLabel>{t('skills.meetingBots.callTranscriptHeading')}</SectionLabel>
|
||||
<div className="max-h-48 space-y-0.5 overflow-y-auto rounded-md bg-surface-muted p-2">
|
||||
{lines.map((line, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className={
|
||||
line.role === 'assistant'
|
||||
? 'text-[10px] text-ocean-700 dark:text-ocean-300'
|
||||
: 'text-[10px] text-content-secondary'
|
||||
}>
|
||||
{line.content}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Chevron({ expanded }: { expanded: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 16 16"
|
||||
className={`h-3 w-3 shrink-0 text-content-faint transition-transform ${
|
||||
expanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2">
|
||||
<path d="M6 4l4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 '—';
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MeetCallRecord } from '../../../services/meetCallService';
|
||||
import { setBackendMeetError, setBackendMeetJoined } from '../../../store/backendMeetSlice';
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import MeetingBotsCard from '../MeetingBotsCard';
|
||||
@@ -290,14 +289,17 @@ describe('MeetingBotsCard — ActiveMeetingView', () => {
|
||||
expect(screen.getByText(/hi there/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the inline form (not ActiveMeetingView) while status is joining', () => {
|
||||
it('shows the active banner (not the inline form) while status is joining', () => {
|
||||
// The redesigned composer shows the live banner for 'joining' (not the inline
|
||||
// form). The banner shows the LIVE badge and "Joining…" status text. The
|
||||
// composer unmounts so there is no meeting-link input while joining.
|
||||
renderWithProviders(<MeetingBotsCard />, {
|
||||
preloadedState: {
|
||||
backendMeet: { ...activeMeetState.backendMeet, status: 'joining' as const },
|
||||
},
|
||||
});
|
||||
expect(screen.getByLabelText(/meeting link/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/live in meeting/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/meeting link/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/joining/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the inline form (not ActiveMeetingView) when status is ended', () => {
|
||||
@@ -319,194 +321,3 @@ describe('MeetingBotsCard — ActiveMeetingView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── RecentCallsSection / RecentCallRow tests ──────────────────────────────────
|
||||
|
||||
function makeCallRecord(overrides: Partial<MeetCallRecord> = {}): MeetCallRecord {
|
||||
return {
|
||||
request_id: 'req-1',
|
||||
meet_url: 'https://meet.google.com/abc-defg-hij',
|
||||
bot_display_name: 'OpenHuman',
|
||||
owner_display_name: 'Alice',
|
||||
started_at_ms: Date.now() - 5 * 60 * 1000,
|
||||
ended_at_ms: Date.now() - 4 * 60 * 1000,
|
||||
listened_seconds: 30,
|
||||
spoken_seconds: 30,
|
||||
turn_count: 3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MeetingBotsCard — recent calls section', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('shows a loading hint while listMeetCalls is pending', () => {
|
||||
listMock.mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
expect(screen.getByText(/loading…/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty-state message when listMeetCalls returns an empty array', async () => {
|
||||
listMock.mockResolvedValueOnce([]);
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no previous calls yet/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a row for each returned call record', async () => {
|
||||
const records = [
|
||||
makeCallRecord({
|
||||
request_id: 'req-1',
|
||||
meet_url: 'https://meet.google.com/aaa-bbbb-ccc',
|
||||
turn_count: 2,
|
||||
}),
|
||||
makeCallRecord({
|
||||
request_id: 'req-2',
|
||||
meet_url: 'https://meet.google.com/ddd-eeee-fff',
|
||||
turn_count: 5,
|
||||
}),
|
||||
];
|
||||
listMock.mockResolvedValueOnce(records);
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('aaa-bbbb-ccc')).toBeInTheDocument();
|
||||
expect(screen.getByText('ddd-eeee-fff')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/2 turns/i)).toBeInTheDocument();
|
||||
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 />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('(1)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error hint and an empty list when listMeetCalls rejects', async () => {
|
||||
listMock.mockRejectedValueOnce(new Error('Network timeout'));
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/network timeout/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(/loading…/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('strips the https://meet.google.com/ prefix and shows only the meeting code', async () => {
|
||||
listMock.mockResolvedValueOnce([
|
||||
makeCallRecord({ meet_url: 'https://meet.google.com/xyz-1234-abc' }),
|
||||
]);
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('xyz-1234-abc')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('https://meet.google.com/xyz-1234-abc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows duration as combined spoken + listened seconds', async () => {
|
||||
listMock.mockResolvedValueOnce([makeCallRecord({ spoken_seconds: 40, listened_seconds: 20 })]);
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/60s on call/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a relative timestamp for recent calls', async () => {
|
||||
listMock.mockResolvedValueOnce([makeCallRecord({ started_at_ms: Date.now() - 5 * 60 * 1000 })]);
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/\dm ago/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "—" for a zero started_at_ms timestamp', async () => {
|
||||
listMock.mockResolvedValueOnce([makeCallRecord({ started_at_ms: 0 })]);
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows singular "turn" (not "turns") when turn_count is 1', async () => {
|
||||
listMock.mockResolvedValueOnce([makeCallRecord({ turn_count: 1 })]);
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 turn$/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(/1 turns/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the raw URL when it cannot be parsed', async () => {
|
||||
listMock.mockResolvedValueOnce([makeCallRecord({ meet_url: 'not-a-valid-url' })]);
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('not-a-valid-url')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows hours-ago label for a timestamp a few hours old', async () => {
|
||||
listMock.mockResolvedValueOnce([
|
||||
makeCallRecord({ started_at_ms: Date.now() - 3 * 60 * 60 * 1000 }),
|
||||
]);
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/3h ago/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "yesterday" for a timestamp ~24 hours ago', async () => {
|
||||
listMock.mockResolvedValueOnce([
|
||||
makeCallRecord({ started_at_ms: Date.now() - 25 * 60 * 60 * 1000 }),
|
||||
]);
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('yesterday')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Nd-ago label for a timestamp a few days old (< 7)', async () => {
|
||||
listMock.mockResolvedValueOnce([
|
||||
makeCallRecord({ started_at_ms: Date.now() - 3 * 24 * 60 * 60 * 1000 }),
|
||||
]);
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/3d ago/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a locale date string for a timestamp older than 7 days', async () => {
|
||||
listMock.mockResolvedValueOnce([
|
||||
makeCallRecord({ started_at_ms: Date.now() - 10 * 24 * 60 * 60 * 1000 }),
|
||||
]);
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
await waitFor(() => {
|
||||
const timestamp = screen.queryByText(/ago|yesterday|\dm|\dh/);
|
||||
expect(timestamp).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5043,9 +5043,11 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': '{label} الدعم قريبًا.',
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'Teams.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'Zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google لقاء',
|
||||
'skills.meetingBots.platforms.teams': 'Microsoft Teams',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': 'تكبير',
|
||||
'skills.meetingBots.sendTo': 'إرسال إلى',
|
||||
'skills.meetingBots.serverOverloaded':
|
||||
@@ -5087,6 +5089,60 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': 'الرد عندما أناديه',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'عند التفعيل، يرد البوت بصوت مسموع بعد أن تقول عبارة التنبيه. عند الإيقاف، يكتفي بالاستماع وتدوين النص.',
|
||||
'skills.meetingBots.history.allPlatforms': 'جميع المنصات',
|
||||
'skills.meetingBots.history.copyTranscript': 'نسخ',
|
||||
'skills.meetingBots.history.downloadTranscript': 'تنزيل',
|
||||
'skills.meetingBots.history.earlier': 'سابقًا',
|
||||
'skills.meetingBots.history.participantCount': '{count} مشارك',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count} مشاركين',
|
||||
'skills.meetingBots.history.runWithOpenHuman': 'تشغيل مع OpenHuman',
|
||||
'skills.meetingBots.history.searchPlaceholder': 'البحث في المكالمات…',
|
||||
'skills.meetingBots.history.selectPrompt': 'اختر مكالمة لعرض ملخصها ونصها.',
|
||||
'skills.meetingBots.history.today': 'اليوم',
|
||||
'skills.meetingBots.history.yesterday': 'أمس',
|
||||
'skills.meetingBots.upcoming.heading': 'القادمة',
|
||||
'skills.meetingBots.upcoming.when': 'الوقت',
|
||||
'skills.meetingBots.upcoming.meeting': 'الاجتماع',
|
||||
'skills.meetingBots.upcoming.platform': 'المنصة',
|
||||
'skills.meetingBots.upcoming.people': 'الأشخاص',
|
||||
'skills.meetingBots.upcoming.joinPolicy': 'سياسة الانضمام',
|
||||
'skills.meetingBots.upcoming.joinNow': 'انضم الآن',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': 'انضم إلى {title}',
|
||||
'skills.meetingBots.upcoming.join': 'انضم',
|
||||
'skills.meetingBots.upcoming.auto': 'تلقائي',
|
||||
'skills.meetingBots.upcoming.ask': 'اسأل',
|
||||
'skills.meetingBots.upcoming.skip': 'تخطَّ',
|
||||
'skills.meetingBots.upcoming.today': 'اليوم',
|
||||
'skills.meetingBots.upcoming.tomorrow': 'غداً',
|
||||
'skills.meetingBots.upcoming.empty':
|
||||
'لا توجد اجتماعات قادمة — قم بربط Google Calendar لرؤيتها هنا.',
|
||||
'skills.meetingBots.upcoming.error': 'تعذّر تحميل الاجتماعات القادمة.',
|
||||
'skills.meetingBots.upcoming.retry': 'إعادة المحاولة',
|
||||
'skills.meetingBots.upcoming.refresh': 'تحديث',
|
||||
'skills.meetingBots.upcoming.filterAll': 'كل المنصات',
|
||||
'skills.meetingBots.upcoming.participants': '{count} مشاركون',
|
||||
'skills.meetingBots.upcoming.imminent': 'يبدأ قريباً',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': 'ينضم تلقائيًا ~في {time}',
|
||||
'skills.meetingBots.upcoming.asksAtStart': 'يسأل عند البدء',
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
"فعِّل 'مراقبة التقويم' في الإعدادات الافتراضية (أيقونة الترس) حتى يصبح الانضمام التلقائي/السؤال فعالاً — وإلا فإن هذه السياسات محفوظة لكنها لن تُفعَّل.",
|
||||
'skills.meetingBots.relative.now': 'الآن',
|
||||
'skills.meetingBots.relative.inMinutes': 'في {count} د',
|
||||
'skills.meetingBots.relative.inHours': 'في {count} س',
|
||||
'skills.meetingBots.relative.minutesAgo': 'منذ {count} د',
|
||||
'skills.meetingBots.relative.hoursAgo': 'منذ {count} س',
|
||||
'skills.meetingBots.relative.daysAgo': 'منذ {count} ي',
|
||||
'skills.meetingBots.relative.yesterday': 'أمس',
|
||||
'skills.meetingBots.defaults.drawerTitle': 'إعدادات الاجتماع الافتراضية',
|
||||
'skills.meetingBots.defaults.closeDrawer': 'إغلاق',
|
||||
'skills.meetingBots.defaults.openDefaults': 'إعدادات الاجتماع',
|
||||
'skills.meetingBots.defaults.watchCalendar': 'مراقبة التقويم',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
'اسمح لـ OpenHuman بمراقبة التقويم المتصل حتى يتمكن من الانضمام تلقائياً أو مطالبتك بالاجتماعات بناءً على السياسات أدناه. هذا منفصل عن إشعارات تذكير الاجتماعات.',
|
||||
'skills.meetingBots.defaults.globalPolicy': 'سياسة الانضمام التلقائي العامة',
|
||||
'skills.meetingBots.defaults.perPlatformTitle': 'إعدادات خاصة بالمنصة',
|
||||
'skills.meetingBots.defaults.perPlatformDesc': 'تجاوز السياسة العامة لمنصات محددة.',
|
||||
'skills.meetingBots.defaults.useDefault': 'استخدام الافتراضي',
|
||||
'skills.resource.preview.closeAriaLabel': 'إغلاق المعاينة',
|
||||
'skills.resource.preview.failed': 'فشلت المعاينة',
|
||||
'skills.resource.preview.loading': 'جارٍ تحميل المعاينة…',
|
||||
|
||||
@@ -5147,9 +5147,11 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': '{label} সমর্থন শীঘ্রই আসছে।',
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google Meet',
|
||||
'skills.meetingBots.platforms.teams': 'মাইক্রোসফট টিম',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': 'জুম',
|
||||
'skills.meetingBots.sendTo': 'পাঠান',
|
||||
'skills.meetingBots.serverOverloaded':
|
||||
@@ -5192,6 +5194,62 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': 'আমি ডাকলে উত্তর দেবে',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'চালু থাকলে, আপনি ওয়েক ফ্রেজ বললে বটটি সশব্দে উত্তর দেয়। বন্ধ থাকলে, এটি শুধু শোনে ও প্রতিলিপি তৈরি করে।',
|
||||
'skills.meetingBots.history.allPlatforms': 'সব প্ল্যাটফর্ম',
|
||||
'skills.meetingBots.history.copyTranscript': 'কপি করুন',
|
||||
'skills.meetingBots.history.downloadTranscript': 'ডাউনলোড',
|
||||
'skills.meetingBots.history.earlier': 'আগে',
|
||||
'skills.meetingBots.history.participantCount': '{count} অংশগ্রহণকারী',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count} অংশগ্রহণকারী',
|
||||
'skills.meetingBots.history.runWithOpenHuman': 'OpenHuman দিয়ে চালান',
|
||||
'skills.meetingBots.history.searchPlaceholder': 'কল খুঁজুন…',
|
||||
'skills.meetingBots.history.selectPrompt':
|
||||
'সারাংশ এবং ট্রান্সক্রিপ্ট দেখতে একটি কল নির্বাচন করুন।',
|
||||
'skills.meetingBots.history.today': 'আজ',
|
||||
'skills.meetingBots.history.yesterday': 'গতকাল',
|
||||
'skills.meetingBots.upcoming.heading': 'আসন্ন',
|
||||
'skills.meetingBots.upcoming.when': 'কখন',
|
||||
'skills.meetingBots.upcoming.meeting': 'মিটিং',
|
||||
'skills.meetingBots.upcoming.platform': 'প্ল্যাটফর্ম',
|
||||
'skills.meetingBots.upcoming.people': 'মানুষ',
|
||||
'skills.meetingBots.upcoming.joinPolicy': 'যোগদান নীতি',
|
||||
'skills.meetingBots.upcoming.joinNow': 'এখনই যোগ দিন',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': '{title}-তে যোগ দিন',
|
||||
'skills.meetingBots.upcoming.join': 'যোগ দিন',
|
||||
'skills.meetingBots.upcoming.auto': 'অটো',
|
||||
'skills.meetingBots.upcoming.ask': 'জিজ্ঞেস করুন',
|
||||
'skills.meetingBots.upcoming.skip': 'এড়িয়ে যান',
|
||||
'skills.meetingBots.upcoming.today': 'আজ',
|
||||
'skills.meetingBots.upcoming.tomorrow': 'আগামীকাল',
|
||||
'skills.meetingBots.upcoming.empty':
|
||||
'কোনো আসন্ন মিটিং নেই — এখানে দেখতে Google Calendar সংযুক্ত করুন।',
|
||||
'skills.meetingBots.upcoming.error': 'আসন্ন মিটিং লোড করা যায়নি।',
|
||||
'skills.meetingBots.upcoming.retry': 'পুনরায় চেষ্টা করুন',
|
||||
'skills.meetingBots.upcoming.refresh': 'রিফ্রেশ',
|
||||
'skills.meetingBots.upcoming.filterAll': 'সব প্ল্যাটফর্ম',
|
||||
'skills.meetingBots.upcoming.participants': '{count} অংশগ্রহণকারী',
|
||||
'skills.meetingBots.upcoming.imminent': 'শীঘ্রই শুরু হচ্ছে',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': 'স্বয়ংক্রিয়-যোগ ~{time}-এ',
|
||||
'skills.meetingBots.upcoming.asksAtStart': 'শুরুতে জিজ্ঞাসা করে',
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
"'আমার ক্যালেন্ডার দেখুন' ডিফল্টস (গিয়ার আইকন)-এ চালু করুন যাতে স্বয়ংক্রিয়/জিজ্ঞাসা কার্যকর হয় — অন্যথায় এই নীতিগুলি সংরক্ষিত কিন্তু ট্রিগার হবে না।",
|
||||
'skills.meetingBots.relative.now': 'এখন',
|
||||
'skills.meetingBots.relative.inMinutes': '{count} মিনিটে',
|
||||
'skills.meetingBots.relative.inHours': '{count} ঘন্টায়',
|
||||
'skills.meetingBots.relative.minutesAgo': '{count} মিনিট আগে',
|
||||
'skills.meetingBots.relative.hoursAgo': '{count} ঘন্টা আগে',
|
||||
'skills.meetingBots.relative.daysAgo': '{count} দিন আগে',
|
||||
'skills.meetingBots.relative.yesterday': 'গতকাল',
|
||||
'skills.meetingBots.defaults.drawerTitle': 'মিটিং ডিফল্ট',
|
||||
'skills.meetingBots.defaults.closeDrawer': 'বন্ধ করুন',
|
||||
'skills.meetingBots.defaults.openDefaults': 'মিটিং সেটিং',
|
||||
'skills.meetingBots.defaults.watchCalendar': 'আমার ক্যালেন্ডার দেখুন',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
'OpenHuman-কে আপনার সংযুক্ত ক্যালেন্ডার দেখতে দিন যাতে এটি নীচের নীতিগুলির উপর ভিত্তি করে মিটিংয়ে স্বয়ংক্রিয়ভাবে যোগ দিতে বা অনুরোধ করতে পারে। এটি মিটিং অনুস্মারক বিজ্ঞপ্তি থেকে আলাদা।',
|
||||
'skills.meetingBots.defaults.globalPolicy': 'বৈশ্বিক স্বয়ংক্রিয়-যোগ দেওয়ার নীতি',
|
||||
'skills.meetingBots.defaults.perPlatformTitle': 'প্রতি-প্ল্যাটফর্ম ওভাররাইড',
|
||||
'skills.meetingBots.defaults.perPlatformDesc':
|
||||
'নির্দিষ্ট প্ল্যাটফর্মের জন্য বৈশ্বিক নীতি ওভাররাইড করুন।',
|
||||
'skills.meetingBots.defaults.useDefault': 'ডিফল্ট ব্যবহার করুন',
|
||||
'skills.resource.preview.closeAriaLabel': 'প্রিভিউ বন্ধ করুন',
|
||||
'skills.resource.preview.failed': 'প্রিভিউ ব্যর্থ',
|
||||
'skills.resource.preview.loading': 'প্রিভিউ লোড হচ্ছে…',
|
||||
|
||||
@@ -5279,9 +5279,11 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': '{label}-Unterstützung ist bald verfügbar.',
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google Treffen Sie',
|
||||
'skills.meetingBots.platforms.teams': 'Microsoft Teams',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': 'Zoom',
|
||||
'skills.meetingBots.sendTo': 'Senden an',
|
||||
'skills.meetingBots.serverOverloaded':
|
||||
@@ -5325,6 +5327,62 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': 'Antworten, wenn ich es anspreche',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'Wenn aktiviert, antwortet der Bot hörbar, nachdem du seinen Weckruf gesagt hast. Wenn deaktiviert, hört er nur zu und transkribiert.',
|
||||
'skills.meetingBots.history.allPlatforms': 'Alle Plattformen',
|
||||
'skills.meetingBots.history.copyTranscript': 'Kopieren',
|
||||
'skills.meetingBots.history.downloadTranscript': 'Herunterladen',
|
||||
'skills.meetingBots.history.earlier': 'Früher',
|
||||
'skills.meetingBots.history.participantCount': '{count} Teilnehmer',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count} Teilnehmer',
|
||||
'skills.meetingBots.history.runWithOpenHuman': 'Mit OpenHuman ausführen',
|
||||
'skills.meetingBots.history.searchPlaceholder': 'Anrufe suchen…',
|
||||
'skills.meetingBots.history.selectPrompt':
|
||||
'Wähle einen Anruf aus, um die Zusammenfassung und das Transkript zu sehen.',
|
||||
'skills.meetingBots.history.today': 'Heute',
|
||||
'skills.meetingBots.history.yesterday': 'Gestern',
|
||||
'skills.meetingBots.upcoming.heading': 'Bevorstehend',
|
||||
'skills.meetingBots.upcoming.when': 'Wann',
|
||||
'skills.meetingBots.upcoming.meeting': 'Meeting',
|
||||
'skills.meetingBots.upcoming.platform': 'Plattform',
|
||||
'skills.meetingBots.upcoming.people': 'Personen',
|
||||
'skills.meetingBots.upcoming.joinPolicy': 'Beitrittsstrategie',
|
||||
'skills.meetingBots.upcoming.joinNow': 'Jetzt beitreten',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': '{title} beitreten',
|
||||
'skills.meetingBots.upcoming.join': 'Beitreten',
|
||||
'skills.meetingBots.upcoming.auto': 'Auto',
|
||||
'skills.meetingBots.upcoming.ask': 'Fragen',
|
||||
'skills.meetingBots.upcoming.skip': 'Überspringen',
|
||||
'skills.meetingBots.upcoming.today': 'Heute',
|
||||
'skills.meetingBots.upcoming.tomorrow': 'Morgen',
|
||||
'skills.meetingBots.upcoming.empty':
|
||||
'Keine bevorstehenden Meetings — verbinde Google Calendar, um sie hier zu sehen.',
|
||||
'skills.meetingBots.upcoming.error': 'Bevorstehende Meetings konnten nicht geladen werden.',
|
||||
'skills.meetingBots.upcoming.retry': 'Erneut versuchen',
|
||||
'skills.meetingBots.upcoming.refresh': 'Aktualisieren',
|
||||
'skills.meetingBots.upcoming.filterAll': 'Alle Plattformen',
|
||||
'skills.meetingBots.upcoming.participants': '{count} Teilnehmer',
|
||||
'skills.meetingBots.upcoming.imminent': 'Beginnt bald',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': 'Auto-Beitritt ~um {time}',
|
||||
'skills.meetingBots.upcoming.asksAtStart': 'Fragt beim Start',
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
"Aktivieren Sie 'Meinen Kalender überwachen' in den Standardeinstellungen (Zahnrad-Symbol), damit Auto/Fragen wirksam wird — andernfalls werden diese Richtlinien gespeichert, aber nicht ausgelöst.",
|
||||
'skills.meetingBots.relative.now': 'jetzt',
|
||||
'skills.meetingBots.relative.inMinutes': 'in {count} Min',
|
||||
'skills.meetingBots.relative.inHours': 'in {count} Std',
|
||||
'skills.meetingBots.relative.minutesAgo': 'vor {count} Min',
|
||||
'skills.meetingBots.relative.hoursAgo': 'vor {count} Std',
|
||||
'skills.meetingBots.relative.daysAgo': 'vor {count} T',
|
||||
'skills.meetingBots.relative.yesterday': 'gestern',
|
||||
'skills.meetingBots.defaults.drawerTitle': 'Besprechungsstandards',
|
||||
'skills.meetingBots.defaults.closeDrawer': 'Schließen',
|
||||
'skills.meetingBots.defaults.openDefaults': 'Besprechungsstandards',
|
||||
'skills.meetingBots.defaults.watchCalendar': 'Meinen Kalender überwachen',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
'Lassen Sie OpenHuman Ihren verbundenen Kalender überwachen, damit er Meetings automatisch beitreten oder Sie gemäß den folgenden Richtlinien dazu auffordern kann. Dies ist von Meeting-Erinnerungsbenachrichtigungen getrennt.',
|
||||
'skills.meetingBots.defaults.globalPolicy': 'Globale Auto-Beitrittsrichtlinie',
|
||||
'skills.meetingBots.defaults.perPlatformTitle': 'Plattformspezifische Einstellungen',
|
||||
'skills.meetingBots.defaults.perPlatformDesc':
|
||||
'Globale Richtlinie für bestimmte Plattformen überschreiben.',
|
||||
'skills.meetingBots.defaults.useDefault': 'Standard verwenden',
|
||||
'skills.resource.preview.closeAriaLabel': 'Vorschau schließen',
|
||||
'skills.resource.preview.failed': 'Vorschau fehlgeschlagen',
|
||||
'skills.resource.preview.loading': 'Vorschau wird geladen…',
|
||||
|
||||
@@ -5794,9 +5794,11 @@ const en: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': '{label} support is coming soon.',
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google Meet',
|
||||
'skills.meetingBots.platforms.teams': 'Microsoft Teams',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': 'Zoom',
|
||||
'skills.meetingBots.sendTo': 'Send to {label}',
|
||||
'skills.meetingBots.serverOverloaded':
|
||||
@@ -5839,6 +5841,61 @@ const en: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': 'Respond when I address it',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'When on, the bot speaks a reply after you say its wake phrase. When off, it only listens and transcribes.',
|
||||
'skills.meetingBots.history.allPlatforms': 'All platforms',
|
||||
'skills.meetingBots.history.copyTranscript': 'Copy',
|
||||
'skills.meetingBots.history.downloadTranscript': 'Download',
|
||||
'skills.meetingBots.history.earlier': 'Earlier',
|
||||
'skills.meetingBots.history.participantCount': '{count} participant',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count} participants',
|
||||
'skills.meetingBots.history.runWithOpenHuman': 'Run with OpenHuman',
|
||||
'skills.meetingBots.history.searchPlaceholder': 'Search calls…',
|
||||
'skills.meetingBots.history.selectPrompt': 'Select a call to see its summary and transcript.',
|
||||
'skills.meetingBots.history.today': 'Today',
|
||||
'skills.meetingBots.history.yesterday': 'Yesterday',
|
||||
'skills.meetingBots.upcoming.heading': 'Upcoming',
|
||||
'skills.meetingBots.upcoming.when': 'When',
|
||||
'skills.meetingBots.upcoming.meeting': 'Meeting',
|
||||
'skills.meetingBots.upcoming.platform': 'Platform',
|
||||
'skills.meetingBots.upcoming.people': 'People',
|
||||
'skills.meetingBots.upcoming.joinPolicy': 'Join Policy',
|
||||
'skills.meetingBots.upcoming.joinNow': 'Join now',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': 'Join {title}',
|
||||
'skills.meetingBots.upcoming.join': 'Join',
|
||||
'skills.meetingBots.upcoming.auto': 'Auto',
|
||||
'skills.meetingBots.upcoming.ask': 'Ask',
|
||||
'skills.meetingBots.upcoming.skip': 'Skip',
|
||||
'skills.meetingBots.upcoming.today': 'Today',
|
||||
'skills.meetingBots.upcoming.tomorrow': 'Tomorrow',
|
||||
'skills.meetingBots.upcoming.empty':
|
||||
'No upcoming meetings — connect Google Calendar to see them here.',
|
||||
'skills.meetingBots.upcoming.error': 'Couldn’t load upcoming meetings.',
|
||||
'skills.meetingBots.upcoming.retry': 'Retry',
|
||||
'skills.meetingBots.upcoming.refresh': 'Refresh',
|
||||
'skills.meetingBots.upcoming.filterAll': 'All platforms',
|
||||
'skills.meetingBots.upcoming.participants': '{count} participants',
|
||||
'skills.meetingBots.upcoming.imminent': 'Starting soon',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': 'Auto-joins ~at {time}',
|
||||
'skills.meetingBots.upcoming.asksAtStart': 'Asks at start',
|
||||
'skills.meetingBots.relative.now': 'now',
|
||||
'skills.meetingBots.relative.inMinutes': 'in {count}m',
|
||||
'skills.meetingBots.relative.inHours': 'in {count}h',
|
||||
'skills.meetingBots.relative.minutesAgo': '{count}m ago',
|
||||
'skills.meetingBots.relative.hoursAgo': '{count}h ago',
|
||||
'skills.meetingBots.relative.daysAgo': '{count}d ago',
|
||||
'skills.meetingBots.relative.yesterday': 'yesterday',
|
||||
'skills.meetingBots.defaults.drawerTitle': 'Meeting Defaults',
|
||||
'skills.meetingBots.defaults.closeDrawer': 'Close defaults',
|
||||
'skills.meetingBots.defaults.openDefaults': 'Meeting defaults',
|
||||
'skills.meetingBots.defaults.watchCalendar': 'Watch my calendar',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
'Let OpenHuman watch your connected calendar so it can auto-join or prompt for meetings based on the policies below. This is separate from meeting reminder notifications.',
|
||||
'skills.meetingBots.defaults.globalPolicy': 'Global auto-join policy',
|
||||
'skills.meetingBots.defaults.perPlatformTitle': 'Per-platform overrides',
|
||||
'skills.meetingBots.defaults.perPlatformDesc':
|
||||
'Override the global policy for specific platforms.',
|
||||
'skills.meetingBots.defaults.useDefault': 'Use default',
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
"Turn on 'Watch my calendar' in Defaults (gear icon) for Auto/Ask to take effect — otherwise these policies are saved but won't trigger.",
|
||||
'skills.resource.preview.closeAriaLabel': 'Close preview',
|
||||
'skills.resource.preview.failed': 'Preview failed',
|
||||
'skills.resource.preview.loading': 'Loading preview…',
|
||||
|
||||
@@ -5243,9 +5243,11 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': 'El soporte de {label} llegará pronto.',
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'equipos.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google Conocer',
|
||||
'skills.meetingBots.platforms.teams': 'Equipos de Microsoft',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': 'Ampliar',
|
||||
'skills.meetingBots.sendTo': 'Enviar a',
|
||||
'skills.meetingBots.serverOverloaded':
|
||||
@@ -5290,6 +5292,62 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': 'Responder cuando me dirija a él',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'Si está activado, el bot responde en voz alta después de que digas su frase de activación. Si está desactivado, solo escucha y transcribe.',
|
||||
'skills.meetingBots.history.allPlatforms': 'Todas las plataformas',
|
||||
'skills.meetingBots.history.copyTranscript': 'Copiar',
|
||||
'skills.meetingBots.history.downloadTranscript': 'Descargar',
|
||||
'skills.meetingBots.history.earlier': 'Antes',
|
||||
'skills.meetingBots.history.participantCount': '{count} participante',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count} participantes',
|
||||
'skills.meetingBots.history.runWithOpenHuman': 'Ejecutar con OpenHuman',
|
||||
'skills.meetingBots.history.searchPlaceholder': 'Buscar llamadas…',
|
||||
'skills.meetingBots.history.selectPrompt':
|
||||
'Selecciona una llamada para ver su resumen y transcripción.',
|
||||
'skills.meetingBots.history.today': 'Hoy',
|
||||
'skills.meetingBots.history.yesterday': 'Ayer',
|
||||
'skills.meetingBots.upcoming.heading': 'Próximas',
|
||||
'skills.meetingBots.upcoming.when': 'Cuándo',
|
||||
'skills.meetingBots.upcoming.meeting': 'Reunión',
|
||||
'skills.meetingBots.upcoming.platform': 'Plataforma',
|
||||
'skills.meetingBots.upcoming.people': 'Personas',
|
||||
'skills.meetingBots.upcoming.joinPolicy': 'Política de unión',
|
||||
'skills.meetingBots.upcoming.joinNow': 'Unirse ahora',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': 'Unirse a {title}',
|
||||
'skills.meetingBots.upcoming.join': 'Unirse',
|
||||
'skills.meetingBots.upcoming.auto': 'Auto',
|
||||
'skills.meetingBots.upcoming.ask': 'Preguntar',
|
||||
'skills.meetingBots.upcoming.skip': 'Omitir',
|
||||
'skills.meetingBots.upcoming.today': 'Hoy',
|
||||
'skills.meetingBots.upcoming.tomorrow': 'Mañana',
|
||||
'skills.meetingBots.upcoming.empty':
|
||||
'No hay reuniones próximas — conecta Google Calendar para verlas aquí.',
|
||||
'skills.meetingBots.upcoming.error': 'No se pudieron cargar las reuniones próximas.',
|
||||
'skills.meetingBots.upcoming.retry': 'Reintentar',
|
||||
'skills.meetingBots.upcoming.refresh': 'Actualizar',
|
||||
'skills.meetingBots.upcoming.filterAll': 'Todas las plataformas',
|
||||
'skills.meetingBots.upcoming.participants': '{count} participantes',
|
||||
'skills.meetingBots.upcoming.imminent': 'Comienza pronto',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': 'Se une automáticamente ~a las {time}',
|
||||
'skills.meetingBots.upcoming.asksAtStart': 'Pregunta al inicio',
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
"Activa 'Vigilar mi calendario' en Predeterminados (icono de engranaje) para que Auto/Preguntar surta efecto — de lo contrario, estas políticas se guardan pero no se activarán.",
|
||||
'skills.meetingBots.relative.now': 'ahora',
|
||||
'skills.meetingBots.relative.inMinutes': 'en {count}m',
|
||||
'skills.meetingBots.relative.inHours': 'en {count}h',
|
||||
'skills.meetingBots.relative.minutesAgo': 'hace {count}m',
|
||||
'skills.meetingBots.relative.hoursAgo': 'hace {count}h',
|
||||
'skills.meetingBots.relative.daysAgo': 'hace {count}d',
|
||||
'skills.meetingBots.relative.yesterday': 'ayer',
|
||||
'skills.meetingBots.defaults.drawerTitle': 'Valores predeterminados de reuniones',
|
||||
'skills.meetingBots.defaults.closeDrawer': 'Cerrar',
|
||||
'skills.meetingBots.defaults.openDefaults': 'Valores predeterminados',
|
||||
'skills.meetingBots.defaults.watchCalendar': 'Vigilar mi calendario',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
'Permite que OpenHuman observe tu calendario conectado para que pueda unirse automáticamente o solicitar reuniones según las políticas a continuación. Esto es independiente de las notificaciones de recordatorio de reuniones.',
|
||||
'skills.meetingBots.defaults.globalPolicy': 'Política global de unión automática',
|
||||
'skills.meetingBots.defaults.perPlatformTitle': 'Configuración por plataforma',
|
||||
'skills.meetingBots.defaults.perPlatformDesc':
|
||||
'Anular la política global para plataformas específicas.',
|
||||
'skills.meetingBots.defaults.useDefault': 'Usar predeterminado',
|
||||
'skills.resource.preview.closeAriaLabel': 'Cerrar vista previa',
|
||||
'skills.resource.preview.failed': 'Vista previa fallida',
|
||||
'skills.resource.preview.loading': 'Cargando vista previa…',
|
||||
|
||||
@@ -5264,9 +5264,11 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': '{label} sera bientôt disponible.',
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google Meet',
|
||||
'skills.meetingBots.platforms.teams': 'Microsoft Teams',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': 'Zoom',
|
||||
'skills.meetingBots.sendTo': 'Envoyer à',
|
||||
'skills.meetingBots.serverOverloaded':
|
||||
@@ -5310,6 +5312,62 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': 'Répondre quand je m’adresse à lui',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'Activé, le bot répond à voix haute après que vous prononcez sa phrase d’activation. Désactivé, il se contente d’écouter et de transcrire.',
|
||||
'skills.meetingBots.history.allPlatforms': 'Toutes les plateformes',
|
||||
'skills.meetingBots.history.copyTranscript': 'Copier',
|
||||
'skills.meetingBots.history.downloadTranscript': 'Télécharger',
|
||||
'skills.meetingBots.history.earlier': 'Plus tôt',
|
||||
'skills.meetingBots.history.participantCount': '{count} participant',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count} participants',
|
||||
'skills.meetingBots.history.runWithOpenHuman': 'Exécuter avec OpenHuman',
|
||||
'skills.meetingBots.history.searchPlaceholder': 'Rechercher des appels…',
|
||||
'skills.meetingBots.history.selectPrompt':
|
||||
'Sélectionnez un appel pour voir son résumé et sa transcription.',
|
||||
'skills.meetingBots.history.today': "Aujourd'hui",
|
||||
'skills.meetingBots.history.yesterday': 'Hier',
|
||||
'skills.meetingBots.upcoming.heading': 'À venir',
|
||||
'skills.meetingBots.upcoming.when': 'Quand',
|
||||
'skills.meetingBots.upcoming.meeting': 'Réunion',
|
||||
'skills.meetingBots.upcoming.platform': 'Plateforme',
|
||||
'skills.meetingBots.upcoming.people': 'Personnes',
|
||||
'skills.meetingBots.upcoming.joinPolicy': 'Politique de participation',
|
||||
'skills.meetingBots.upcoming.joinNow': 'Rejoindre maintenant',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': 'Rejoindre {title}',
|
||||
'skills.meetingBots.upcoming.join': 'Rejoindre',
|
||||
'skills.meetingBots.upcoming.auto': 'Auto',
|
||||
'skills.meetingBots.upcoming.ask': 'Demander',
|
||||
'skills.meetingBots.upcoming.skip': 'Ignorer',
|
||||
'skills.meetingBots.upcoming.today': "Aujourd'hui",
|
||||
'skills.meetingBots.upcoming.tomorrow': 'Demain',
|
||||
'skills.meetingBots.upcoming.empty':
|
||||
'Aucune réunion à venir — connectez Google Calendar pour les voir ici.',
|
||||
'skills.meetingBots.upcoming.error': 'Impossible de charger les réunions à venir.',
|
||||
'skills.meetingBots.upcoming.retry': 'Réessayer',
|
||||
'skills.meetingBots.upcoming.refresh': 'Actualiser',
|
||||
'skills.meetingBots.upcoming.filterAll': 'Toutes les plateformes',
|
||||
'skills.meetingBots.upcoming.participants': '{count} participants',
|
||||
'skills.meetingBots.upcoming.imminent': 'Commence bientôt',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': 'Rejoint automatiquement ~à {time}',
|
||||
'skills.meetingBots.upcoming.asksAtStart': 'Demande au début',
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
"Activez 'Surveiller mon agenda' dans les Paramètres (icône engrenage) pour que Auto/Demander prenne effet — sinon ces politiques sont enregistrées mais ne se déclencheront pas.",
|
||||
'skills.meetingBots.relative.now': 'maintenant',
|
||||
'skills.meetingBots.relative.inMinutes': 'dans {count}m',
|
||||
'skills.meetingBots.relative.inHours': 'dans {count}h',
|
||||
'skills.meetingBots.relative.minutesAgo': 'il y a {count}m',
|
||||
'skills.meetingBots.relative.hoursAgo': 'il y a {count}h',
|
||||
'skills.meetingBots.relative.daysAgo': 'il y a {count}j',
|
||||
'skills.meetingBots.relative.yesterday': 'hier',
|
||||
'skills.meetingBots.defaults.drawerTitle': 'Paramètres de réunion',
|
||||
'skills.meetingBots.defaults.closeDrawer': 'Fermer',
|
||||
'skills.meetingBots.defaults.openDefaults': 'Paramètres de réunion',
|
||||
'skills.meetingBots.defaults.watchCalendar': 'Surveiller mon agenda',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
"Laissez OpenHuman surveiller votre agenda connecté pour qu'il puisse rejoindre automatiquement ou vous inviter à des réunions selon les politiques ci-dessous. Cela est indépendant des notifications de rappel de réunion.",
|
||||
'skills.meetingBots.defaults.globalPolicy': "Politique d'adhésion automatique globale",
|
||||
'skills.meetingBots.defaults.perPlatformTitle': 'Paramètres par plateforme',
|
||||
'skills.meetingBots.defaults.perPlatformDesc':
|
||||
'Remplacer la politique globale pour des plateformes spécifiques.',
|
||||
'skills.meetingBots.defaults.useDefault': 'Utiliser par défaut',
|
||||
'skills.resource.preview.closeAriaLabel': "Fermer l'aperçu",
|
||||
'skills.resource.preview.failed': "Échec de l'aperçu",
|
||||
'skills.resource.preview.loading': "Chargement de l'aperçu…",
|
||||
|
||||
@@ -5151,9 +5151,11 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': '{label} समर्थन जल्द ही आ रहा है।',
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'Teams.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'Zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google मिलें',
|
||||
'skills.meetingBots.platforms.teams': 'माइक्रोसॉफ्ट टीमें',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': 'ज़ूम करें',
|
||||
'skills.meetingBots.sendTo': 'भेजें',
|
||||
'skills.meetingBots.serverOverloaded':
|
||||
@@ -5197,6 +5199,61 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': 'जब मैं बुलाऊँ तब जवाब दे',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'चालू होने पर, वेक फ़्रेज़ कहने के बाद बॉट बोलकर जवाब देता है। बंद होने पर, यह सिर्फ़ सुनता और ट्रांसक्राइब करता है।',
|
||||
'skills.meetingBots.history.allPlatforms': 'सभी प्लेटफ़ॉर्म',
|
||||
'skills.meetingBots.history.copyTranscript': 'कॉपी करें',
|
||||
'skills.meetingBots.history.downloadTranscript': 'डाउनलोड',
|
||||
'skills.meetingBots.history.earlier': 'पहले',
|
||||
'skills.meetingBots.history.participantCount': '{count} प्रतिभागी',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count} प्रतिभागी',
|
||||
'skills.meetingBots.history.runWithOpenHuman': 'OpenHuman के साथ चलाएँ',
|
||||
'skills.meetingBots.history.searchPlaceholder': 'कॉल खोजें…',
|
||||
'skills.meetingBots.history.selectPrompt': 'सारांश और ट्रांसक्रिप्ट देखने के लिए एक कॉल चुनें।',
|
||||
'skills.meetingBots.history.today': 'आज',
|
||||
'skills.meetingBots.history.yesterday': 'कल',
|
||||
'skills.meetingBots.upcoming.heading': 'आगामी',
|
||||
'skills.meetingBots.upcoming.when': 'कब',
|
||||
'skills.meetingBots.upcoming.meeting': 'मीटिंग',
|
||||
'skills.meetingBots.upcoming.platform': 'प्लेटफ़ॉर्म',
|
||||
'skills.meetingBots.upcoming.people': 'लोग',
|
||||
'skills.meetingBots.upcoming.joinPolicy': 'जॉइन नीति',
|
||||
'skills.meetingBots.upcoming.joinNow': 'अभी जॉइन करें',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': '{title} जॉइन करें',
|
||||
'skills.meetingBots.upcoming.join': 'जॉइन करें',
|
||||
'skills.meetingBots.upcoming.auto': 'ऑटो',
|
||||
'skills.meetingBots.upcoming.ask': 'पूछें',
|
||||
'skills.meetingBots.upcoming.skip': 'छोड़ें',
|
||||
'skills.meetingBots.upcoming.today': 'आज',
|
||||
'skills.meetingBots.upcoming.tomorrow': 'कल',
|
||||
'skills.meetingBots.upcoming.empty':
|
||||
'कोई आगामी मीटिंग नहीं — यहाँ देखने के लिए Google Calendar कनेक्ट करें।',
|
||||
'skills.meetingBots.upcoming.error': 'आगामी मीटिंग लोड नहीं हो सकीं।',
|
||||
'skills.meetingBots.upcoming.retry': 'पुनः प्रयास करें',
|
||||
'skills.meetingBots.upcoming.refresh': 'रिफ्रेश करें',
|
||||
'skills.meetingBots.upcoming.filterAll': 'सभी प्लेटफ़ॉर्म',
|
||||
'skills.meetingBots.upcoming.participants': '{count} प्रतिभागी',
|
||||
'skills.meetingBots.upcoming.imminent': 'जल्द शुरू हो रही है',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': 'ऑटो-जॉइन ~{time} पर',
|
||||
'skills.meetingBots.upcoming.asksAtStart': 'शुरुआत में पूछता है',
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
"ऑटो/पूछें प्रभावी करने के लिए डिफ़ॉल्ट (गियर आइकन) में 'मेरा कैलेंडर देखें' चालू करें — अन्यथा ये नीतियाँ सहेजी जाती हैं लेकिन ट्रिगर नहीं होंगी।",
|
||||
'skills.meetingBots.relative.now': 'अभी',
|
||||
'skills.meetingBots.relative.inMinutes': '{count}म में',
|
||||
'skills.meetingBots.relative.inHours': '{count}घ में',
|
||||
'skills.meetingBots.relative.minutesAgo': '{count}म पहले',
|
||||
'skills.meetingBots.relative.hoursAgo': '{count}घ पहले',
|
||||
'skills.meetingBots.relative.daysAgo': '{count}दि पहले',
|
||||
'skills.meetingBots.relative.yesterday': 'कल',
|
||||
'skills.meetingBots.defaults.drawerTitle': 'मीटिंग डिफ़ॉल्ट',
|
||||
'skills.meetingBots.defaults.closeDrawer': 'बंद करें',
|
||||
'skills.meetingBots.defaults.openDefaults': 'मीटिंग सेटिंग',
|
||||
'skills.meetingBots.defaults.watchCalendar': 'मेरा कैलेंडर देखें',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
'OpenHuman को अपना कनेक्टेड कैलेंडर देखने दें ताकि यह नीचे दी गई नीतियों के आधार पर बैठकों में स्वचालित रूप से शामिल हो सके या संकेत दे सके। यह बैठक अनुस्मारक सूचनाओं से अलग है।',
|
||||
'skills.meetingBots.defaults.globalPolicy': 'वैश्विक ऑटो-जॉइन नीति',
|
||||
'skills.meetingBots.defaults.perPlatformTitle': 'प्रति-प्लेटफ़ॉर्म ओवरराइड',
|
||||
'skills.meetingBots.defaults.perPlatformDesc':
|
||||
'विशिष्ट प्लेटफ़ॉर्म के लिए वैश्विक नीति को ओवरराइड करें।',
|
||||
'skills.meetingBots.defaults.useDefault': 'डिफ़ॉल्ट उपयोग करें',
|
||||
'skills.resource.preview.closeAriaLabel': 'प्रीव्यू बंद करें',
|
||||
'skills.resource.preview.failed': 'पूर्वावलोकन विफल',
|
||||
'skills.resource.preview.loading': 'प्रीव्यू लोड हो रहा है…',
|
||||
|
||||
@@ -5165,9 +5165,11 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': '{label} dukungan akan segera hadir.',
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'team.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google Temui',
|
||||
'skills.meetingBots.platforms.teams': 'Microsoft Teams',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': 'Zoom',
|
||||
'skills.meetingBots.sendTo': 'Kirim ke',
|
||||
'skills.meetingBots.serverOverloaded':
|
||||
@@ -5211,6 +5213,61 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': 'Tanggapi saat saya menyapa',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'Saat aktif, bot menjawab dengan suara setelah Anda mengucapkan frasa pemicunya. Saat nonaktif, bot hanya mendengarkan dan mentranskripsikan.',
|
||||
'skills.meetingBots.history.allPlatforms': 'Semua platform',
|
||||
'skills.meetingBots.history.copyTranscript': 'Salin',
|
||||
'skills.meetingBots.history.downloadTranscript': 'Unduh',
|
||||
'skills.meetingBots.history.earlier': 'Sebelumnya',
|
||||
'skills.meetingBots.history.participantCount': '{count} peserta',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count} peserta',
|
||||
'skills.meetingBots.history.runWithOpenHuman': 'Jalankan dengan OpenHuman',
|
||||
'skills.meetingBots.history.searchPlaceholder': 'Cari panggilan…',
|
||||
'skills.meetingBots.history.selectPrompt':
|
||||
'Pilih panggilan untuk melihat ringkasan dan transkripnya.',
|
||||
'skills.meetingBots.history.today': 'Hari ini',
|
||||
'skills.meetingBots.history.yesterday': 'Kemarin',
|
||||
'skills.meetingBots.upcoming.heading': 'Mendatang',
|
||||
'skills.meetingBots.upcoming.when': 'Kapan',
|
||||
'skills.meetingBots.upcoming.meeting': 'Rapat',
|
||||
'skills.meetingBots.upcoming.platform': 'Platform',
|
||||
'skills.meetingBots.upcoming.people': 'Orang',
|
||||
'skills.meetingBots.upcoming.joinPolicy': 'Kebijakan bergabung',
|
||||
'skills.meetingBots.upcoming.joinNow': 'Bergabung sekarang',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': 'Bergabung dengan {title}',
|
||||
'skills.meetingBots.upcoming.join': 'Bergabung',
|
||||
'skills.meetingBots.upcoming.auto': 'Otomatis',
|
||||
'skills.meetingBots.upcoming.ask': 'Tanya',
|
||||
'skills.meetingBots.upcoming.skip': 'Lewati',
|
||||
'skills.meetingBots.upcoming.today': 'Hari ini',
|
||||
'skills.meetingBots.upcoming.tomorrow': 'Besok',
|
||||
'skills.meetingBots.upcoming.empty':
|
||||
'Tidak ada rapat mendatang — hubungkan Google Calendar untuk melihatnya di sini.',
|
||||
'skills.meetingBots.upcoming.error': 'Tidak dapat memuat rapat mendatang.',
|
||||
'skills.meetingBots.upcoming.retry': 'Coba lagi',
|
||||
'skills.meetingBots.upcoming.refresh': 'Segarkan',
|
||||
'skills.meetingBots.upcoming.filterAll': 'Semua platform',
|
||||
'skills.meetingBots.upcoming.participants': '{count} peserta',
|
||||
'skills.meetingBots.upcoming.imminent': 'Segera dimulai',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': 'Bergabung otomatis ~pukul {time}',
|
||||
'skills.meetingBots.upcoming.asksAtStart': 'Bertanya saat mulai',
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
"Aktifkan 'Pantau kalender saya' di Pengaturan Default (ikon gigi) agar Auto/Tanya berlaku — jika tidak, kebijakan ini disimpan tetapi tidak akan dipicu.",
|
||||
'skills.meetingBots.relative.now': 'sekarang',
|
||||
'skills.meetingBots.relative.inMinutes': 'dalam {count}m',
|
||||
'skills.meetingBots.relative.inHours': 'dalam {count}j',
|
||||
'skills.meetingBots.relative.minutesAgo': '{count}m lalu',
|
||||
'skills.meetingBots.relative.hoursAgo': '{count}j lalu',
|
||||
'skills.meetingBots.relative.daysAgo': '{count}h lalu',
|
||||
'skills.meetingBots.relative.yesterday': 'kemarin',
|
||||
'skills.meetingBots.defaults.drawerTitle': 'Default Rapat',
|
||||
'skills.meetingBots.defaults.closeDrawer': 'Tutup',
|
||||
'skills.meetingBots.defaults.openDefaults': 'Pengaturan rapat',
|
||||
'skills.meetingBots.defaults.watchCalendar': 'Pantau kalender saya',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
'Biarkan OpenHuman memantau kalender yang terhubung agar dapat bergabung otomatis atau meminta konfirmasi untuk rapat berdasarkan kebijakan di bawah ini. Ini terpisah dari notifikasi pengingat rapat.',
|
||||
'skills.meetingBots.defaults.globalPolicy': 'Kebijakan bergabung otomatis global',
|
||||
'skills.meetingBots.defaults.perPlatformTitle': 'Pengaturan per platform',
|
||||
'skills.meetingBots.defaults.perPlatformDesc': 'Timpa kebijakan global untuk platform tertentu.',
|
||||
'skills.meetingBots.defaults.useDefault': 'Gunakan default',
|
||||
'skills.resource.preview.closeAriaLabel': 'Tutup pratinjau',
|
||||
'skills.resource.preview.failed': 'Pratinjau gagal',
|
||||
'skills.resource.preview.loading': 'Memuat pratinjau...',
|
||||
|
||||
@@ -5232,9 +5232,11 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': 'Il supporto {label} sarà presto disponibile.',
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google Incontra',
|
||||
'skills.meetingBots.platforms.teams': 'Microsoft Teams',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': 'Zoom',
|
||||
'skills.meetingBots.sendTo': 'Invia a',
|
||||
'skills.meetingBots.serverOverloaded':
|
||||
@@ -5279,6 +5281,62 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': 'Rispondi quando mi rivolgo a lui',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'Se attivo, il bot risponde ad alta voce dopo che pronunci la sua frase di attivazione. Se disattivato, si limita ad ascoltare e trascrivere.',
|
||||
'skills.meetingBots.history.allPlatforms': 'Tutte le piattaforme',
|
||||
'skills.meetingBots.history.copyTranscript': 'Copia',
|
||||
'skills.meetingBots.history.downloadTranscript': 'Scarica',
|
||||
'skills.meetingBots.history.earlier': 'Prima',
|
||||
'skills.meetingBots.history.participantCount': '{count} partecipante',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count} partecipanti',
|
||||
'skills.meetingBots.history.runWithOpenHuman': 'Esegui con OpenHuman',
|
||||
'skills.meetingBots.history.searchPlaceholder': 'Cerca chiamate…',
|
||||
'skills.meetingBots.history.selectPrompt':
|
||||
'Seleziona una chiamata per vedere il riepilogo e la trascrizione.',
|
||||
'skills.meetingBots.history.today': 'Oggi',
|
||||
'skills.meetingBots.history.yesterday': 'Ieri',
|
||||
'skills.meetingBots.upcoming.heading': 'In arrivo',
|
||||
'skills.meetingBots.upcoming.when': 'Quando',
|
||||
'skills.meetingBots.upcoming.meeting': 'Riunione',
|
||||
'skills.meetingBots.upcoming.platform': 'Piattaforma',
|
||||
'skills.meetingBots.upcoming.people': 'Persone',
|
||||
'skills.meetingBots.upcoming.joinPolicy': 'Politica di partecipazione',
|
||||
'skills.meetingBots.upcoming.joinNow': 'Partecipa ora',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': 'Partecipa a {title}',
|
||||
'skills.meetingBots.upcoming.join': 'Partecipa',
|
||||
'skills.meetingBots.upcoming.auto': 'Auto',
|
||||
'skills.meetingBots.upcoming.ask': 'Chiedi',
|
||||
'skills.meetingBots.upcoming.skip': 'Salta',
|
||||
'skills.meetingBots.upcoming.today': 'Oggi',
|
||||
'skills.meetingBots.upcoming.tomorrow': 'Domani',
|
||||
'skills.meetingBots.upcoming.empty':
|
||||
'Nessuna riunione in arrivo — collega Google Calendar per vederle qui.',
|
||||
'skills.meetingBots.upcoming.error': 'Impossibile caricare le riunioni in arrivo.',
|
||||
'skills.meetingBots.upcoming.retry': 'Riprova',
|
||||
'skills.meetingBots.upcoming.refresh': 'Aggiorna',
|
||||
'skills.meetingBots.upcoming.filterAll': 'Tutte le piattaforme',
|
||||
'skills.meetingBots.upcoming.participants': '{count} partecipanti',
|
||||
'skills.meetingBots.upcoming.imminent': 'Inizia presto',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': 'Partecipazione automatica ~alle {time}',
|
||||
'skills.meetingBots.upcoming.asksAtStart': "Chiede all'inizio",
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
"Attiva 'Monitora il mio calendario' nelle Impostazioni predefinite (icona a ingranaggio) affinché Auto/Chiedi abbiano effetto — altrimenti queste politiche vengono salvate ma non si attivano.",
|
||||
'skills.meetingBots.relative.now': 'adesso',
|
||||
'skills.meetingBots.relative.inMinutes': 'tra {count}m',
|
||||
'skills.meetingBots.relative.inHours': 'tra {count}h',
|
||||
'skills.meetingBots.relative.minutesAgo': '{count}m fa',
|
||||
'skills.meetingBots.relative.hoursAgo': '{count}h fa',
|
||||
'skills.meetingBots.relative.daysAgo': '{count}g fa',
|
||||
'skills.meetingBots.relative.yesterday': 'ieri',
|
||||
'skills.meetingBots.defaults.drawerTitle': 'Impostazioni riunione',
|
||||
'skills.meetingBots.defaults.closeDrawer': 'Chiudi',
|
||||
'skills.meetingBots.defaults.openDefaults': 'Impostazioni riunione',
|
||||
'skills.meetingBots.defaults.watchCalendar': 'Monitora il mio calendario',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
'Consenti a OpenHuman di monitorare il tuo calendario connesso per poter partecipare automaticamente o richiedere conferma per le riunioni in base alle politiche seguenti. Questo è separato dalle notifiche di promemoria delle riunioni.',
|
||||
'skills.meetingBots.defaults.globalPolicy': 'Politica di partecipazione automatica globale',
|
||||
'skills.meetingBots.defaults.perPlatformTitle': 'Impostazioni per piattaforma',
|
||||
'skills.meetingBots.defaults.perPlatformDesc':
|
||||
'Sostituire la politica globale per piattaforme specifiche.',
|
||||
'skills.meetingBots.defaults.useDefault': 'Usa predefinito',
|
||||
'skills.resource.preview.closeAriaLabel': 'Chiudi anteprima',
|
||||
'skills.resource.preview.failed': 'Anteprima fallita',
|
||||
'skills.resource.preview.loading': 'Caricamento anteprima…',
|
||||
|
||||
@@ -5099,9 +5099,11 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': '{label} 지원이 곧 제공될 예정입니다.',
|
||||
'skills.meetingBots.platformHints.gmeet': 'Meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'Teams.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'Zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google 모임',
|
||||
'skills.meetingBots.platforms.teams': 'Microsoft Teams',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': 'Zoom',
|
||||
'skills.meetingBots.sendTo': '보내기',
|
||||
'skills.meetingBots.serverOverloaded':
|
||||
@@ -5143,6 +5145,60 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': '부르면 응답하기',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'켜면 호출 문구를 말한 뒤 봇이 소리 내어 답합니다. 끄면 듣고 기록만 합니다.',
|
||||
'skills.meetingBots.history.allPlatforms': '모든 플랫폼',
|
||||
'skills.meetingBots.history.copyTranscript': '복사',
|
||||
'skills.meetingBots.history.downloadTranscript': '다운로드',
|
||||
'skills.meetingBots.history.earlier': '이전',
|
||||
'skills.meetingBots.history.participantCount': '{count}명 참가자',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count}명 참가자',
|
||||
'skills.meetingBots.history.runWithOpenHuman': 'OpenHuman으로 실행',
|
||||
'skills.meetingBots.history.searchPlaceholder': '통화 검색…',
|
||||
'skills.meetingBots.history.selectPrompt': '통화를 선택하면 요약과 전사를 볼 수 있습니다.',
|
||||
'skills.meetingBots.history.today': '오늘',
|
||||
'skills.meetingBots.history.yesterday': '어제',
|
||||
'skills.meetingBots.upcoming.heading': '예정된 회의',
|
||||
'skills.meetingBots.upcoming.when': '시간',
|
||||
'skills.meetingBots.upcoming.meeting': '회의',
|
||||
'skills.meetingBots.upcoming.platform': '플랫폼',
|
||||
'skills.meetingBots.upcoming.people': '참가자',
|
||||
'skills.meetingBots.upcoming.joinPolicy': '참여 정책',
|
||||
'skills.meetingBots.upcoming.joinNow': '지금 참여',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': '{title} 참여',
|
||||
'skills.meetingBots.upcoming.join': '참여',
|
||||
'skills.meetingBots.upcoming.auto': '자동',
|
||||
'skills.meetingBots.upcoming.ask': '묻기',
|
||||
'skills.meetingBots.upcoming.skip': '건너뛰기',
|
||||
'skills.meetingBots.upcoming.today': '오늘',
|
||||
'skills.meetingBots.upcoming.tomorrow': '내일',
|
||||
'skills.meetingBots.upcoming.empty':
|
||||
'예정된 회의가 없습니다 — Google Calendar를 연결하여 여기에서 확인하세요.',
|
||||
'skills.meetingBots.upcoming.error': '예정된 회의를 불러올 수 없습니다.',
|
||||
'skills.meetingBots.upcoming.retry': '다시 시도',
|
||||
'skills.meetingBots.upcoming.refresh': '새로 고침',
|
||||
'skills.meetingBots.upcoming.filterAll': '모든 플랫폼',
|
||||
'skills.meetingBots.upcoming.participants': '{count}명',
|
||||
'skills.meetingBots.upcoming.imminent': '곧 시작',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': '~{time}에 자동 참가',
|
||||
'skills.meetingBots.upcoming.asksAtStart': '시작 시 묻기',
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
"자동/묻기가 적용되도록 기본값(기어 아이콘)에서 '내 캘린더 감시'를 활성화하세요 — 그렇지 않으면 이 정책들은 저장되지만 트리거되지 않습니다.",
|
||||
'skills.meetingBots.relative.now': '지금',
|
||||
'skills.meetingBots.relative.inMinutes': '{count}분 후',
|
||||
'skills.meetingBots.relative.inHours': '{count}시간 후',
|
||||
'skills.meetingBots.relative.minutesAgo': '{count}분 전',
|
||||
'skills.meetingBots.relative.hoursAgo': '{count}시간 전',
|
||||
'skills.meetingBots.relative.daysAgo': '{count}일 전',
|
||||
'skills.meetingBots.relative.yesterday': '어제',
|
||||
'skills.meetingBots.defaults.drawerTitle': '회의 기본값',
|
||||
'skills.meetingBots.defaults.closeDrawer': '닫기',
|
||||
'skills.meetingBots.defaults.openDefaults': '회의 설정',
|
||||
'skills.meetingBots.defaults.watchCalendar': '내 캘린더 감시',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
'OpenHuman이 연결된 캘린더를 감시하여 아래 정책에 따라 회의에 자동 참가하거나 참가 여부를 묻도록 허용합니다. 이는 회의 알림과는 별개입니다.',
|
||||
'skills.meetingBots.defaults.globalPolicy': '전역 자동 참가 정책',
|
||||
'skills.meetingBots.defaults.perPlatformTitle': '플랫폼별 설정',
|
||||
'skills.meetingBots.defaults.perPlatformDesc': '특정 플랫폼에 대한 전역 정책을 재정의합니다.',
|
||||
'skills.meetingBots.defaults.useDefault': '기본값 사용',
|
||||
'skills.resource.preview.closeAriaLabel': '미리보기 닫기',
|
||||
'skills.resource.preview.failed': '미리보기 실패',
|
||||
'skills.resource.preview.loading': '미리보기 불러오는 중…',
|
||||
|
||||
@@ -5219,9 +5219,11 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': 'Obsługa {label} jest już wkrótce.',
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google Meet',
|
||||
'skills.meetingBots.platforms.teams': 'Microsoft Teams',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': 'Zoom',
|
||||
'skills.meetingBots.sendTo': 'Wyślij do',
|
||||
'skills.meetingBots.serverOverloaded':
|
||||
@@ -5266,6 +5268,61 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': 'Odpowiadaj, gdy się do niego zwracam',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'Gdy włączone, bot odpowiada na głos po wypowiedzeniu frazy aktywującej. Gdy wyłączone, tylko słucha i transkrybuje.',
|
||||
'skills.meetingBots.history.allPlatforms': 'Wszystkie platformy',
|
||||
'skills.meetingBots.history.copyTranscript': 'Kopiuj',
|
||||
'skills.meetingBots.history.downloadTranscript': 'Pobierz',
|
||||
'skills.meetingBots.history.earlier': 'Wcześniej',
|
||||
'skills.meetingBots.history.participantCount': '{count} uczestnik',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count} uczestników',
|
||||
'skills.meetingBots.history.runWithOpenHuman': 'Uruchom z OpenHuman',
|
||||
'skills.meetingBots.history.searchPlaceholder': 'Szukaj połączeń…',
|
||||
'skills.meetingBots.history.selectPrompt':
|
||||
'Wybierz połączenie, aby zobaczyć podsumowanie i transkrypt.',
|
||||
'skills.meetingBots.history.today': 'Dzisiaj',
|
||||
'skills.meetingBots.history.yesterday': 'Wczoraj',
|
||||
'skills.meetingBots.upcoming.heading': 'Nadchodzące',
|
||||
'skills.meetingBots.upcoming.when': 'Kiedy',
|
||||
'skills.meetingBots.upcoming.meeting': 'Spotkanie',
|
||||
'skills.meetingBots.upcoming.platform': 'Platforma',
|
||||
'skills.meetingBots.upcoming.people': 'Osoby',
|
||||
'skills.meetingBots.upcoming.joinPolicy': 'Polityka dołączania',
|
||||
'skills.meetingBots.upcoming.joinNow': 'Dołącz teraz',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': 'Dołącz do {title}',
|
||||
'skills.meetingBots.upcoming.join': 'Dołącz',
|
||||
'skills.meetingBots.upcoming.auto': 'Auto',
|
||||
'skills.meetingBots.upcoming.ask': 'Zapytaj',
|
||||
'skills.meetingBots.upcoming.skip': 'Pomiń',
|
||||
'skills.meetingBots.upcoming.today': 'Dziś',
|
||||
'skills.meetingBots.upcoming.tomorrow': 'Jutro',
|
||||
'skills.meetingBots.upcoming.empty':
|
||||
'Brak nadchodzących spotkań — połącz Google Calendar, aby je zobaczyć.',
|
||||
'skills.meetingBots.upcoming.error': 'Nie można załadować nadchodzących spotkań.',
|
||||
'skills.meetingBots.upcoming.retry': 'Ponów',
|
||||
'skills.meetingBots.upcoming.refresh': 'Odśwież',
|
||||
'skills.meetingBots.upcoming.filterAll': 'Wszystkie platformy',
|
||||
'skills.meetingBots.upcoming.participants': '{count} uczestników',
|
||||
'skills.meetingBots.upcoming.imminent': 'Zaczyna się wkrótce',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': 'Auto-dołącza ~o {time}',
|
||||
'skills.meetingBots.upcoming.asksAtStart': 'Pyta na początku',
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
"Włącz 'Monitoruj mój kalendarz' w Ustawieniach domyślnych (ikona koła zębatego), aby Auto/Pytaj działały — w przeciwnym razie te zasady są zapisane, ale nie będą wyzwalane.",
|
||||
'skills.meetingBots.relative.now': 'teraz',
|
||||
'skills.meetingBots.relative.inMinutes': 'za {count}m',
|
||||
'skills.meetingBots.relative.inHours': 'za {count}h',
|
||||
'skills.meetingBots.relative.minutesAgo': '{count}m temu',
|
||||
'skills.meetingBots.relative.hoursAgo': '{count}h temu',
|
||||
'skills.meetingBots.relative.daysAgo': '{count}d temu',
|
||||
'skills.meetingBots.relative.yesterday': 'wczoraj',
|
||||
'skills.meetingBots.defaults.drawerTitle': 'Domyślne ustawienia spotkań',
|
||||
'skills.meetingBots.defaults.closeDrawer': 'Zamknij',
|
||||
'skills.meetingBots.defaults.openDefaults': 'Ustawienia spotkań',
|
||||
'skills.meetingBots.defaults.watchCalendar': 'Monitoruj mój kalendarz',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
'Pozwól OpenHuman monitorować Twój połączony kalendarz, aby mógł automatycznie dołączać do spotkań lub pytać o dołączenie zgodnie z poniższymi zasadami. Jest to niezależne od przypomnień o spotkaniach.',
|
||||
'skills.meetingBots.defaults.globalPolicy': 'Globalna zasada automatycznego dołączania',
|
||||
'skills.meetingBots.defaults.perPlatformTitle': 'Ustawienia według platformy',
|
||||
'skills.meetingBots.defaults.perPlatformDesc': 'Zastąp globalną zasadę dla określonych platform.',
|
||||
'skills.meetingBots.defaults.useDefault': 'Użyj domyślnego',
|
||||
'skills.resource.preview.closeAriaLabel': 'Zamknij podgląd',
|
||||
'skills.resource.preview.failed': 'Podgląd nie powiódł się',
|
||||
'skills.resource.preview.loading': 'Wczytywanie podglądu…',
|
||||
|
||||
@@ -5235,9 +5235,11 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': 'O suporte {label} estará disponível em breve.',
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'times.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google Conheça',
|
||||
'skills.meetingBots.platforms.teams': 'Microsoft Teams',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': 'Zoom',
|
||||
'skills.meetingBots.sendTo': 'Enviar para',
|
||||
'skills.meetingBots.serverOverloaded':
|
||||
@@ -5281,6 +5283,62 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': 'Responder quando eu falar com ele',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'Quando ativado, o bot responde em voz alta depois que você diz a frase de ativação. Quando desativado, ele apenas ouve e transcreve.',
|
||||
'skills.meetingBots.history.allPlatforms': 'Todas as plataformas',
|
||||
'skills.meetingBots.history.copyTranscript': 'Copiar',
|
||||
'skills.meetingBots.history.downloadTranscript': 'Baixar',
|
||||
'skills.meetingBots.history.earlier': 'Antes',
|
||||
'skills.meetingBots.history.participantCount': '{count} participante',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count} participantes',
|
||||
'skills.meetingBots.history.runWithOpenHuman': 'Executar com OpenHuman',
|
||||
'skills.meetingBots.history.searchPlaceholder': 'Pesquisar chamadas…',
|
||||
'skills.meetingBots.history.selectPrompt':
|
||||
'Selecione uma chamada para ver seu resumo e transcrição.',
|
||||
'skills.meetingBots.history.today': 'Hoje',
|
||||
'skills.meetingBots.history.yesterday': 'Ontem',
|
||||
'skills.meetingBots.upcoming.heading': 'Próximas',
|
||||
'skills.meetingBots.upcoming.when': 'Quando',
|
||||
'skills.meetingBots.upcoming.meeting': 'Reunião',
|
||||
'skills.meetingBots.upcoming.platform': 'Plataforma',
|
||||
'skills.meetingBots.upcoming.people': 'Pessoas',
|
||||
'skills.meetingBots.upcoming.joinPolicy': 'Política de participação',
|
||||
'skills.meetingBots.upcoming.joinNow': 'Participar agora',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': 'Participar de {title}',
|
||||
'skills.meetingBots.upcoming.join': 'Participar',
|
||||
'skills.meetingBots.upcoming.auto': 'Auto',
|
||||
'skills.meetingBots.upcoming.ask': 'Perguntar',
|
||||
'skills.meetingBots.upcoming.skip': 'Ignorar',
|
||||
'skills.meetingBots.upcoming.today': 'Hoje',
|
||||
'skills.meetingBots.upcoming.tomorrow': 'Amanhã',
|
||||
'skills.meetingBots.upcoming.empty':
|
||||
'Sem reuniões próximas — conecte o Google Calendar para vê-las aqui.',
|
||||
'skills.meetingBots.upcoming.error': 'Não foi possível carregar as reuniões próximas.',
|
||||
'skills.meetingBots.upcoming.retry': 'Tentar novamente',
|
||||
'skills.meetingBots.upcoming.refresh': 'Atualizar',
|
||||
'skills.meetingBots.upcoming.filterAll': 'Todas as plataformas',
|
||||
'skills.meetingBots.upcoming.participants': '{count} participantes',
|
||||
'skills.meetingBots.upcoming.imminent': 'Começa em breve',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': 'Entra automaticamente ~às {time}',
|
||||
'skills.meetingBots.upcoming.asksAtStart': 'Pergunta no início',
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
"Ative 'Monitorar meu calendário' nos Padrões (ícone de engrenagem) para que Auto/Perguntar entre em vigor — caso contrário, essas políticas ficam salvas mas não serão acionadas.",
|
||||
'skills.meetingBots.relative.now': 'agora',
|
||||
'skills.meetingBots.relative.inMinutes': 'em {count}m',
|
||||
'skills.meetingBots.relative.inHours': 'em {count}h',
|
||||
'skills.meetingBots.relative.minutesAgo': 'há {count}m',
|
||||
'skills.meetingBots.relative.hoursAgo': 'há {count}h',
|
||||
'skills.meetingBots.relative.daysAgo': 'há {count}d',
|
||||
'skills.meetingBots.relative.yesterday': 'ontem',
|
||||
'skills.meetingBots.defaults.drawerTitle': 'Padrões de reunião',
|
||||
'skills.meetingBots.defaults.closeDrawer': 'Fechar',
|
||||
'skills.meetingBots.defaults.openDefaults': 'Configurações de reunião',
|
||||
'skills.meetingBots.defaults.watchCalendar': 'Monitorar meu calendário',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
'Permita que o OpenHuman monitore seu calendário conectado para entrar automaticamente em reuniões ou solicitar confirmação com base nas políticas abaixo. Isso é separado das notificações de lembrete de reunião.',
|
||||
'skills.meetingBots.defaults.globalPolicy': 'Política global de entrada automática',
|
||||
'skills.meetingBots.defaults.perPlatformTitle': 'Configurações por plataforma',
|
||||
'skills.meetingBots.defaults.perPlatformDesc':
|
||||
'Substituir a política global para plataformas específicas.',
|
||||
'skills.meetingBots.defaults.useDefault': 'Usar padrão',
|
||||
'skills.resource.preview.closeAriaLabel': 'Fechar visualização',
|
||||
'skills.resource.preview.failed': 'Falha na pré-visualização',
|
||||
'skills.resource.preview.loading': 'Carregando visualização…',
|
||||
|
||||
@@ -5194,9 +5194,11 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': 'Поддержка {label} скоро появится.',
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'Teams.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'Zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google Встречайте',
|
||||
'skills.meetingBots.platforms.teams': 'Microsoft Teams',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': 'Zoom',
|
||||
'skills.meetingBots.sendTo': 'Отправить',
|
||||
'skills.meetingBots.serverOverloaded':
|
||||
@@ -5238,6 +5240,61 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': 'Отвечать, когда я обращаюсь',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'Когда включено, бот отвечает вслух после того, как вы произнесёте фразу-обращение. Когда выключено, он только слушает и расшифровывает.',
|
||||
'skills.meetingBots.history.allPlatforms': 'Все платформы',
|
||||
'skills.meetingBots.history.copyTranscript': 'Копировать',
|
||||
'skills.meetingBots.history.downloadTranscript': 'Скачать',
|
||||
'skills.meetingBots.history.earlier': 'Ранее',
|
||||
'skills.meetingBots.history.participantCount': '{count} участник',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count} участников',
|
||||
'skills.meetingBots.history.runWithOpenHuman': 'Запустить с OpenHuman',
|
||||
'skills.meetingBots.history.searchPlaceholder': 'Поиск звонков…',
|
||||
'skills.meetingBots.history.selectPrompt': 'Выберите звонок для просмотра сводки и транскрипта.',
|
||||
'skills.meetingBots.history.today': 'Сегодня',
|
||||
'skills.meetingBots.history.yesterday': 'Вчера',
|
||||
'skills.meetingBots.upcoming.heading': 'Предстоящие',
|
||||
'skills.meetingBots.upcoming.when': 'Когда',
|
||||
'skills.meetingBots.upcoming.meeting': 'Встреча',
|
||||
'skills.meetingBots.upcoming.platform': 'Платформа',
|
||||
'skills.meetingBots.upcoming.people': 'Люди',
|
||||
'skills.meetingBots.upcoming.joinPolicy': 'Политика входа',
|
||||
'skills.meetingBots.upcoming.joinNow': 'Войти сейчас',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': 'Войти в {title}',
|
||||
'skills.meetingBots.upcoming.join': 'Войти',
|
||||
'skills.meetingBots.upcoming.auto': 'Авто',
|
||||
'skills.meetingBots.upcoming.ask': 'Спросить',
|
||||
'skills.meetingBots.upcoming.skip': 'Пропустить',
|
||||
'skills.meetingBots.upcoming.today': 'Сегодня',
|
||||
'skills.meetingBots.upcoming.tomorrow': 'Завтра',
|
||||
'skills.meetingBots.upcoming.empty':
|
||||
'Нет предстоящих встреч — подключите Google Calendar, чтобы увидеть их здесь.',
|
||||
'skills.meetingBots.upcoming.error': 'Не удалось загрузить предстоящие встречи.',
|
||||
'skills.meetingBots.upcoming.retry': 'Повторить',
|
||||
'skills.meetingBots.upcoming.refresh': 'Обновить',
|
||||
'skills.meetingBots.upcoming.filterAll': 'Все платформы',
|
||||
'skills.meetingBots.upcoming.participants': '{count} участников',
|
||||
'skills.meetingBots.upcoming.imminent': 'Скоро начнётся',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': 'Автовход ~в {time}',
|
||||
'skills.meetingBots.upcoming.asksAtStart': 'Спрашивает при старте',
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
'Включите «Следить за календарём» в настройках по умолчанию (значок шестерёнки), чтобы политики «Авто» и «Спросить» работали — иначе они сохранятся, но не будут срабатывать.',
|
||||
'skills.meetingBots.relative.now': 'сейчас',
|
||||
'skills.meetingBots.relative.inMinutes': 'через {count}м',
|
||||
'skills.meetingBots.relative.inHours': 'через {count}ч',
|
||||
'skills.meetingBots.relative.minutesAgo': '{count}м назад',
|
||||
'skills.meetingBots.relative.hoursAgo': '{count}ч назад',
|
||||
'skills.meetingBots.relative.daysAgo': '{count}д назад',
|
||||
'skills.meetingBots.relative.yesterday': 'вчера',
|
||||
'skills.meetingBots.defaults.drawerTitle': 'Настройки встреч',
|
||||
'skills.meetingBots.defaults.closeDrawer': 'Закрыть',
|
||||
'skills.meetingBots.defaults.openDefaults': 'Настройки встреч',
|
||||
'skills.meetingBots.defaults.watchCalendar': 'Следить за календарём',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
'Разрешите OpenHuman отслеживать подключённый календарь, чтобы автоматически входить на встречи или запрашивать подтверждение в соответствии с приведёнными ниже политиками. Это не зависит от уведомлений-напоминаний о встречах.',
|
||||
'skills.meetingBots.defaults.globalPolicy': 'Глобальная политика автовхода',
|
||||
'skills.meetingBots.defaults.perPlatformTitle': 'Настройки по платформам',
|
||||
'skills.meetingBots.defaults.perPlatformDesc':
|
||||
'Переопределить глобальную политику для конкретных платформ.',
|
||||
'skills.meetingBots.defaults.useDefault': 'Использовать по умолчанию',
|
||||
'skills.resource.preview.closeAriaLabel': 'Закрыть предпросмотр',
|
||||
'skills.resource.preview.failed': 'Не удалось показать превью',
|
||||
'skills.resource.preview.loading': 'Загрузка предпросмотра…',
|
||||
|
||||
@@ -4893,9 +4893,11 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.platformComingSoon': '{label} 支持即将推出。',
|
||||
'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij',
|
||||
'skills.meetingBots.platformHints.teams': 'team.microsoft.com/...',
|
||||
'skills.meetingBots.platformHints.webex': 'webex.com/meet/...',
|
||||
'skills.meetingBots.platformHints.zoom': 'Zoom.us/j/...',
|
||||
'skills.meetingBots.platforms.gmeet': 'Google 见面',
|
||||
'skills.meetingBots.platforms.teams': '微软团队',
|
||||
'skills.meetingBots.platforms.webex': 'Webex',
|
||||
'skills.meetingBots.platforms.zoom': '变焦',
|
||||
'skills.meetingBots.sendTo': '发送到会议',
|
||||
'skills.meetingBots.serverOverloaded': 'OpenHuman 当前负载过高,请几分钟后重试。',
|
||||
@@ -4936,6 +4938,59 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.activeMode': '当我呼叫时回应',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'开启后,说出唤醒词后机器人会出声回答。关闭后,它只聆听并转写。',
|
||||
'skills.meetingBots.history.allPlatforms': '所有平台',
|
||||
'skills.meetingBots.history.copyTranscript': '复制',
|
||||
'skills.meetingBots.history.downloadTranscript': '下载',
|
||||
'skills.meetingBots.history.earlier': '更早',
|
||||
'skills.meetingBots.history.participantCount': '{count} 位参与者',
|
||||
'skills.meetingBots.history.participantCountPlural': '{count} 位参与者',
|
||||
'skills.meetingBots.history.runWithOpenHuman': '使用 OpenHuman 运行',
|
||||
'skills.meetingBots.history.searchPlaceholder': '搜索通话…',
|
||||
'skills.meetingBots.history.selectPrompt': '选择一个通话以查看其摘要和转录。',
|
||||
'skills.meetingBots.history.today': '今天',
|
||||
'skills.meetingBots.history.yesterday': '昨天',
|
||||
'skills.meetingBots.upcoming.heading': '即将举行',
|
||||
'skills.meetingBots.upcoming.when': '时间',
|
||||
'skills.meetingBots.upcoming.meeting': '会议',
|
||||
'skills.meetingBots.upcoming.platform': '平台',
|
||||
'skills.meetingBots.upcoming.people': '人员',
|
||||
'skills.meetingBots.upcoming.joinPolicy': '加入策略',
|
||||
'skills.meetingBots.upcoming.joinNow': '立即加入',
|
||||
'skills.meetingBots.upcoming.joinNowAriaLabel': '加入 {title}',
|
||||
'skills.meetingBots.upcoming.join': '加入',
|
||||
'skills.meetingBots.upcoming.auto': '自动',
|
||||
'skills.meetingBots.upcoming.ask': '询问',
|
||||
'skills.meetingBots.upcoming.skip': '跳过',
|
||||
'skills.meetingBots.upcoming.today': '今天',
|
||||
'skills.meetingBots.upcoming.tomorrow': '明天',
|
||||
'skills.meetingBots.upcoming.empty': '没有即将举行的会议 — 连接 Google Calendar 以在此查看。',
|
||||
'skills.meetingBots.upcoming.error': '无法加载即将举行的会议。',
|
||||
'skills.meetingBots.upcoming.retry': '重试',
|
||||
'skills.meetingBots.upcoming.refresh': '刷新',
|
||||
'skills.meetingBots.upcoming.filterAll': '所有平台',
|
||||
'skills.meetingBots.upcoming.participants': '{count} 位参与者',
|
||||
'skills.meetingBots.upcoming.imminent': '即将开始',
|
||||
'skills.meetingBots.upcoming.autoJoinsAt': '自动加入 ~{time}',
|
||||
'skills.meetingBots.upcoming.asksAtStart': '开始时询问',
|
||||
'skills.meetingBots.upcoming.watchCalendarHint':
|
||||
'在默认设置(齿轮图标)中开启「监控我的日历」,以使自动/询问生效——否则这些策略已保存但不会触发。',
|
||||
'skills.meetingBots.relative.now': '现在',
|
||||
'skills.meetingBots.relative.inMinutes': '{count}分钟后',
|
||||
'skills.meetingBots.relative.inHours': '{count}小时后',
|
||||
'skills.meetingBots.relative.minutesAgo': '{count}分钟前',
|
||||
'skills.meetingBots.relative.hoursAgo': '{count}小时前',
|
||||
'skills.meetingBots.relative.daysAgo': '{count}天前',
|
||||
'skills.meetingBots.relative.yesterday': '昨天',
|
||||
'skills.meetingBots.defaults.drawerTitle': '会议默认设置',
|
||||
'skills.meetingBots.defaults.closeDrawer': '关闭',
|
||||
'skills.meetingBots.defaults.openDefaults': '会议设置',
|
||||
'skills.meetingBots.defaults.watchCalendar': '监控我的日历',
|
||||
'skills.meetingBots.defaults.watchCalendarDesc':
|
||||
'让 OpenHuman 监控您连接的日历,以便根据以下策略自动加入会议或提示参加会议。这与会议提醒通知是分开的。',
|
||||
'skills.meetingBots.defaults.globalPolicy': '全局自动加入策略',
|
||||
'skills.meetingBots.defaults.perPlatformTitle': '平台特定设置',
|
||||
'skills.meetingBots.defaults.perPlatformDesc': '覆盖特定平台的全局策略。',
|
||||
'skills.meetingBots.defaults.useDefault': '使用默认值',
|
||||
'skills.resource.preview.closeAriaLabel': '关闭预览',
|
||||
'skills.resource.preview.failed': '预览失败',
|
||||
'skills.resource.preview.loading': '加载预览中…',
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import PanelPage from '../components/layout/PanelPage';
|
||||
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
|
||||
import TwoPaneNav from '../components/layout/TwoPaneNav';
|
||||
import MeetingsPage from '../components/meetings/MeetingsPage';
|
||||
import { SettingsLayoutProvider } from '../components/settings/layout/SettingsLayoutContext';
|
||||
import AIPanel from '../components/settings/panels/AIPanel';
|
||||
import ComposioPanel from '../components/settings/panels/ComposioPanel';
|
||||
@@ -21,7 +22,6 @@ import EmbeddingsPanel from '../components/settings/panels/EmbeddingsPanel';
|
||||
import SearchPanel from '../components/settings/panels/SearchPanel';
|
||||
import VoicePanel from '../components/settings/panels/VoicePanel';
|
||||
import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal';
|
||||
import MeetingBotsCard from '../components/skills/MeetingBotsCard';
|
||||
import ScreenIntelligenceSetupModal from '../components/skills/ScreenIntelligenceSetupModal';
|
||||
import UnifiedSkillCard from '../components/skills/SkillCard';
|
||||
import { SKILL_CATEGORY_ORDER, type SkillCategory } from '../components/skills/skillCategories';
|
||||
@@ -1030,7 +1030,8 @@ export default function Skills() {
|
||||
</div>
|
||||
) : (
|
||||
<PanelPage contentClassName="p-4">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4">
|
||||
<div
|
||||
className={`mx-auto w-full space-y-4 ${activeTab !== 'meetings' ? 'max-w-3xl' : ''}`}>
|
||||
{/* <div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold text-content">
|
||||
@@ -1259,12 +1260,7 @@ export default function Skills() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'meetings' && (
|
||||
<div className="space-y-3 animate-fade-up">
|
||||
<BetaBanner />
|
||||
<MeetingBotsCard onToast={addToast} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'meetings' && <MeetingsPage onToast={addToast} />}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../../test/mockDefaultSkillStatusHooks';
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
import Skills from '../Skills';
|
||||
|
||||
vi.mock('../../components/skills/MeetingBotsCard', () => ({
|
||||
vi.mock('../../components/meetings/MeetingsPage', () => ({
|
||||
default: () => <div data-testid="meeting-bots-card">Meeting bot CTA</div>,
|
||||
}));
|
||||
|
||||
|
||||
@@ -4,9 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
import {
|
||||
closeMeetCall,
|
||||
getEventPolicies,
|
||||
joinMeetCall,
|
||||
joinMeetViaBackendBot,
|
||||
listMeetCalls,
|
||||
listUpcomingMeetings,
|
||||
parseTranscriptLine,
|
||||
setEventPolicy,
|
||||
} from '../meetCallService';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() }));
|
||||
@@ -255,3 +259,162 @@ describe('closeMeetCall', () => {
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listUpcomingMeetings', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(callCoreRpc).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const mockMeeting = {
|
||||
calendar_event_id: 'evt-1',
|
||||
title: 'Standup',
|
||||
start_time_ms: Date.now() + 30 * 60 * 1000,
|
||||
end_time_ms: Date.now() + 60 * 60 * 1000,
|
||||
meet_url: 'https://meet.google.com/abc-def-ghi',
|
||||
platform: 'gmeet',
|
||||
participant_count: 4,
|
||||
organizer: 'alice@example.com',
|
||||
join_policy: 'ask',
|
||||
calendar_source: 'google:alice@example.com',
|
||||
};
|
||||
|
||||
it('calls openhuman.meet_list_upcoming with no params when no args given', async () => {
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({ ok: true, meetings: [mockMeeting] } as never);
|
||||
|
||||
const result = await listUpcomingMeetings();
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.meet_list_upcoming',
|
||||
params: {},
|
||||
});
|
||||
expect(result).toEqual([mockMeeting]);
|
||||
});
|
||||
|
||||
it('forwards lookahead_minutes and limit when provided', async () => {
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({ ok: true, meetings: [] } as never);
|
||||
|
||||
await listUpcomingMeetings(120, 10);
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.meet_list_upcoming',
|
||||
params: { lookahead_minutes: 120, limit: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty array when core returns no meetings', async () => {
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({ ok: true, meetings: undefined } as never);
|
||||
|
||||
const result = await listUpcomingMeetings();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws when core returns ok: false', async () => {
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({ ok: false } as never);
|
||||
await expect(listUpcomingMeetings()).rejects.toThrow(/meet_list_upcoming/);
|
||||
});
|
||||
|
||||
it('throws when core returns a falsy result', async () => {
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce(null as never);
|
||||
await expect(listUpcomingMeetings()).rejects.toThrow(/meet_list_upcoming/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEventPolicy', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(callCoreRpc).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('calls meet_set_event_policy with correct params', async () => {
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({ ok: true } as never);
|
||||
await setEventPolicy('evt-123', 'skip');
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.meet_set_event_policy',
|
||||
params: { calendar_event_id: 'evt-123', policy: 'skip' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when core returns ok=false', async () => {
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({ ok: false } as never);
|
||||
await expect(setEventPolicy('evt-123', 'auto')).rejects.toThrow('meet_set_event_policy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEventPolicies', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(callCoreRpc).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('calls meet_get_event_policies with correct params', async () => {
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
policies: { 'evt-1': 'skip' },
|
||||
} as never);
|
||||
const result = await getEventPolicies(['evt-1', 'evt-2']);
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.meet_get_event_policies',
|
||||
params: { calendar_event_ids: ['evt-1', 'evt-2'] },
|
||||
});
|
||||
expect(result).toEqual({ 'evt-1': 'skip' });
|
||||
});
|
||||
|
||||
it('returns empty object for empty input', async () => {
|
||||
const result = await getEventPolicies([]);
|
||||
expect(callCoreRpc).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('throws when core returns ok=false', async () => {
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({ ok: false } as never);
|
||||
await expect(getEventPolicies(['evt-x'])).rejects.toThrow('meet_get_event_policies');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTranscriptLine', () => {
|
||||
it('parses a line with a [MM:SS] [Name] prefix', () => {
|
||||
const result = parseTranscriptLine({
|
||||
role: 'participant',
|
||||
content: '[1:23] [Alice] Hello there!',
|
||||
});
|
||||
expect(result.timestamp).toBe('1:23');
|
||||
expect(result.speaker).toBe('Alice');
|
||||
expect(result.text).toBe('Hello there!');
|
||||
expect(result.role).toBe('participant');
|
||||
});
|
||||
|
||||
it('returns null timestamp and speaker when prefix is absent', () => {
|
||||
const result = parseTranscriptLine({ role: 'assistant', content: 'How can I help?' });
|
||||
expect(result.timestamp).toBeNull();
|
||||
expect(result.speaker).toBeNull();
|
||||
expect(result.text).toBe('How can I help?');
|
||||
expect(result.role).toBe('assistant');
|
||||
});
|
||||
|
||||
it('handles partial brackets — no match, returns full content', () => {
|
||||
const result = parseTranscriptLine({
|
||||
role: 'participant',
|
||||
content: '[1:23] missing second bracket',
|
||||
});
|
||||
expect(result.timestamp).toBeNull();
|
||||
expect(result.speaker).toBeNull();
|
||||
expect(result.text).toBe('[1:23] missing second bracket');
|
||||
});
|
||||
|
||||
it('handles malformed content gracefully', () => {
|
||||
const result = parseTranscriptLine({ role: 'participant', content: 'just plain text' });
|
||||
expect(result.timestamp).toBeNull();
|
||||
expect(result.speaker).toBeNull();
|
||||
expect(result.text).toBe('just plain text');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,6 +218,38 @@ export async function getMeetCallDetail(requestId: string): Promise<MeetCallDeta
|
||||
return result.detail ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transcript parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A transcript line with its parsed timestamp/speaker prefix stripped out. */
|
||||
export interface ParsedTranscriptLine {
|
||||
timestamp: string | null;
|
||||
speaker: string | null;
|
||||
text: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const TRANSCRIPT_PREFIX_RE = /^\[(\d{1,2}:\d{2})\]\s*\[([^\]]+)\]\s*(.*)/s;
|
||||
|
||||
/**
|
||||
* Parse a raw transcript line's content for the optional `[MM:SS] [Name]` prefix.
|
||||
* When the prefix is present, returns the parsed timestamp, speaker, and remaining text.
|
||||
* When absent, timestamp and speaker are null and text is the full content.
|
||||
*/
|
||||
export function parseTranscriptLine(line: MeetCallTranscriptLine): ParsedTranscriptLine {
|
||||
const match = TRANSCRIPT_PREFIX_RE.exec(line.content);
|
||||
if (match) {
|
||||
return {
|
||||
timestamp: match[1] ?? null,
|
||||
speaker: match[2] ?? null,
|
||||
text: match[3] ?? '',
|
||||
role: line.role,
|
||||
};
|
||||
}
|
||||
return { timestamp: null, speaker: null, text: line.content, role: line.role };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend Meet Bot via Core Socket.IO bridge
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -320,7 +352,8 @@ export async function sendHarnessResponse(result: string): Promise<void> {
|
||||
* The app normally uses `joinMeetViaBackendBot`, which routes through the
|
||||
* core Socket.IO bridge so backend bot events can be handled locally too.
|
||||
*/
|
||||
export type MascotMeetPlatform = 'gmeet' | 'zoom' | 'teams' | 'webex';
|
||||
/** Alias of {@link MeetingPlatform} — kept for existing consumers. */
|
||||
export type MascotMeetPlatform = MeetingPlatform;
|
||||
|
||||
export interface MascotJoinMeetingInput {
|
||||
platform: MascotMeetPlatform;
|
||||
@@ -373,6 +406,105 @@ function isApiErrorLike(value: unknown): value is { error?: unknown; message?: u
|
||||
return !!value && typeof value === 'object' && ('error' in value || 'message' in value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upcoming meetings (meet_list_upcoming RPC)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One upcoming calendar meeting returned by `openhuman.meet_list_upcoming`.
|
||||
* Mirrors `UpcomingMeeting` in `src/openhuman/agent_meetings/types.rs`.
|
||||
*/
|
||||
export interface UpcomingMeeting {
|
||||
calendar_event_id: string;
|
||||
title: string;
|
||||
/** Unix milliseconds */
|
||||
start_time_ms: number;
|
||||
/** Unix milliseconds */
|
||||
end_time_ms: number;
|
||||
meet_url: string | null;
|
||||
/** Platform slug: "gmeet" | "zoom" | "teams" | "webex" */
|
||||
platform: string | null;
|
||||
participant_count: number | null;
|
||||
organizer: string | null;
|
||||
/** "auto" | "ask" | "skip" — local UI state only this phase */
|
||||
join_policy: string;
|
||||
calendar_source: string;
|
||||
}
|
||||
|
||||
interface CoreListUpcomingResponse {
|
||||
ok: boolean;
|
||||
meetings: UpcomingMeeting[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch upcoming calendar meetings that have a conferencing link.
|
||||
* Returns an empty array when no Google Calendar is connected.
|
||||
*/
|
||||
export async function listUpcomingMeetings(
|
||||
lookaheadMinutes?: number,
|
||||
limit?: number
|
||||
): Promise<UpcomingMeeting[]> {
|
||||
const result = await callCoreRpc<CoreListUpcomingResponse>({
|
||||
method: 'openhuman.meet_list_upcoming',
|
||||
params: {
|
||||
...(lookaheadMinutes != null ? { lookahead_minutes: lookaheadMinutes } : {}),
|
||||
...(limit != null ? { limit } : {}),
|
||||
},
|
||||
});
|
||||
if (!result?.ok) {
|
||||
throw new Error('Core rejected the meet_list_upcoming request.');
|
||||
}
|
||||
return result.meetings ?? [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-event join-policy overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CoreSetEventPolicyResponse {
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
interface CoreGetEventPoliciesResponse {
|
||||
ok: boolean;
|
||||
policies: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a per-event join-policy override for a specific calendar event.
|
||||
* Resolution order (Rust side): per-event > per-platform > global.
|
||||
*/
|
||||
export async function setEventPolicy(
|
||||
calendarEventId: string,
|
||||
policy: 'auto' | 'ask' | 'skip'
|
||||
): Promise<void> {
|
||||
const result = await callCoreRpc<CoreSetEventPolicyResponse>({
|
||||
method: 'openhuman.meet_set_event_policy',
|
||||
params: { calendar_event_id: calendarEventId, policy },
|
||||
});
|
||||
if (!result?.ok) {
|
||||
throw new Error('Core rejected the meet_set_event_policy request.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetch per-event join-policy overrides for the given calendar event IDs.
|
||||
* Only IDs that have an explicit override are present in the returned map.
|
||||
*/
|
||||
export async function getEventPolicies(
|
||||
calendarEventIds: string[]
|
||||
): Promise<Record<string, string>> {
|
||||
if (calendarEventIds.length === 0) return {};
|
||||
const result = await callCoreRpc<CoreGetEventPoliciesResponse>({
|
||||
method: 'openhuman.meet_get_event_policies',
|
||||
params: { calendar_event_ids: calendarEventIds },
|
||||
});
|
||||
if (!result?.ok) {
|
||||
throw new Error('Core rejected the meet_get_event_policies request.');
|
||||
}
|
||||
return result.policies ?? {};
|
||||
}
|
||||
|
||||
export async function joinMeetingViaMascotBot(
|
||||
input: MascotJoinMeetingInput
|
||||
): Promise<MascotJoinMeetingResult> {
|
||||
|
||||
@@ -791,6 +791,13 @@ export interface MeetSettings {
|
||||
auto_summarize_policy: MeetAutoSummarizePolicy;
|
||||
listen_only_default: boolean;
|
||||
ingest_backend_transcripts: boolean;
|
||||
/** Per-platform auto-join policy overrides. Keys: "gmeet"|"zoom"|"teams"|"webex". */
|
||||
platform_auto_join_policies?: Record<string, MeetAutoJoinPolicy>;
|
||||
/**
|
||||
* Master switch for calendar-driven meeting actions (auto-join / ask-to-join).
|
||||
* Decoupled from the heartbeat reminder-notification toggle.
|
||||
*/
|
||||
watch_calendar: boolean;
|
||||
}
|
||||
|
||||
/** Partial update accepted by `openhuman.config_update_meet_settings`. */
|
||||
@@ -800,6 +807,10 @@ export interface MeetSettingsUpdate {
|
||||
auto_summarize_policy?: MeetAutoSummarizePolicy;
|
||||
listen_only_default?: boolean;
|
||||
ingest_backend_transcripts?: boolean;
|
||||
/** Per-platform auto-join policy overrides. Keys: "gmeet"|"zoom"|"teams"|"webex". */
|
||||
platform_auto_join_policies?: Record<string, MeetAutoJoinPolicy>;
|
||||
/** Master switch for calendar-driven auto-join / ask-to-join. */
|
||||
watch_calendar?: boolean;
|
||||
}
|
||||
|
||||
export async function openhumanUpdateMeetSettings(
|
||||
|
||||
@@ -13,18 +13,22 @@ test.describe('Google Meet Connections tab', () => {
|
||||
await dismissWalkthroughIfPresent(page);
|
||||
});
|
||||
|
||||
test('opens the Meetings tab and shows the inline join form', async ({ page }) => {
|
||||
test('opens the Meetings tab and shows the multi-platform composer', async ({ page }) => {
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 })
|
||||
.toContain('/connections');
|
||||
|
||||
await expect(page.getByTestId('two-pane-nav-meetings')).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
// The join form renders inline on the Meetings tab (no banner/modal).
|
||||
// The redesigned composer renders inline on the Meetings tab (no banner/modal).
|
||||
await expect(page.getByText('Send OpenHuman to a meeting')).toBeVisible();
|
||||
await expect(page.getByText('Meeting link')).toBeVisible();
|
||||
await expect(page.locator('input[type="url"]')).toHaveCount(1);
|
||||
await expect(page.getByText('Zoom')).toHaveCount(0);
|
||||
await expect(page.getByText('Microsoft Teams')).toHaveCount(0);
|
||||
|
||||
// The redesign replaced the single-platform form with a platform selector;
|
||||
// all four platforms are now selectable radio chips.
|
||||
await expect(page.getByRole('radio', { name: /google meet/i })).toBeVisible();
|
||||
await expect(page.getByRole('radio', { name: /zoom/i })).toBeVisible();
|
||||
await expect(page.getByRole('radio', { name: /microsoft teams/i })).toBeVisible();
|
||||
await expect(page.getByRole('radio', { name: /webex/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+3
-1
@@ -627,7 +627,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
|
||||
),
|
||||
"meet" => Some(
|
||||
"Validate Google Meet call-join requests and mint a request_id; the desktop \
|
||||
shell opens the embedded CEF webview that joins the call as an anonymous guest.",
|
||||
shell opens the embedded CEF webview that joins the call as an anonymous guest. \
|
||||
Also provides meet_list_upcoming to fetch upcoming calendar meetings with \
|
||||
conferencing links from connected Google Calendar accounts.",
|
||||
),
|
||||
"meet_agent" => Some(
|
||||
"Live agent loop for an open Google Meet call: shell streams inbound PCM, \
|
||||
|
||||
@@ -145,7 +145,32 @@ impl EventHandler for MeetCalendarSubscriber {
|
||||
"[meet:calendar] detected imminent Google Meet meeting"
|
||||
);
|
||||
|
||||
handle_calendar_meeting_candidate(meet_url, event_title, owner_display_name).await;
|
||||
// Extract the calendar event id from the payload so the per-event
|
||||
// policy tier can fire. Use the SHARED canonical extractor (id →
|
||||
// eventId → icalUID, top-level or nested under `data`) so the webhook
|
||||
// path keys per-event policy lookups by the SAME id the UI persists
|
||||
// overrides under — the events.list resource id built in upcoming.rs and
|
||||
// by the heartbeat collector. See finding #3.
|
||||
let calendar_event_id = super::ops::extract_calendar_event_id_from_payload(payload);
|
||||
if calendar_event_id.is_none() {
|
||||
// TODO(meet): if a real Composio googlecalendar trigger ever carries
|
||||
// the event id under a key other than id/eventId/icalUID, the
|
||||
// per-event override won't resolve here. Surface it so we notice
|
||||
// rather than silently dropping to the per-platform/global tier.
|
||||
tracing::warn!(
|
||||
trigger = %trigger,
|
||||
"[meet:calendar] webhook payload has no event id (id/eventId/icalUID) — \
|
||||
per-event policy override cannot be applied; using per-platform/global tier"
|
||||
);
|
||||
}
|
||||
|
||||
handle_calendar_meeting_candidate(
|
||||
meet_url,
|
||||
event_title,
|
||||
owner_display_name,
|
||||
calendar_event_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +299,23 @@ pub async fn handle_calendar_meeting_candidate(
|
||||
meet_url: String,
|
||||
event_title: String,
|
||||
owner_display_name: Option<String>,
|
||||
calendar_event_id: Option<String>,
|
||||
) -> bool {
|
||||
// SECURITY: strict allowlist validation before any auto-join can fire. This
|
||||
// is the last gate shared by the live Composio webhook path and the
|
||||
// heartbeat poller — both feed URLs harvested from calendar event text. A
|
||||
// spoofed host like `https://meet.google.com.attacker.com/x` would slip past
|
||||
// a loose substring check; `validate_meeting_url` parses the host and
|
||||
// matches it exactly against the allowlist, rejecting the spoof.
|
||||
if let Err(e) = super::ops::validate_meeting_url(&meet_url) {
|
||||
tracing::warn!(
|
||||
meet_url = %meet_url,
|
||||
error = %e,
|
||||
"[meet:calendar] rejected non-allowlisted meeting URL (possible spoofed host) — not auto-joining"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve the reply anchor. Callers without payload context (the heartbeat
|
||||
// poller passes `None`) fall back to the signed-in account identity here so
|
||||
// the bot still knows who to reply to.
|
||||
@@ -312,7 +353,28 @@ pub async fn handle_calendar_meeting_candidate(
|
||||
}
|
||||
};
|
||||
|
||||
match config.meet.auto_join_policy {
|
||||
// Resolve the effective join policy using the three-tier precedence:
|
||||
// per-event override → per-platform default → global default.
|
||||
let platform = url::Url::parse(&meet_url)
|
||||
.ok()
|
||||
.map(|u| super::ops::infer_platform(&u).to_string());
|
||||
let effective_policy_str = super::ops::resolve_effective_join_policy(
|
||||
calendar_event_id.as_deref(),
|
||||
platform.as_deref(),
|
||||
&config,
|
||||
);
|
||||
let effective_policy = super::ops::str_to_auto_join_policy(&effective_policy_str)
|
||||
.unwrap_or(crate::openhuman::config::schema::AutoJoinPolicy::AskEachTime);
|
||||
|
||||
tracing::debug!(
|
||||
meet_url = %meet_url,
|
||||
calendar_event_id = ?calendar_event_id,
|
||||
platform = ?platform,
|
||||
effective_policy = %effective_policy_str,
|
||||
"[meet:calendar] resolved effective join policy"
|
||||
);
|
||||
|
||||
match effective_policy {
|
||||
crate::openhuman::config::schema::AutoJoinPolicy::Never => {
|
||||
tracing::debug!("[meet:calendar] auto_join_policy=never, dropping");
|
||||
false
|
||||
@@ -353,12 +415,15 @@ pub async fn handle_calendar_meeting_candidate(
|
||||
|
||||
// Persist a session keyed by correlation_id so future trigger
|
||||
// firings find the existing entry and skip (see dedup guard above).
|
||||
// Persist the resolved calendar_event_id so per-event policy
|
||||
// lookups and dedup can key off the calendar event rather than
|
||||
// only the meeting URL.
|
||||
let now_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
|
||||
let session = MeetingSession {
|
||||
id: correlation_id.clone(),
|
||||
meet_url: meet_url.clone(),
|
||||
title: Some(event_title.clone()),
|
||||
calendar_event_id: None,
|
||||
calendar_event_id: calendar_event_id.clone(),
|
||||
status: MeetingSessionStatus::Joined,
|
||||
source: AutoJoinSource::Calendar,
|
||||
thread_id: None,
|
||||
@@ -417,11 +482,14 @@ pub async fn handle_calendar_meeting_candidate(
|
||||
|
||||
let meeting_id = uuid::Uuid::new_v4().to_string();
|
||||
let now_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
|
||||
// Persist the resolved calendar_event_id so per-event policy
|
||||
// lookups and dedup can key off the calendar event rather than
|
||||
// only the meeting URL.
|
||||
let session = MeetingSession {
|
||||
id: meeting_id.clone(),
|
||||
meet_url: meet_url.clone(),
|
||||
title: Some(event_title.clone()),
|
||||
calendar_event_id: None,
|
||||
calendar_event_id: calendar_event_id.clone(),
|
||||
status: MeetingSessionStatus::Pending,
|
||||
source: AutoJoinSource::Calendar,
|
||||
thread_id: None,
|
||||
@@ -565,43 +633,9 @@ fn is_meeting_imminent(payload: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Supported meeting URL host patterns. A string is considered a meeting
|
||||
/// link when it contains any of these substrings.
|
||||
const MEETING_HOST_PATTERNS: &[&str] = &[
|
||||
"meet.google.com",
|
||||
"zoom.us",
|
||||
"teams.microsoft.com",
|
||||
"webex.com",
|
||||
];
|
||||
|
||||
fn is_meeting_url(s: &str) -> bool {
|
||||
MEETING_HOST_PATTERNS.iter().any(|pat| s.contains(pat))
|
||||
}
|
||||
|
||||
/// Pull the first parseable meeting URL out of a free-form string.
|
||||
///
|
||||
/// Calendar `location` is free-form and commonly mixes a label with a URL
|
||||
/// (e.g. `"Zoom Meeting: https://zoom.us/j/123"`). Returning the raw string
|
||||
/// would produce a `meeting_url` that `url::Url::parse` later rejects, leaving
|
||||
/// Join/Skip buttons that silently fail. So scan whitespace-separated tokens,
|
||||
/// strip surrounding punctuation (including trailing `.`), and return the first
|
||||
/// token that both matches a known meeting host and parses as an http(s) URL.
|
||||
fn extract_meeting_url_from_text(text: &str) -> Option<String> {
|
||||
text.split_whitespace()
|
||||
.map(|tok| {
|
||||
tok.trim_matches(|c: char| {
|
||||
matches!(
|
||||
c,
|
||||
'(' | ')' | '[' | ']' | '<' | '>' | ',' | ';' | '"' | '\'' | '.'
|
||||
)
|
||||
})
|
||||
})
|
||||
.filter(|tok| is_meeting_url(tok))
|
||||
.find_map(|tok| {
|
||||
let parsed = url::Url::parse(tok).ok()?;
|
||||
matches!(parsed.scheme(), "http" | "https").then(|| parsed.to_string())
|
||||
})
|
||||
}
|
||||
// URL/host/platform primitives (`is_meeting_url`, `extract_url_from_text`)
|
||||
// live in `super::ops` as the single canonical, strict implementations —
|
||||
// see finding #9. This module just composes them over the Composio payload.
|
||||
|
||||
/// Extract a meeting URL from a Composio Google Calendar trigger payload.
|
||||
///
|
||||
@@ -614,7 +648,7 @@ fn extract_meet_url(payload: &serde_json::Value) -> Option<String> {
|
||||
for root in [payload, payload.get("data").unwrap_or(payload)] {
|
||||
// hangoutLink (Google Meet)
|
||||
if let Some(link) = root.get("hangoutLink").and_then(|v| v.as_str()) {
|
||||
if is_meeting_url(link) {
|
||||
if super::ops::is_meeting_url(link) {
|
||||
return Some(link.to_string());
|
||||
}
|
||||
}
|
||||
@@ -627,7 +661,7 @@ fn extract_meet_url(payload: &serde_json::Value) -> Option<String> {
|
||||
{
|
||||
for entry in entries {
|
||||
if let Some(uri) = entry.get("uri").and_then(|v| v.as_str()) {
|
||||
if is_meeting_url(uri) {
|
||||
if super::ops::is_meeting_url(uri) {
|
||||
return Some(uri.to_string());
|
||||
}
|
||||
}
|
||||
@@ -639,7 +673,7 @@ fn extract_meet_url(payload: &serde_json::Value) -> Option<String> {
|
||||
// parseable URL token — returning the whole string would fail later
|
||||
// validation in handle_join → validate_meeting_url.
|
||||
if let Some(loc) = root.get("location").and_then(|v| v.as_str()) {
|
||||
if let Some(url) = extract_meeting_url_from_text(loc) {
|
||||
if let Some(url) = super::ops::extract_url_from_text(loc) {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
@@ -651,7 +685,7 @@ fn extract_meet_url(payload: &serde_json::Value) -> Option<String> {
|
||||
|
||||
fn find_meet_url_recursive(val: &serde_json::Value) -> Option<String> {
|
||||
match val {
|
||||
serde_json::Value::String(s) if is_meeting_url(s) => Some(s.clone()),
|
||||
serde_json::Value::String(s) if super::ops::is_meeting_url(s) => Some(s.clone()),
|
||||
serde_json::Value::Object(map) => {
|
||||
for v in map.values() {
|
||||
if let Some(url) = find_meet_url_recursive(v) {
|
||||
@@ -681,7 +715,6 @@ async fn auto_join_meeting(
|
||||
owner_display_name: Option<String>,
|
||||
) {
|
||||
use crate::openhuman::socket::global_socket_manager;
|
||||
use serde_json::json;
|
||||
|
||||
let mgr = match global_socket_manager() {
|
||||
Some(mgr) if mgr.is_connected() => mgr,
|
||||
@@ -691,8 +724,15 @@ async fn auto_join_meeting(
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve the platform from the URL so the backend bot routes to the
|
||||
// right provider instead of defaulting every auto-join to Google Meet.
|
||||
// Uses the same strict host validation as the manual-join path; an
|
||||
// unrecognized host falls back to "gmeet".
|
||||
let platform = super::ops::infer_platform_from_url(&meet_url).unwrap_or("gmeet");
|
||||
|
||||
let payload = build_auto_join_payload(
|
||||
&meet_url,
|
||||
platform,
|
||||
&correlation_id,
|
||||
listen_only,
|
||||
owner_display_name.as_deref(),
|
||||
@@ -700,6 +740,7 @@ async fn auto_join_meeting(
|
||||
|
||||
tracing::info!(
|
||||
meet_url = %meet_url,
|
||||
platform = %platform,
|
||||
title = %event_title,
|
||||
correlation_id = %correlation_id,
|
||||
listen_only = listen_only,
|
||||
@@ -745,12 +786,14 @@ fn build_action_payload(
|
||||
/// which the backend bot treats as "respond to everyone".
|
||||
fn build_auto_join_payload(
|
||||
meet_url: &str,
|
||||
platform: &str,
|
||||
correlation_id: &str,
|
||||
listen_only: bool,
|
||||
owner_display_name: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let mut payload = serde_json::json!({
|
||||
"meetUrl": meet_url,
|
||||
"platform": platform,
|
||||
"displayName": "Tiny",
|
||||
"correlationId": correlation_id,
|
||||
"listenOnly": listen_only,
|
||||
@@ -766,6 +809,8 @@ fn build_auto_join_payload(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// The free-form URL extractor now lives in `ops` (finding #9 consolidation).
|
||||
use crate::openhuman::agent_meetings::ops::extract_url_from_text as extract_meeting_url_from_text;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -819,6 +864,39 @@ mod tests {
|
||||
assert!(extract_meet_url(&payload).is_none());
|
||||
}
|
||||
|
||||
// ── spoofed-host rejection (finding #1) ─────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_meet_url_rejects_spoofed_host() {
|
||||
// A loose `contains("meet.google.com")` would extract this; the strict
|
||||
// host check must reject it so it never reaches auto-join.
|
||||
let payload = json!({
|
||||
"summary": "Phishing invite",
|
||||
"hangoutLink": "https://meet.google.com.attacker.com/x"
|
||||
});
|
||||
assert!(extract_meet_url(&payload).is_none());
|
||||
|
||||
let payload2 = json!({
|
||||
"location": "Join: https://zoom.us.evil.example/j/1"
|
||||
});
|
||||
assert!(extract_meet_url(&payload2).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn candidate_rejects_spoofed_host_before_join() {
|
||||
// Strict validation gates the auto-join entry point: a spoofed host
|
||||
// returns false without emitting bot:join (no config/socket needed since
|
||||
// the gate fires first).
|
||||
let joined = handle_calendar_meeting_candidate(
|
||||
"https://meet.google.com.attacker.com/x".to_string(),
|
||||
"Spoofed".to_string(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(!joined);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imminent_meeting_starting_in_5_minutes() {
|
||||
let start = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
|
||||
@@ -1047,6 +1125,7 @@ mod tests {
|
||||
fn auto_join_payload_includes_respond_to_participant() {
|
||||
let p = build_auto_join_payload(
|
||||
"https://meet.google.com/abc",
|
||||
"gmeet",
|
||||
"corr-1",
|
||||
false,
|
||||
Some("Aditya"),
|
||||
@@ -1059,16 +1138,29 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn auto_join_payload_omits_respond_to_participant_when_absent() {
|
||||
let p = build_auto_join_payload("https://meet.google.com/abc", "corr-1", true, None);
|
||||
let p =
|
||||
build_auto_join_payload("https://meet.google.com/abc", "gmeet", "corr-1", true, None);
|
||||
assert!(p.get("respondToParticipant").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_join_payload_omits_respond_to_participant_when_blank() {
|
||||
let p = build_auto_join_payload("https://meet.google.com/abc", "corr-1", true, Some(" "));
|
||||
let p = build_auto_join_payload(
|
||||
"https://meet.google.com/abc",
|
||||
"gmeet",
|
||||
"corr-1",
|
||||
true,
|
||||
Some(" "),
|
||||
);
|
||||
assert!(p.get("respondToParticipant").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_join_payload_includes_platform() {
|
||||
let p = build_auto_join_payload("https://zoom.us/j/123", "zoom", "corr-1", true, None);
|
||||
assert_eq!(p["platform"], json!("zoom"));
|
||||
}
|
||||
|
||||
// ── effective_listen_only ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -1194,4 +1286,48 @@ mod tests {
|
||||
Some("https://teams.microsoft.com/l/meetup-join/abc")
|
||||
);
|
||||
}
|
||||
|
||||
// ── calendar_event_id persisted on session (finding #3) ─────
|
||||
|
||||
#[test]
|
||||
fn session_persists_calendar_event_id_round_trip() {
|
||||
use crate::openhuman::agent_meetings::store;
|
||||
use crate::openhuman::agent_meetings::types::{
|
||||
AutoJoinSource, MeetingSession, MeetingSessionStatus,
|
||||
};
|
||||
use crate::openhuman::config::Config;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut config = Config::default();
|
||||
config.workspace_dir = dir.path().to_path_buf();
|
||||
|
||||
// Simulate what handle_calendar_meeting_candidate does after the fix:
|
||||
// it populates calendar_event_id from the resolved payload id.
|
||||
let now_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
|
||||
let session = MeetingSession {
|
||||
id: "corr-id-abc".to_string(),
|
||||
meet_url: "https://meet.google.com/cal-test".to_string(),
|
||||
title: Some("Calendar meeting".to_string()),
|
||||
// After finding #3 fix this is Some("cal-ev-xyz"), not None.
|
||||
calendar_event_id: Some("cal-ev-xyz".to_string()),
|
||||
status: MeetingSessionStatus::Joined,
|
||||
source: AutoJoinSource::Calendar,
|
||||
thread_id: None,
|
||||
transcript_received: false,
|
||||
summary_generated: false,
|
||||
created_at_ms: now_ms,
|
||||
updated_at_ms: now_ms,
|
||||
};
|
||||
store::create_session(&config, &session).unwrap();
|
||||
|
||||
let fetched = store::get_session(&config, "corr-id-abc")
|
||||
.unwrap()
|
||||
.expect("session must exist");
|
||||
assert_eq!(
|
||||
fetched.calendar_event_id.as_deref(),
|
||||
Some("cal-ev-xyz"),
|
||||
"calendar_event_id must survive store round-trip (finding #3)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ pub mod schemas;
|
||||
pub mod store;
|
||||
pub mod summary;
|
||||
pub mod types;
|
||||
pub mod upcoming;
|
||||
|
||||
pub use schemas::{
|
||||
all_controller_schemas as all_agent_meetings_controller_schemas,
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! `SocketManager`. The backend's meeting bot handler picks these up and
|
||||
//! drives the Recall.ai (or Camoufox) session.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::core::event_bus::BackendMeetTurn;
|
||||
@@ -25,6 +27,118 @@ const ALLOWED_HOSTS: &[(&str, &str)] = &[
|
||||
("webex.com", "webex"),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3 policy helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Map `AutoJoinPolicy` → the compact string used by the frontend and the
|
||||
/// per-event policy store ("auto" | "ask" | "skip").
|
||||
pub(crate) fn auto_join_policy_to_str(
|
||||
p: &crate::openhuman::config::schema::AutoJoinPolicy,
|
||||
) -> &'static str {
|
||||
use crate::openhuman::config::schema::AutoJoinPolicy;
|
||||
match p {
|
||||
AutoJoinPolicy::Always => "auto",
|
||||
AutoJoinPolicy::AskEachTime => "ask",
|
||||
AutoJoinPolicy::Never => "skip",
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the compact policy string back to `AutoJoinPolicy`. Returns `None` for
|
||||
/// unrecognised strings.
|
||||
pub(crate) fn str_to_auto_join_policy(
|
||||
s: &str,
|
||||
) -> Option<crate::openhuman::config::schema::AutoJoinPolicy> {
|
||||
use crate::openhuman::config::schema::AutoJoinPolicy;
|
||||
match s {
|
||||
"auto" => Some(AutoJoinPolicy::Always),
|
||||
"ask" => Some(AutoJoinPolicy::AskEachTime),
|
||||
"skip" => Some(AutoJoinPolicy::Never),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the effective join policy for a meeting, applying a three-tier
|
||||
/// precedence:
|
||||
///
|
||||
/// 1. **Per-event override** — stored by `openhuman.meet_set_event_policy`.
|
||||
/// 2. **Per-platform default** — from `config.meet.platform_auto_join_policies`.
|
||||
/// 3. **Global default** — from `config.meet.auto_join_policy`.
|
||||
///
|
||||
/// Returns a compact string: "auto" | "ask" | "skip".
|
||||
pub(crate) fn resolve_effective_join_policy(
|
||||
calendar_event_id: Option<&str>,
|
||||
platform: Option<&str>,
|
||||
config: &crate::openhuman::config::Config,
|
||||
) -> String {
|
||||
// Single-event path: fetch this one override (opens one connection) and
|
||||
// delegate to the prefetch-aware resolver so the tier logic lives in exactly
|
||||
// one place.
|
||||
let mut overrides: HashMap<String, String> = HashMap::new();
|
||||
if let Some(event_id) = calendar_event_id {
|
||||
match super::store::get_event_policy(config, event_id) {
|
||||
Ok(Some(policy)) if !policy.is_empty() => {
|
||||
overrides.insert(event_id.to_string(), policy);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
event_id,
|
||||
error = %e,
|
||||
"[meet:policy] per-event lookup failed, falling through"
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
resolve_effective_join_policy_with_overrides(calendar_event_id, platform, config, &overrides)
|
||||
}
|
||||
|
||||
/// Prefetch-aware variant of [`resolve_effective_join_policy`].
|
||||
///
|
||||
/// Takes a map of per-event overrides already loaded from the store (see
|
||||
/// [`super::store::get_event_policies_batch`]) so a batch RPC like
|
||||
/// `handle_list_upcoming` can resolve every meeting's effective policy fully
|
||||
/// in-memory — no per-event SQLite connection / schema migration. Applies the
|
||||
/// same three-tier precedence: per-event override → per-platform → global.
|
||||
pub(crate) fn resolve_effective_join_policy_with_overrides(
|
||||
calendar_event_id: Option<&str>,
|
||||
platform: Option<&str>,
|
||||
config: &crate::openhuman::config::Config,
|
||||
event_overrides: &HashMap<String, String>,
|
||||
) -> String {
|
||||
// Tier 1: per-event override (from the prefetched map).
|
||||
if let Some(event_id) = calendar_event_id {
|
||||
if let Some(policy) = event_overrides.get(event_id) {
|
||||
if !policy.is_empty() {
|
||||
tracing::debug!(
|
||||
event_id,
|
||||
policy = %policy,
|
||||
"[meet:policy] tier=per_event"
|
||||
);
|
||||
return policy.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: per-platform default.
|
||||
if let Some(plat) = platform {
|
||||
if let Some(policy) = config.meet.platform_auto_join_policies.get(plat) {
|
||||
let s = auto_join_policy_to_str(policy);
|
||||
tracing::debug!(
|
||||
platform = plat,
|
||||
policy = s,
|
||||
"[meet:policy] tier=per_platform"
|
||||
);
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: global default.
|
||||
let s = auto_join_policy_to_str(&config.meet.auto_join_policy);
|
||||
tracing::debug!(policy = s, "[meet:policy] tier=global");
|
||||
s.to_string()
|
||||
}
|
||||
|
||||
fn transcript_turns_to_chat_batch(
|
||||
turns: &[BackendMeetTurn],
|
||||
duration_ms: u64,
|
||||
@@ -265,7 +379,25 @@ pub async fn create_meeting_thread_with_transcript(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_meeting_url(raw: &str) -> Result<url::Url, String> {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canonical URL / host helpers (single source of truth)
|
||||
//
|
||||
// `calendar.rs` and `upcoming.rs` both call into these instead of carrying
|
||||
// their own near-duplicate copies. Host matching is STRICT — it parses the URL
|
||||
// and compares the host against `ALLOWED_HOSTS` exactly (or as a subdomain), so
|
||||
// a spoofed host like `meet.google.com.attacker.com` is rejected (it would have
|
||||
// passed a loose `contains("meet.google.com")` check).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `true` when `host` is one of the allowed meeting hosts, either exactly or as
|
||||
/// a subdomain (e.g. `company.zoom.us` matches `zoom.us`).
|
||||
fn host_is_allowed(host: &str) -> bool {
|
||||
ALLOWED_HOSTS
|
||||
.iter()
|
||||
.any(|(allowed, _)| host == *allowed || host.ends_with(&format!(".{allowed}")))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_meeting_url(raw: &str) -> Result<url::Url, String> {
|
||||
let url = url::Url::parse(raw.trim()).map_err(|e| format!("invalid meeting URL: {e}"))?;
|
||||
|
||||
if url.scheme() != "https" && url.scheme() != "http" {
|
||||
@@ -279,11 +411,7 @@ fn validate_meeting_url(raw: &str) -> Result<url::Url, String> {
|
||||
.host_str()
|
||||
.ok_or_else(|| "invalid meeting URL: missing host".to_string())?;
|
||||
|
||||
let is_allowed = ALLOWED_HOSTS
|
||||
.iter()
|
||||
.any(|(allowed, _)| host == *allowed || host.ends_with(&format!(".{allowed}")));
|
||||
|
||||
if !is_allowed {
|
||||
if !host_is_allowed(host) {
|
||||
return Err(format!(
|
||||
"invalid meeting URL: host `{host}` not recognized (supported: Google Meet, Zoom, Teams, Webex)"
|
||||
));
|
||||
@@ -292,7 +420,7 @@ fn validate_meeting_url(raw: &str) -> Result<url::Url, String> {
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn infer_platform(url: &url::Url) -> &'static str {
|
||||
pub(crate) fn infer_platform(url: &url::Url) -> &'static str {
|
||||
let host = url.host_str().unwrap_or("");
|
||||
for (allowed, platform) in ALLOWED_HOSTS {
|
||||
if host == *allowed || host.ends_with(&format!(".{allowed}")) {
|
||||
@@ -302,6 +430,97 @@ fn infer_platform(url: &url::Url) -> &'static str {
|
||||
"gmeet"
|
||||
}
|
||||
|
||||
/// Strict check: does `s` parse as an http(s) URL whose host is an allowed
|
||||
/// meeting host? This is the single canonical `is_meeting_url` used by both the
|
||||
/// calendar auto-join subscriber and the upcoming-meetings fetcher. Unlike a
|
||||
/// loose substring match, this rejects `https://meet.google.com.attacker.com/x`.
|
||||
pub(crate) fn is_meeting_url(s: &str) -> bool {
|
||||
match url::Url::parse(s.trim()) {
|
||||
Ok(u) => {
|
||||
matches!(u.scheme(), "http" | "https")
|
||||
&& u.host_str().map(host_is_allowed).unwrap_or(false)
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Infer the platform slug from a URL string using strict host matching.
|
||||
/// Returns `None` when the host is not a recognized meeting host.
|
||||
pub(crate) fn infer_platform_from_url(url_str: &str) -> Option<&'static str> {
|
||||
let parsed = url::Url::parse(url_str.trim()).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
ALLOWED_HOSTS
|
||||
.iter()
|
||||
.find(|(allowed, _)| host == *allowed || host.ends_with(&format!(".{allowed}")))
|
||||
.map(|(_, platform)| *platform)
|
||||
}
|
||||
|
||||
/// Extract the first strictly-validated meeting URL from a free-form text
|
||||
/// string (e.g. a calendar `location`/`description` like
|
||||
/// `"Zoom Meeting: https://zoom.us/j/123"`). Scans whitespace-separated tokens,
|
||||
/// strips surrounding punctuation (including trailing `.`), and returns the
|
||||
/// first token that parses as an http(s) URL with an allowed meeting host.
|
||||
pub(crate) fn extract_url_from_text(text: &str) -> Option<String> {
|
||||
text.split_whitespace()
|
||||
.map(|tok| {
|
||||
tok.trim_matches(|c: char| {
|
||||
matches!(
|
||||
c,
|
||||
'(' | ')' | '[' | ']' | '<' | '>' | ',' | ';' | '"' | '\'' | '.'
|
||||
)
|
||||
})
|
||||
})
|
||||
.find_map(|tok| {
|
||||
let parsed = url::Url::parse(tok).ok()?;
|
||||
(matches!(parsed.scheme(), "http" | "https")
|
||||
&& parsed.host_str().map(host_is_allowed).unwrap_or(false))
|
||||
.then(|| parsed.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract a stable calendar event id from a calendar event object map, in the
|
||||
/// canonical priority order shared by every meeting consumer (the UI
|
||||
/// `meet_list_upcoming` table, the heartbeat planner, and the calendar
|
||||
/// auto-join subscriber): `id` → `eventId` → `icalUID`. Returns `None` when the
|
||||
/// object carries none of these.
|
||||
pub(crate) fn extract_calendar_event_id(
|
||||
map: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
map.get("id")
|
||||
.or_else(|| map.get("eventId"))
|
||||
.or_else(|| map.get("icalUID"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Extract the calendar event id from a Composio trigger payload, which may nest
|
||||
/// the event resource under `data`. Uses the same `id`/`eventId`/`icalUID`
|
||||
/// priority as [`extract_calendar_event_id`] so the webhook auto-join path keys
|
||||
/// per-event policy lookups by the SAME id the UI persists them under.
|
||||
///
|
||||
/// The `data` sub-object is checked **first** so the actual calendar event id
|
||||
/// nested under `data` wins over any top-level `id` that might belong to the
|
||||
/// Composio trigger wrapper rather than to the calendar event itself. Falls back
|
||||
/// to the top-level object (events.list shape where `id` is directly on the
|
||||
/// event).
|
||||
pub(crate) fn extract_calendar_event_id_from_payload(payload: &Value) -> Option<String> {
|
||||
// Prefer nested `data` (Composio webhook trigger shape).
|
||||
if let Some(data) = payload.get("data") {
|
||||
if let Some(obj) = data.as_object() {
|
||||
if let Some(id) = extract_calendar_event_id(obj) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to top-level (events.list shape).
|
||||
if let Some(obj) = payload.as_object() {
|
||||
extract_calendar_event_id(obj)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `bot:join` Socket.IO payload from a validated request.
|
||||
///
|
||||
/// Extracted as a pure function so it can be unit-tested independently of the
|
||||
@@ -700,6 +919,181 @@ pub async fn handle_notification_action(params: Map<String, Value>) -> Result<Va
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `openhuman.meet_list_upcoming` — list upcoming calendar meetings that
|
||||
/// have a conferencing link, fetching from Composio's Google Calendar integration.
|
||||
///
|
||||
/// Returns an empty meetings list (ok=true) when:
|
||||
/// - No calendar is connected.
|
||||
/// - The user is not signed in to the backend.
|
||||
/// - All connections are inactive.
|
||||
///
|
||||
/// Returns an error string only for hard failures (bad params, config load).
|
||||
pub async fn handle_list_upcoming(params: Map<String, Value>) -> Result<Value, String> {
|
||||
use super::types::{ListUpcomingRequest, ListUpcomingResponse};
|
||||
use super::upcoming::{fetch_upcoming_meetings, DEFAULT_LIMIT, DEFAULT_LOOKAHEAD_MINUTES};
|
||||
|
||||
let req: ListUpcomingRequest = serde_json::from_value(Value::Object(params))
|
||||
.map_err(|e| format!("[meet:upcoming] invalid params: {e}"))?;
|
||||
|
||||
let lookahead_minutes = req.lookahead_minutes.unwrap_or(DEFAULT_LOOKAHEAD_MINUTES);
|
||||
let limit = req.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, 100);
|
||||
|
||||
tracing::debug!(
|
||||
lookahead_minutes,
|
||||
limit,
|
||||
"[meet:upcoming] handle_list_upcoming called"
|
||||
);
|
||||
|
||||
// Load config to get the auto_join_policy and composio settings.
|
||||
let config = crate::openhuman::config::ops::load_config_with_timeout()
|
||||
.await
|
||||
.map_err(|e| format!("[meet:upcoming] config load failed: {e}"))?;
|
||||
|
||||
// The global fallback policy string (used when no per-event or per-platform override exists).
|
||||
let global_policy = auto_join_policy_to_str(&config.meet.auto_join_policy);
|
||||
|
||||
let mut meetings = fetch_upcoming_meetings(&config, lookahead_minutes, limit, global_policy)
|
||||
.await
|
||||
.map_err(|e| format!("[meet:upcoming] fetch failed: {e}"))?;
|
||||
|
||||
// Phase 3: resolve effective per-event policy overrides.
|
||||
//
|
||||
// Batch-load every meeting's per-event override in ONE SQLite connection
|
||||
// (single schema migration) up front, then resolve each meeting's effective
|
||||
// policy fully in-memory. The previous per-meeting `get_event_policy` call
|
||||
// opened a fresh connection AND re-ran the full schema migration once per
|
||||
// meeting — up to ~100× per RPC.
|
||||
let event_overrides = {
|
||||
let event_ids: Vec<&str> = meetings
|
||||
.iter()
|
||||
.map(|m| m.calendar_event_id.as_str())
|
||||
.collect();
|
||||
super::store::get_event_policies_batch(&config, &event_ids).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"[meet:upcoming] batch policy fetch failed — falling back to global/per-platform only"
|
||||
);
|
||||
HashMap::new()
|
||||
})
|
||||
};
|
||||
for meeting in &mut meetings {
|
||||
let platform = meeting.platform.as_deref();
|
||||
let event_id = Some(meeting.calendar_event_id.as_str());
|
||||
let effective = resolve_effective_join_policy_with_overrides(
|
||||
event_id,
|
||||
platform,
|
||||
&config,
|
||||
&event_overrides,
|
||||
);
|
||||
if effective != meeting.join_policy {
|
||||
tracing::debug!(
|
||||
calendar_event_id = %meeting.calendar_event_id,
|
||||
global = %meeting.join_policy,
|
||||
effective = %effective,
|
||||
"[meet:upcoming] per-event/platform policy override applied"
|
||||
);
|
||||
meeting.join_policy = effective;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
count = meetings.len(),
|
||||
global_policy,
|
||||
"[meet:upcoming] returning meetings"
|
||||
);
|
||||
|
||||
let response = ListUpcomingResponse { ok: true, meetings };
|
||||
let outcome = RpcOutcome::new(
|
||||
serde_json::to_value(response)
|
||||
.map_err(|e| format!("[meet:upcoming] serialize failed: {e}"))?,
|
||||
vec![],
|
||||
);
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
/// Handle `openhuman.meet_set_event_policy` — persist a per-event join-policy
|
||||
/// override for a specific calendar event.
|
||||
pub async fn handle_set_event_policy(params: Map<String, Value>) -> Result<Value, String> {
|
||||
use super::types::{SetEventPolicyRequest, SetEventPolicyResponse};
|
||||
|
||||
let req: SetEventPolicyRequest = serde_json::from_value(Value::Object(params))
|
||||
.map_err(|e| format!("[meet:set_event_policy] invalid params: {e}"))?;
|
||||
|
||||
let policy = req.policy.trim();
|
||||
if !matches!(policy, "auto" | "ask" | "skip") {
|
||||
return Err(format!(
|
||||
"[meet:set_event_policy] invalid policy: {policy} (valid: auto, ask, skip)"
|
||||
));
|
||||
}
|
||||
|
||||
let calendar_event_id = req.calendar_event_id.trim().to_string();
|
||||
if calendar_event_id.is_empty() {
|
||||
return Err("[meet:set_event_policy] calendar_event_id must not be empty".to_string());
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
calendar_event_id = %calendar_event_id,
|
||||
policy = %policy,
|
||||
"[meet:set_event_policy] persisting policy override"
|
||||
);
|
||||
|
||||
let config = crate::openhuman::config::ops::load_config_with_timeout()
|
||||
.await
|
||||
.map_err(|e| format!("[meet:set_event_policy] config load failed: {e}"))?;
|
||||
|
||||
super::store::set_event_policy(&config, &calendar_event_id, policy)
|
||||
.map_err(|e| format!("[meet:set_event_policy] store failed: {e}"))?;
|
||||
|
||||
tracing::info!(
|
||||
calendar_event_id = %calendar_event_id,
|
||||
policy = %policy,
|
||||
"[meet:set_event_policy] policy stored"
|
||||
);
|
||||
|
||||
let response = SetEventPolicyResponse { ok: true };
|
||||
let outcome = RpcOutcome::new(
|
||||
serde_json::to_value(response)
|
||||
.map_err(|e| format!("[meet:set_event_policy] serialize failed: {e}"))?,
|
||||
vec![],
|
||||
);
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
/// Handle `openhuman.meet_get_event_policies` — retrieve stored per-event
|
||||
/// join-policy overrides for a batch of calendar event IDs.
|
||||
pub async fn handle_get_event_policies(params: Map<String, Value>) -> Result<Value, String> {
|
||||
use super::types::{GetEventPoliciesRequest, GetEventPoliciesResponse};
|
||||
|
||||
let req: GetEventPoliciesRequest = serde_json::from_value(Value::Object(params))
|
||||
.map_err(|e| format!("[meet:get_event_policies] invalid params: {e}"))?;
|
||||
|
||||
tracing::debug!(
|
||||
count = req.calendar_event_ids.len(),
|
||||
"[meet:get_event_policies] fetching policies"
|
||||
);
|
||||
|
||||
let config = crate::openhuman::config::ops::load_config_with_timeout()
|
||||
.await
|
||||
.map_err(|e| format!("[meet:get_event_policies] config load failed: {e}"))?;
|
||||
|
||||
let id_refs: Vec<&str> = req.calendar_event_ids.iter().map(String::as_str).collect();
|
||||
let policies = super::store::get_event_policies_batch(&config, &id_refs)
|
||||
.map_err(|e| format!("[meet:get_event_policies] store failed: {e}"))?;
|
||||
|
||||
tracing::debug!(
|
||||
found = policies.len(),
|
||||
"[meet:get_event_policies] returning policies"
|
||||
);
|
||||
|
||||
let response = GetEventPoliciesResponse { ok: true, policies };
|
||||
let outcome = RpcOutcome::new(
|
||||
serde_json::to_value(response)
|
||||
.map_err(|e| format!("[meet:get_event_policies] serialize failed: {e}"))?,
|
||||
vec![],
|
||||
);
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -731,6 +1125,194 @@ mod tests {
|
||||
assert!(validate_meeting_url("https://example.com/meeting").is_err());
|
||||
}
|
||||
|
||||
// ── strict host matching (anti-spoof) ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_spoofed_suffix_host() {
|
||||
// A loose `contains("meet.google.com")` check would let these through.
|
||||
assert!(validate_meeting_url("https://meet.google.com.attacker.com/x").is_err());
|
||||
assert!(validate_meeting_url("https://zoom.us.evil.example/x").is_err());
|
||||
assert!(validate_meeting_url("https://notzoom.us/x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_meeting_url_strict_rejects_spoofed_host() {
|
||||
assert!(!is_meeting_url("https://meet.google.com.attacker.com/x"));
|
||||
assert!(!is_meeting_url("https://evilzoom.us.attacker.com/j/1"));
|
||||
// Bare host with no scheme is not a usable meeting URL.
|
||||
assert!(!is_meeting_url("meet.google.com/abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_meeting_url_accepts_allowed_hosts_and_subdomains() {
|
||||
assert!(is_meeting_url("https://meet.google.com/abc-defg-hij"));
|
||||
assert!(is_meeting_url("https://zoom.us/j/123"));
|
||||
assert!(is_meeting_url("https://company.zoom.us/j/123"));
|
||||
assert!(is_meeting_url(
|
||||
"https://teams.microsoft.com/l/meetup-join/abc"
|
||||
));
|
||||
assert!(is_meeting_url("https://meet.webex.com/meet/abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_platform_from_url_strict() {
|
||||
assert_eq!(
|
||||
infer_platform_from_url("https://company.zoom.us/j/1"),
|
||||
Some("zoom")
|
||||
);
|
||||
assert_eq!(
|
||||
infer_platform_from_url("https://meet.google.com/abc"),
|
||||
Some("gmeet")
|
||||
);
|
||||
// Spoofed host must not infer a platform.
|
||||
assert!(infer_platform_from_url("https://meet.google.com.attacker.com/x").is_none());
|
||||
assert!(infer_platform_from_url("https://example.com/x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_url_from_text_strict() {
|
||||
assert_eq!(
|
||||
extract_url_from_text("Zoom Meeting: https://zoom.us/j/123"),
|
||||
Some("https://zoom.us/j/123".to_string())
|
||||
);
|
||||
// Spoofed host embedded in free-form text is rejected.
|
||||
assert!(extract_url_from_text("Join https://meet.google.com.attacker.com/x now").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_calendar_event_id_priority() {
|
||||
let map = json!({ "id": "real-id", "eventId": "ev", "icalUID": "ical" });
|
||||
assert_eq!(
|
||||
extract_calendar_event_id(map.as_object().unwrap()).as_deref(),
|
||||
Some("real-id")
|
||||
);
|
||||
let map = json!({ "eventId": "ev", "icalUID": "ical" });
|
||||
assert_eq!(
|
||||
extract_calendar_event_id(map.as_object().unwrap()).as_deref(),
|
||||
Some("ev")
|
||||
);
|
||||
let map = json!({ "summary": "no id" });
|
||||
assert!(extract_calendar_event_id(map.as_object().unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_calendar_event_id_from_payload_handles_nested_data() {
|
||||
let payload = json!({ "data": { "id": "nested-id" } });
|
||||
assert_eq!(
|
||||
extract_calendar_event_id_from_payload(&payload).as_deref(),
|
||||
Some("nested-id")
|
||||
);
|
||||
let payload = json!({ "id": "top-id" });
|
||||
assert_eq!(
|
||||
extract_calendar_event_id_from_payload(&payload).as_deref(),
|
||||
Some("top-id")
|
||||
);
|
||||
}
|
||||
|
||||
// ── nested data preferred over top-level (finding #4) ──────
|
||||
|
||||
#[test]
|
||||
fn extract_calendar_event_id_from_payload_prefers_nested_data_when_both_present() {
|
||||
// A Composio trigger wrapper may carry its own top-level `id` (trigger
|
||||
// metadata) while the actual calendar event id is under `data.id`.
|
||||
// The nested value must always win.
|
||||
let payload = json!({
|
||||
"id": "trigger-wrapper-id",
|
||||
"data": { "id": "calendar-event-id-real" }
|
||||
});
|
||||
assert_eq!(
|
||||
extract_calendar_event_id_from_payload(&payload).as_deref(),
|
||||
Some("calendar-event-id-real"),
|
||||
"nested data.id must win over top-level trigger wrapper id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_calendar_event_id_from_payload_uses_nested_event_id_fallback() {
|
||||
// data.eventId when data.id is absent.
|
||||
let payload = json!({ "id": "outer-id", "data": { "eventId": "ev-nested-456" } });
|
||||
assert_eq!(
|
||||
extract_calendar_event_id_from_payload(&payload).as_deref(),
|
||||
Some("ev-nested-456"),
|
||||
"nested data.eventId must win over top-level id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_calendar_event_id_from_payload_uses_nested_ical_uid() {
|
||||
// data.icalUID as last resort in nested data.
|
||||
let payload = json!({
|
||||
"id": "outer-id",
|
||||
"data": { "icalUID": "ical-uid-nested@calendar.google.com" }
|
||||
});
|
||||
assert_eq!(
|
||||
extract_calendar_event_id_from_payload(&payload).as_deref(),
|
||||
Some("ical-uid-nested@calendar.google.com"),
|
||||
"nested data.icalUID must win over top-level id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_calendar_event_id_from_payload_falls_back_to_top_level_when_data_has_no_id() {
|
||||
// data is present but has no id fields → fall back to top-level.
|
||||
let payload = json!({ "id": "top-id", "data": { "summary": "No id here" } });
|
||||
assert_eq!(
|
||||
extract_calendar_event_id_from_payload(&payload).as_deref(),
|
||||
Some("top-id"),
|
||||
"top-level id used when data has no id fields"
|
||||
);
|
||||
}
|
||||
|
||||
// ── batch policy resolution ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_with_overrides_tiers() {
|
||||
use crate::openhuman::config::schema::AutoJoinPolicy;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.workspace_dir = dir.path().to_path_buf();
|
||||
config
|
||||
.meet
|
||||
.platform_auto_join_policies
|
||||
.insert("zoom".to_string(), AutoJoinPolicy::Always);
|
||||
|
||||
let mut overrides = HashMap::new();
|
||||
overrides.insert("evt-1".to_string(), "skip".to_string());
|
||||
|
||||
// Tier 1: per-event override wins.
|
||||
assert_eq!(
|
||||
resolve_effective_join_policy_with_overrides(
|
||||
Some("evt-1"),
|
||||
Some("zoom"),
|
||||
&config,
|
||||
&overrides
|
||||
),
|
||||
"skip"
|
||||
);
|
||||
// Tier 2: no override for this event → per-platform.
|
||||
assert_eq!(
|
||||
resolve_effective_join_policy_with_overrides(
|
||||
Some("evt-2"),
|
||||
Some("zoom"),
|
||||
&config,
|
||||
&overrides
|
||||
),
|
||||
"auto"
|
||||
);
|
||||
// Tier 3: no override, unknown platform → global default ("ask").
|
||||
assert_eq!(
|
||||
resolve_effective_join_policy_with_overrides(
|
||||
Some("evt-2"),
|
||||
Some("gmeet"),
|
||||
&config,
|
||||
&overrides
|
||||
),
|
||||
"ask"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_action_requires_action_id() {
|
||||
let err = handle_notification_action(Map::new()).await.unwrap_err();
|
||||
@@ -1121,4 +1703,48 @@ mod tests {
|
||||
assert_eq!(rc.primary_color.as_deref(), Some("#abc"));
|
||||
assert_eq!(rc.secondary_color.as_deref(), Some("#def"));
|
||||
}
|
||||
|
||||
// ── policy resolution tiers ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn policy_resolution_tiers() {
|
||||
use crate::openhuman::config::schema::AutoJoinPolicy;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.workspace_dir = dir.path().to_path_buf();
|
||||
|
||||
// Global default is AskEachTime → "ask".
|
||||
assert_eq!(resolve_effective_join_policy(None, None, &config), "ask");
|
||||
|
||||
// Per-platform override for "zoom" → "auto".
|
||||
config
|
||||
.meet
|
||||
.platform_auto_join_policies
|
||||
.insert("zoom".to_string(), AutoJoinPolicy::Always);
|
||||
assert_eq!(
|
||||
resolve_effective_join_policy(None, Some("zoom"), &config),
|
||||
"auto"
|
||||
);
|
||||
// Other platforms still fall through to global.
|
||||
assert_eq!(
|
||||
resolve_effective_join_policy(None, Some("gmeet"), &config),
|
||||
"ask"
|
||||
);
|
||||
|
||||
// Per-event override wins over per-platform.
|
||||
crate::openhuman::agent_meetings::store::set_event_policy(&config, "evt-zoom-1", "skip")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolve_effective_join_policy(Some("evt-zoom-1"), Some("zoom"), &config),
|
||||
"skip"
|
||||
);
|
||||
|
||||
// Different event still gets per-platform.
|
||||
assert_eq!(
|
||||
resolve_effective_join_policy(Some("evt-zoom-2"), Some("zoom"), &config),
|
||||
"auto"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,21 @@ const DEFS: &[BackendMeetControllerDef] = &[
|
||||
schema: schema_notification_action,
|
||||
handler: handle_notification_action_wrap,
|
||||
},
|
||||
BackendMeetControllerDef {
|
||||
function: "list_upcoming",
|
||||
schema: schema_list_upcoming,
|
||||
handler: handle_list_upcoming_wrap,
|
||||
},
|
||||
BackendMeetControllerDef {
|
||||
function: "set_event_policy",
|
||||
schema: schema_set_event_policy,
|
||||
handler: handle_set_event_policy_wrap,
|
||||
},
|
||||
BackendMeetControllerDef {
|
||||
function: "get_event_policies",
|
||||
schema: schema_get_event_policies,
|
||||
handler: handle_get_event_policies_wrap,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
@@ -279,6 +294,124 @@ fn handle_notification_action_wrap(params: Map<String, Value>) -> ControllerFutu
|
||||
Box::pin(async move { super::ops::handle_notification_action(params).await })
|
||||
}
|
||||
|
||||
fn schema_list_upcoming() -> ControllerSchema {
|
||||
ControllerSchema {
|
||||
// NOTE: namespace is "meet" (not "agent_meetings") so the RPC name is
|
||||
// `openhuman.meet_list_upcoming`. The handler lives here in the
|
||||
// agent_meetings module because the logic is tightly coupled to the
|
||||
// calendar/meeting infrastructure already present in this domain.
|
||||
namespace: "meet",
|
||||
function: "list_upcoming",
|
||||
description: "List upcoming calendar meetings that have a conferencing link (Google Meet, \
|
||||
Zoom, Teams, Webex), fetched from the user's connected Google Calendar via \
|
||||
Composio. Returns an empty list when no calendar is connected. Sort order: \
|
||||
soonest first. Each record includes the inferred platform, attendee count, \
|
||||
organizer, and the global auto-join policy as the default join_policy.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "lookahead_minutes",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "How many minutes ahead to look for meetings. Defaults to 480 (8 hours).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "limit",
|
||||
ty: TypeSchema::U64,
|
||||
comment:
|
||||
"Maximum number of meetings to return. Defaults to 20. Clamped to [1, 100].",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "ok",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Always true on success.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "meetings",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Array of upcoming meeting records (may be empty).",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_list_upcoming_wrap(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { super::ops::handle_list_upcoming(params).await })
|
||||
}
|
||||
|
||||
fn schema_set_event_policy() -> ControllerSchema {
|
||||
ControllerSchema {
|
||||
namespace: "meet",
|
||||
function: "set_event_policy",
|
||||
description: "Persist a per-event join-policy override for a specific calendar event. \
|
||||
The stored policy takes precedence over per-platform and global defaults \
|
||||
when the same event ID appears in meet_list_upcoming.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "calendar_event_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Stable calendar provider event id (the same id returned by meet_list_upcoming).",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "policy",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Join policy for this event: \"auto\" (always join), \"ask\" (prompt), or \"skip\" (never join).",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "ok",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when the policy was stored successfully.",
|
||||
required: true,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_set_event_policy_wrap(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { super::ops::handle_set_event_policy(params).await })
|
||||
}
|
||||
|
||||
fn schema_get_event_policies() -> ControllerSchema {
|
||||
ControllerSchema {
|
||||
namespace: "meet",
|
||||
function: "get_event_policies",
|
||||
description: "Retrieve stored per-event join-policy overrides for a batch of calendar \
|
||||
event IDs. Event IDs without a stored override are omitted from the \
|
||||
returned map.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "calendar_event_ids",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Array of calendar event id strings to look up.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "ok",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Always true on success.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "policies",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Object mapping calendar_event_id → policy string (\"auto\" | \"ask\" | \"skip\"). \
|
||||
Only IDs with a stored override are included.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_get_event_policies_wrap(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { super::ops::handle_get_event_policies(params).await })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -301,7 +434,10 @@ mod tests {
|
||||
"leave",
|
||||
"harness_response",
|
||||
"speak",
|
||||
"notification_action"
|
||||
"notification_action",
|
||||
"list_upcoming",
|
||||
"set_event_policy",
|
||||
"get_event_policies",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! SQLite persistence for `MeetingSession` records.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
@@ -25,6 +27,11 @@ CREATE INDEX IF NOT EXISTS idx_meeting_sessions_status
|
||||
ON meeting_sessions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_meeting_sessions_meet_url
|
||||
ON meeting_sessions(meet_url);
|
||||
CREATE TABLE IF NOT EXISTS meeting_event_policies (
|
||||
calendar_event_id TEXT PRIMARY KEY,
|
||||
policy TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
|
||||
@@ -240,6 +247,67 @@ pub fn mark_summary_generated(config: &Config, id: &str, now_ms: u64) -> Result<
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist or replace the join-policy override for a specific calendar event.
|
||||
///
|
||||
/// The `policy` must be one of "auto" | "ask" | "skip" — anything else is
|
||||
/// rejected with an error so the table cannot accumulate invalid values.
|
||||
pub fn set_event_policy(config: &Config, calendar_event_id: &str, policy: &str) -> Result<()> {
|
||||
if !matches!(policy, "auto" | "ask" | "skip") {
|
||||
anyhow::bail!(
|
||||
"[meetings::store] set_event_policy: unknown policy {:?} (valid: auto, ask, skip)",
|
||||
policy
|
||||
);
|
||||
}
|
||||
with_connection(config, |conn| {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meeting_event_policies (calendar_event_id, policy, updated_at_ms) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![calendar_event_id, policy, now_ms],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Retrieve the join-policy override for a specific calendar event. Returns
|
||||
/// `None` when no override has been stored.
|
||||
pub fn get_event_policy(config: &Config, calendar_event_id: &str) -> Result<Option<String>> {
|
||||
with_connection(config, |conn| {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT policy FROM meeting_event_policies WHERE calendar_event_id = ?1")?;
|
||||
let mut rows = stmt.query(rusqlite::params![calendar_event_id])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(row.get(0)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Retrieve join-policy overrides for a batch of calendar event IDs in a
|
||||
/// single database connection. IDs without a stored override are omitted from
|
||||
/// the returned map.
|
||||
pub fn get_event_policies_batch(config: &Config, ids: &[&str]) -> Result<HashMap<String, String>> {
|
||||
with_connection(config, |conn| {
|
||||
let mut map = HashMap::new();
|
||||
if ids.is_empty() {
|
||||
return Ok(map);
|
||||
}
|
||||
// Prepare the SELECT once and reuse it for every id — not once per id.
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT policy FROM meeting_event_policies WHERE calendar_event_id = ?1")?;
|
||||
for &id in ids {
|
||||
let mut rows = stmt.query(rusqlite::params![id])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
map.insert(id.to_string(), row.get(0)?);
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -363,4 +431,126 @@ mod tests {
|
||||
let result = get_session(&config, "nonexistent").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_get_event_policy() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let mut config = Config::default();
|
||||
config.workspace_dir = dir.path().to_path_buf();
|
||||
set_event_policy(&config, "evt-1", "auto").unwrap();
|
||||
let result = get_event_policy(&config, "evt-1").unwrap();
|
||||
assert_eq!(result, Some("auto".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_overwrites() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let mut config = Config::default();
|
||||
config.workspace_dir = dir.path().to_path_buf();
|
||||
set_event_policy(&config, "evt-2", "auto").unwrap();
|
||||
set_event_policy(&config, "evt-2", "skip").unwrap();
|
||||
let result = get_event_policy(&config, "evt-2").unwrap();
|
||||
assert_eq!(result, Some("skip".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_event_policy_returns_none() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let mut config = Config::default();
|
||||
config.workspace_dir = dir.path().to_path_buf();
|
||||
let result = get_event_policy(&config, "nonexistent-evt").unwrap();
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_policies_round_trip() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let mut config = Config::default();
|
||||
config.workspace_dir = dir.path().to_path_buf();
|
||||
set_event_policy(&config, "evt-a", "auto").unwrap();
|
||||
set_event_policy(&config, "evt-b", "skip").unwrap();
|
||||
let map = get_event_policies_batch(&config, &["evt-a", "evt-b", "evt-missing"]).unwrap();
|
||||
assert_eq!(map.len(), 2);
|
||||
assert_eq!(map.get("evt-a"), Some(&"auto".to_string()));
|
||||
assert_eq!(map.get("evt-b"), Some(&"skip".to_string()));
|
||||
assert!(!map.contains_key("evt-missing"));
|
||||
}
|
||||
|
||||
// ── policy validation (finding #5) ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_event_policy_accepts_all_known_values() {
|
||||
let (config, _dir) = test_config();
|
||||
set_event_policy(&config, "evt-auto", "auto").unwrap();
|
||||
set_event_policy(&config, "evt-ask", "ask").unwrap();
|
||||
set_event_policy(&config, "evt-skip", "skip").unwrap();
|
||||
assert_eq!(
|
||||
get_event_policy(&config, "evt-auto").unwrap().as_deref(),
|
||||
Some("auto")
|
||||
);
|
||||
assert_eq!(
|
||||
get_event_policy(&config, "evt-ask").unwrap().as_deref(),
|
||||
Some("ask")
|
||||
);
|
||||
assert_eq!(
|
||||
get_event_policy(&config, "evt-skip").unwrap().as_deref(),
|
||||
Some("skip")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_event_policy_rejects_unknown_value() {
|
||||
let (config, _dir) = test_config();
|
||||
let err = set_event_policy(&config, "evt-bad", "invalid").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("invalid") || msg.contains("unknown"),
|
||||
"error must describe the rejected value: {msg}"
|
||||
);
|
||||
// Must NOT have been persisted.
|
||||
assert!(
|
||||
get_event_policy(&config, "evt-bad").unwrap().is_none(),
|
||||
"unknown policy must not be written to the store"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_event_policy_rejects_empty_string() {
|
||||
let (config, _dir) = test_config();
|
||||
let err = set_event_policy(&config, "evt-empty", "").unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("unknown") || err.to_string().contains("valid"),
|
||||
"empty string should be rejected: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_calendar_event_id_persisted_and_retrieved() {
|
||||
// Regression test for finding #3: calendar_event_id on a session must
|
||||
// survive the store round-trip (was previously stored as None).
|
||||
let (config, _dir) = test_config();
|
||||
let now_ms = 99_000u64;
|
||||
let session = MeetingSession {
|
||||
id: "sess-cev-test".into(),
|
||||
meet_url: "https://meet.google.com/cev-test".into(),
|
||||
title: Some("Finding #3 test meeting".into()),
|
||||
calendar_event_id: Some("actual-calendar-event-id-xyz".into()),
|
||||
status: MeetingSessionStatus::Joined,
|
||||
source: AutoJoinSource::Calendar,
|
||||
thread_id: None,
|
||||
transcript_received: false,
|
||||
summary_generated: false,
|
||||
created_at_ms: now_ms,
|
||||
updated_at_ms: now_ms,
|
||||
};
|
||||
create_session(&config, &session).unwrap();
|
||||
let fetched = get_session(&config, "sess-cev-test")
|
||||
.unwrap()
|
||||
.expect("session must exist");
|
||||
assert_eq!(
|
||||
fetched.calendar_event_id.as_deref(),
|
||||
Some("actual-calendar-event-id-xyz"),
|
||||
"calendar_event_id must survive store round-trip"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,84 @@ pub struct BackendMeetSpeakRequest {
|
||||
pub correlation_id: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// meet_list_upcoming RPC types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Inputs to `openhuman.meet_list_upcoming`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ListUpcomingRequest {
|
||||
/// How many minutes ahead to look for meetings. Defaults to 480 (8 hours).
|
||||
#[serde(default)]
|
||||
pub lookahead_minutes: Option<u32>,
|
||||
/// Maximum number of meetings to return. Defaults to 20.
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// One upcoming calendar meeting that has a conferencing link.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UpcomingMeeting {
|
||||
/// Calendar provider event id (stable dedupe key).
|
||||
pub calendar_event_id: String,
|
||||
/// Human-readable meeting title (from calendar event summary).
|
||||
pub title: String,
|
||||
/// Start time as Unix milliseconds.
|
||||
pub start_time_ms: u64,
|
||||
/// End time as Unix milliseconds.
|
||||
pub end_time_ms: u64,
|
||||
/// Conferencing URL (Google Meet, Zoom, Teams, Webex).
|
||||
pub meet_url: Option<String>,
|
||||
/// Platform slug inferred from the URL host: gmeet, zoom, teams, webex.
|
||||
pub platform: Option<String>,
|
||||
/// Number of attendees listed on the calendar event.
|
||||
pub participant_count: Option<u32>,
|
||||
/// Organizer display name or email, if present.
|
||||
pub organizer: Option<String>,
|
||||
/// Join policy string: "auto" | "ask" | "skip" (mapped from MeetConfig.auto_join_policy).
|
||||
pub join_policy: String,
|
||||
/// Source integration slug, e.g. "googlecalendar".
|
||||
pub calendar_source: String,
|
||||
}
|
||||
|
||||
/// Response from `openhuman.meet_list_upcoming`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ListUpcomingResponse {
|
||||
pub ok: bool,
|
||||
pub meetings: Vec<UpcomingMeeting>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3 per-event policy RPC types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request for `openhuman.meet_set_event_policy`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetEventPolicyRequest {
|
||||
pub calendar_event_id: String,
|
||||
/// "auto" | "ask" | "skip"
|
||||
pub policy: String,
|
||||
}
|
||||
|
||||
/// Response for `openhuman.meet_set_event_policy`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SetEventPolicyResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// Request for `openhuman.meet_get_event_policies`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetEventPoliciesRequest {
|
||||
pub calendar_event_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Response for `openhuman.meet_get_event_policies`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GetEventPoliciesResponse {
|
||||
pub ok: bool,
|
||||
pub policies: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,932 @@
|
||||
//! Fetch upcoming calendar meetings via Composio for the
|
||||
//! `openhuman.meet_list_upcoming` RPC.
|
||||
//!
|
||||
//! Independent of the heartbeat collectors path — two consumers, one Composio
|
||||
//! access pattern. Does NOT touch the heartbeat planner or notification pipeline.
|
||||
//!
|
||||
//! ## Design note
|
||||
//!
|
||||
//! The heartbeat's `collect_calendar_events` helper is intentionally NOT shared
|
||||
//! here (brief guidance: "Do NOT refactor the heartbeat collectors module to
|
||||
//! share code — that risks regressing the notification planner"). We reuse only
|
||||
//! the Composio client factory (`create_composio_client`) and the calendar query
|
||||
//! defaults helper (`apply_calendar_query_defaults`) — both are already `pub` and
|
||||
//! carry zero heartbeat logic.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::openhuman::composio::client::{
|
||||
create_composio_client, direct_execute, direct_list_connections, ComposioClientKind,
|
||||
};
|
||||
use crate::openhuman::composio::types::ComposioConnection;
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
use super::types::UpcomingMeeting;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) const DEFAULT_LOOKAHEAD_MINUTES: u32 = 480; // 8 hours
|
||||
pub(crate) const DEFAULT_LIMIT: u32 = 20;
|
||||
|
||||
// URL/host/platform helpers (`is_meeting_url`, `extract_url_from_text`,
|
||||
// `infer_platform_from_url`) are the canonical strict versions in `super::ops`
|
||||
// — see finding #9 consolidation. This module no longer carries its own copies.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetch upcoming meetings across all connected Google Calendar accounts.
|
||||
///
|
||||
/// Returns an empty `Vec` (not an error) when no calendar is connected —
|
||||
/// this is expected for users who haven't linked Google Calendar yet.
|
||||
pub(crate) async fn fetch_upcoming_meetings(
|
||||
config: &Config,
|
||||
lookahead_minutes: u32,
|
||||
limit: u32,
|
||||
join_policy: &str,
|
||||
) -> Result<Vec<UpcomingMeeting>, String> {
|
||||
let now = Utc::now();
|
||||
let end_window = now + chrono::Duration::minutes(i64::from(lookahead_minutes.max(1)));
|
||||
|
||||
tracing::debug!(
|
||||
lookahead_minutes,
|
||||
limit,
|
||||
now = %now,
|
||||
end_window = %end_window,
|
||||
"[meet:upcoming] fetch start"
|
||||
);
|
||||
|
||||
// Build the mode-aware Composio client. Fails gracefully when the user
|
||||
// is not signed in or has no Composio config — same pattern as the
|
||||
// heartbeat planner.
|
||||
let kind = match create_composio_client(config) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
tracing::info!(
|
||||
error = %e,
|
||||
"[meet:upcoming] Composio client unavailable — skipping (no calendar connected)"
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
|
||||
// List connections and filter to active Google Calendar connections only.
|
||||
// Propagate API errors — a failed list_connections is not the same as
|
||||
// "no connections"; it means we could not determine the connection state.
|
||||
let connections = fetch_connections(&kind).await?;
|
||||
let calendar_connections: Vec<_> = connections
|
||||
.into_iter()
|
||||
.filter(|c| c.is_active() && is_calendar_connection(c))
|
||||
.collect();
|
||||
|
||||
tracing::debug!(
|
||||
count = calendar_connections.len(),
|
||||
"[meet:upcoming] active calendar connections"
|
||||
);
|
||||
|
||||
if calendar_connections.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut all_meetings: Vec<UpcomingMeeting> = Vec::new();
|
||||
// Global dedup across connections: same event may appear via multiple accounts.
|
||||
let mut seen_ids: HashSet<String> = HashSet::new();
|
||||
|
||||
for conn in &calendar_connections {
|
||||
// Propagate per-connection fetch errors — an API failure for a
|
||||
// connected calendar is not an empty-calendar case and must surface as
|
||||
// an error so the UI can show its error state rather than a blank list.
|
||||
let events = fetch_events_for_connection(
|
||||
&kind,
|
||||
conn,
|
||||
&config.composio.entity_id,
|
||||
now,
|
||||
end_window,
|
||||
limit,
|
||||
join_policy,
|
||||
&mut seen_ids,
|
||||
)
|
||||
.await?;
|
||||
all_meetings.extend(events);
|
||||
}
|
||||
|
||||
// Sort soonest-first, apply limit.
|
||||
all_meetings.sort_by_key(|m| m.start_time_ms);
|
||||
all_meetings.truncate(limit as usize);
|
||||
|
||||
tracing::info!(total = all_meetings.len(), "[meet:upcoming] fetch complete");
|
||||
|
||||
Ok(all_meetings)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetch the full list of Composio connections.
|
||||
///
|
||||
/// Returns `Ok(connections)` on success or `Err(message)` on any API /
|
||||
/// transport failure. An empty `Ok(vec![])` is a valid response when the
|
||||
/// user has no connections configured — callers must NOT conflate that with
|
||||
/// an error.
|
||||
async fn fetch_connections(kind: &ComposioClientKind) -> Result<Vec<ComposioConnection>, String> {
|
||||
match kind {
|
||||
ComposioClientKind::Backend(client) => match client.list_connections().await {
|
||||
Ok(resp) => {
|
||||
tracing::debug!(
|
||||
count = resp.connections.len(),
|
||||
"[meet:upcoming] list_connections (backend) ok"
|
||||
);
|
||||
Ok(resp.connections)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[meet:upcoming] list_connections (backend) failed");
|
||||
Err(format!("[meet:upcoming] list_connections failed: {e}"))
|
||||
}
|
||||
},
|
||||
ComposioClientKind::Direct(direct) => match direct_list_connections(direct).await {
|
||||
Ok(resp) => {
|
||||
tracing::debug!(
|
||||
count = resp.connections.len(),
|
||||
"[meet:upcoming] list_connections (direct) ok"
|
||||
);
|
||||
Ok(resp.connections)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[meet:upcoming] list_connections (direct) failed");
|
||||
Err(format!("[meet:upcoming] list_connections failed: {e}"))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn is_calendar_connection(conn: &ComposioConnection) -> bool {
|
||||
let toolkit = conn.normalized_toolkit();
|
||||
toolkit == "googlecalendar" || toolkit == "google_calendar" || toolkit == "calendar"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-connection event fetch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the Google Calendar `maxResults` page size for a fetch.
|
||||
///
|
||||
/// Always returns 100 (the API maximum) regardless of the caller's `limit`.
|
||||
/// Conferencing-link events are only identified AFTER fetching, so passing
|
||||
/// `limit` as `maxResults` silently drops link-bearing events that fall past
|
||||
/// position N in the raw calendar page (behind reminders, OOO blocks, etc.).
|
||||
/// The real cap is applied by `truncate(limit)` in `fetch_upcoming_meetings`
|
||||
/// once the filter has run.
|
||||
fn page_size(_limit: u32) -> u32 {
|
||||
100
|
||||
}
|
||||
|
||||
/// Fetch calendar events for one Composio connection and extract upcoming
|
||||
/// meetings with conferencing links.
|
||||
///
|
||||
/// Returns `Ok(meetings)` when the API call succeeds (possibly empty when no
|
||||
/// link-bearing events fall within the window). Returns `Err(message)` on an
|
||||
/// API transport failure or when the Google Calendar tool reports
|
||||
/// `successful = false` — the caller should surface this as an error state
|
||||
/// rather than silently treating it as an empty calendar.
|
||||
async fn fetch_events_for_connection(
|
||||
kind: &ComposioClientKind,
|
||||
conn: &ComposioConnection,
|
||||
entity_id: &str,
|
||||
now: DateTime<Utc>,
|
||||
end_window: DateTime<Utc>,
|
||||
limit: u32,
|
||||
join_policy: &str,
|
||||
seen_ids: &mut HashSet<String>,
|
||||
) -> Result<Vec<UpcomingMeeting>, String> {
|
||||
// Always fetch a full page (100) so that link-bearing events sitting past
|
||||
// the first `limit` raw entries (behind reminders, OOO blocks, etc.) are
|
||||
// not silently dropped before the conferencing-link filter runs.
|
||||
// `timeMin = now` filters on an event's *end* time so in-progress meetings
|
||||
// are also returned.
|
||||
let max_results = page_size(limit);
|
||||
let arguments = json!({
|
||||
"connectionId": conn.id,
|
||||
"timeMin": now.to_rfc3339(),
|
||||
"timeMax": end_window.to_rfc3339(),
|
||||
"maxResults": max_results,
|
||||
});
|
||||
let iana = crate::openhuman::composio::googlecalendar_args::current_iana_timezone();
|
||||
let arguments = crate::openhuman::composio::googlecalendar_args::apply_calendar_query_defaults(
|
||||
"GOOGLECALENDAR_EVENTS_LIST",
|
||||
Some(arguments),
|
||||
&iana,
|
||||
);
|
||||
|
||||
tracing::debug!(
|
||||
connection_id = %conn.id,
|
||||
iana = %iana,
|
||||
"[meet:upcoming] fetching GOOGLECALENDAR_EVENTS_LIST"
|
||||
);
|
||||
|
||||
let resp = match kind {
|
||||
ComposioClientKind::Backend(client) => {
|
||||
client
|
||||
.execute_tool("GOOGLECALENDAR_EVENTS_LIST", arguments)
|
||||
.await
|
||||
}
|
||||
ComposioClientKind::Direct(direct) => {
|
||||
direct_execute(
|
||||
direct,
|
||||
"GOOGLECALENDAR_EVENTS_LIST",
|
||||
arguments,
|
||||
entity_id,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
match resp {
|
||||
Ok(r) if r.successful => {
|
||||
let events = extract_upcoming_meetings(&r.data, now, end_window, join_policy, seen_ids);
|
||||
tracing::debug!(
|
||||
connection_id = %conn.id,
|
||||
event_count = events.len(),
|
||||
"[meet:upcoming] events with conferencing link extracted"
|
||||
);
|
||||
Ok(events)
|
||||
}
|
||||
Ok(r) => {
|
||||
let detail = r.error.as_deref().unwrap_or("unsuccessful=true");
|
||||
tracing::warn!(
|
||||
connection_id = %conn.id,
|
||||
error = %detail,
|
||||
"[meet:upcoming] GOOGLECALENDAR_EVENTS_LIST returned unsuccessful"
|
||||
);
|
||||
Err(format!(
|
||||
"[meet:upcoming] GOOGLECALENDAR_EVENTS_LIST unsuccessful for connection {}: {detail}",
|
||||
conn.id
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
connection_id = %conn.id,
|
||||
error = %e,
|
||||
"[meet:upcoming] GOOGLECALENDAR_EVENTS_LIST transport error"
|
||||
);
|
||||
Err(format!(
|
||||
"[meet:upcoming] GOOGLECALENDAR_EVENTS_LIST transport error for connection {}: {e}",
|
||||
conn.id
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn extract_upcoming_meetings(
|
||||
data: &serde_json::Value,
|
||||
start_window: DateTime<Utc>,
|
||||
end_window: DateTime<Utc>,
|
||||
join_policy: &str,
|
||||
seen_ids: &mut HashSet<String>,
|
||||
) -> Vec<UpcomingMeeting> {
|
||||
let mut out = Vec::new();
|
||||
collect_recursive(
|
||||
data,
|
||||
start_window,
|
||||
end_window,
|
||||
join_policy,
|
||||
seen_ids,
|
||||
&mut out,
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_recursive(
|
||||
value: &serde_json::Value,
|
||||
start_window: DateTime<Utc>,
|
||||
end_window: DateTime<Utc>,
|
||||
join_policy: &str,
|
||||
seen_ids: &mut HashSet<String>,
|
||||
out: &mut Vec<UpcomingMeeting>,
|
||||
) {
|
||||
match value {
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_recursive(item, start_window, end_window, join_policy, seen_ids, out);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
if let Some(meeting) = try_extract_meeting(map, start_window, end_window, join_policy) {
|
||||
// Global dedup: skip if this event id was already extracted from
|
||||
// a different connection or an earlier part of the same response.
|
||||
if seen_ids.insert(meeting.calendar_event_id.clone()) {
|
||||
out.push(meeting);
|
||||
}
|
||||
}
|
||||
// Recurse into children so we handle Composio envelope shapes
|
||||
// (e.g. { "items": [...] }) without hardcoding the key name.
|
||||
for child in map.values() {
|
||||
collect_recursive(child, start_window, end_window, join_policy, seen_ids, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to interpret a JSON object as a Google Calendar event with a conferencing
|
||||
/// link. Returns `None` if:
|
||||
/// - the object has no `start.dateTime` (all-day events, metadata objects), or
|
||||
/// - the start time is outside `[start_window, end_window]`, or
|
||||
/// - the event has no parseable meeting URL.
|
||||
fn try_extract_meeting(
|
||||
map: &serde_json::Map<String, serde_json::Value>,
|
||||
start_window: DateTime<Utc>,
|
||||
end_window: DateTime<Utc>,
|
||||
join_policy: &str,
|
||||
) -> Option<UpcomingMeeting> {
|
||||
// Only timed events (start.dateTime). All-day events only have start.date.
|
||||
let start_str = datetime_field(map, "start", "dateTime")?;
|
||||
let start_dt = chrono::DateTime::parse_from_rfc3339(start_str).ok()?;
|
||||
let start_utc = start_dt.with_timezone(&Utc);
|
||||
|
||||
// Parse the end time up front (default 1-hour duration when absent) so we
|
||||
// can keep meetings that are *currently in progress*.
|
||||
let end_utc = datetime_field(map, "end", "dateTime")
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc));
|
||||
|
||||
// Window check that INCLUDES in-progress meetings: keep an event when it
|
||||
// hasn't ended yet (end >= now, where `start_window` == now) and it starts
|
||||
// before the window end. A meeting that started 10 min ago and ends in
|
||||
// 20 min must still appear — the previous `start_utc < start_window` check
|
||||
// dropped it.
|
||||
let effective_end = end_utc.unwrap_or(start_utc);
|
||||
if effective_end < start_window || start_utc > end_window {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start_ms = start_utc.timestamp_millis().max(0) as u64;
|
||||
|
||||
let end_ms = end_utc
|
||||
.map(|dt| dt.timestamp_millis().max(0) as u64)
|
||||
.unwrap_or_else(|| start_ms + 3_600_000); // Default 1-hour duration
|
||||
|
||||
// Must have a conferencing URL. This is the filter: events without a
|
||||
// meeting link are calendar items (appointments, reminders, OOO) that the
|
||||
// Upcoming table doesn't need to show.
|
||||
let meet_url = extract_meet_url_from_event(map)?;
|
||||
|
||||
let platform = super::ops::infer_platform_from_url(&meet_url).map(str::to_string);
|
||||
|
||||
let title = map
|
||||
.get("summary")
|
||||
.or_else(|| map.get("title"))
|
||||
.or_else(|| map.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Untitled meeting")
|
||||
.to_string();
|
||||
|
||||
// Stable event id for dedup + per-event policy keying. When the event has no
|
||||
// real id (id/eventId/icalUID all absent), fall back to a STABLE synthetic
|
||||
// key from the meeting URL + start time rather than a shared literal
|
||||
// "unknown" — otherwise every id-less event collapses onto the same dedup
|
||||
// key and only the first survives.
|
||||
let calendar_event_id = super::ops::extract_calendar_event_id(map)
|
||||
.unwrap_or_else(|| format!("{meet_url}@{start_ms}"));
|
||||
|
||||
let participant_count = map
|
||||
.get("attendees")
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| arr.len() as u32);
|
||||
|
||||
let organizer = map
|
||||
.get("organizer")
|
||||
.and_then(|o| {
|
||||
o.get("displayName")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| o.get("email").and_then(|v| v.as_str()))
|
||||
})
|
||||
.map(str::to_string);
|
||||
|
||||
tracing::debug!(
|
||||
calendar_event_id = %calendar_event_id,
|
||||
title = %title,
|
||||
start_ms,
|
||||
platform = ?platform,
|
||||
"[meet:upcoming] extracted meeting with conferencing link"
|
||||
);
|
||||
|
||||
Some(UpcomingMeeting {
|
||||
calendar_event_id,
|
||||
title,
|
||||
start_time_ms: start_ms,
|
||||
end_time_ms: end_ms,
|
||||
meet_url: Some(meet_url),
|
||||
platform,
|
||||
participant_count,
|
||||
organizer,
|
||||
join_policy: join_policy.to_string(),
|
||||
calendar_source: "googlecalendar".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event URL extraction
|
||||
//
|
||||
// The strict URL/host/platform primitives (`is_meeting_url`,
|
||||
// `extract_url_from_text`, `infer_platform_from_url`) live in `super::ops` —
|
||||
// this module just composes them over the Google Calendar event shape.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract the conferencing URL from a Google Calendar event object, checking
|
||||
/// in priority order:
|
||||
///
|
||||
/// 1. `hangoutLink` (Google Meet direct field)
|
||||
/// 2. `conferenceData.entryPoints[].uri`
|
||||
/// 3. `location` field (Zoom/Teams links often pasted here as free-form text)
|
||||
/// 4. `description` field (fallback — some invites embed the link in the body)
|
||||
fn extract_meet_url_from_event(map: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
|
||||
// 1. hangoutLink
|
||||
if let Some(link) = map.get("hangoutLink").and_then(|v| v.as_str()) {
|
||||
if super::ops::is_meeting_url(link) {
|
||||
return Some(link.to_string());
|
||||
}
|
||||
}
|
||||
// 2. conferenceData.entryPoints[].uri
|
||||
if let Some(entries) = map
|
||||
.get("conferenceData")
|
||||
.and_then(|cd| cd.get("entryPoints"))
|
||||
.and_then(|ep| ep.as_array())
|
||||
{
|
||||
for entry in entries {
|
||||
if let Some(uri) = entry.get("uri").and_then(|v| v.as_str()) {
|
||||
if super::ops::is_meeting_url(uri) {
|
||||
return Some(uri.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. location (free-form, e.g. "Zoom Meeting: https://zoom.us/j/123")
|
||||
if let Some(loc) = map.get("location").and_then(|v| v.as_str()) {
|
||||
if let Some(url) = super::ops::extract_url_from_text(loc) {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
// 4. description (last resort)
|
||||
if let Some(desc) = map.get("description").and_then(|v| v.as_str()) {
|
||||
if let Some(url) = super::ops::extract_url_from_text(desc) {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Pull a `start.dateTime` (or `end.dateTime`) string from an event map.
|
||||
fn datetime_field<'a>(
|
||||
map: &'a serde_json::Map<String, serde_json::Value>,
|
||||
outer: &str,
|
||||
inner: &str,
|
||||
) -> Option<&'a str> {
|
||||
map.get(outer)
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|o| o.get(inner))
|
||||
.and_then(|v| v.as_str())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::agent_meetings::ops::infer_platform_from_url;
|
||||
use serde_json::json;
|
||||
|
||||
fn window() -> (DateTime<Utc>, DateTime<Utc>) {
|
||||
let now = Utc::now();
|
||||
let end = now + chrono::Duration::hours(8);
|
||||
(now, end)
|
||||
}
|
||||
|
||||
fn future_event(offset_minutes: i64) -> serde_json::Value {
|
||||
let start = Utc::now() + chrono::Duration::minutes(offset_minutes);
|
||||
let end = start + chrono::Duration::hours(1);
|
||||
json!({
|
||||
"id": format!("event-{offset_minutes}"),
|
||||
"summary": format!("Meeting in {offset_minutes}m"),
|
||||
"start": { "dateTime": start.to_rfc3339() },
|
||||
"end": { "dateTime": end.to_rfc3339() },
|
||||
"hangoutLink": "https://meet.google.com/abc-defg-hij"
|
||||
})
|
||||
}
|
||||
|
||||
// ── event JSON → UpcomingMeeting mapping ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn extracts_meeting_from_event_with_hangout_link() {
|
||||
let (start_window, end_window) = window();
|
||||
let event = future_event(30);
|
||||
let map = event.as_object().unwrap();
|
||||
let meeting = try_extract_meeting(map, start_window, end_window, "ask").unwrap();
|
||||
assert_eq!(meeting.title, "Meeting in 30m");
|
||||
assert_eq!(
|
||||
meeting.meet_url.as_deref(),
|
||||
Some("https://meet.google.com/abc-defg-hij")
|
||||
);
|
||||
assert_eq!(meeting.platform.as_deref(), Some("gmeet"));
|
||||
assert_eq!(meeting.join_policy, "ask");
|
||||
assert_eq!(meeting.calendar_source, "googlecalendar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_meeting_from_conference_data_entry_points() {
|
||||
let (start_window, end_window) = window();
|
||||
let start = Utc::now() + chrono::Duration::minutes(60);
|
||||
let end = start + chrono::Duration::hours(1);
|
||||
let event = json!({
|
||||
"id": "ev-1",
|
||||
"summary": "Zoom call",
|
||||
"start": { "dateTime": start.to_rfc3339() },
|
||||
"end": { "dateTime": end.to_rfc3339() },
|
||||
"conferenceData": {
|
||||
"entryPoints": [
|
||||
{ "entryPointType": "video", "uri": "https://zoom.us/j/123456789" }
|
||||
]
|
||||
}
|
||||
});
|
||||
let map = event.as_object().unwrap();
|
||||
let meeting = try_extract_meeting(map, start_window, end_window, "ask").unwrap();
|
||||
assert_eq!(meeting.platform.as_deref(), Some("zoom"));
|
||||
assert_eq!(
|
||||
meeting.meet_url.as_deref(),
|
||||
Some("https://zoom.us/j/123456789")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_event_with_no_conferencing_link() {
|
||||
let (start_window, end_window) = window();
|
||||
let start = Utc::now() + chrono::Duration::minutes(60);
|
||||
let end = start + chrono::Duration::hours(1);
|
||||
let event = json!({
|
||||
"id": "ev-noop",
|
||||
"summary": "Lunch",
|
||||
"start": { "dateTime": start.to_rfc3339() },
|
||||
"end": { "dateTime": end.to_rfc3339() },
|
||||
"location": "Office kitchen"
|
||||
});
|
||||
let map = event.as_object().unwrap();
|
||||
assert!(try_extract_meeting(map, start_window, end_window, "ask").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_all_day_event_without_datetime() {
|
||||
let (start_window, end_window) = window();
|
||||
let event = json!({
|
||||
"id": "ev-allday",
|
||||
"summary": "Holiday",
|
||||
"start": { "date": "2026-06-30" },
|
||||
"end": { "date": "2026-07-01" },
|
||||
"hangoutLink": "https://meet.google.com/xyz"
|
||||
});
|
||||
let map = event.as_object().unwrap();
|
||||
assert!(try_extract_meeting(map, start_window, end_window, "ask").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_event_outside_window() {
|
||||
let (start_window, end_window) = window();
|
||||
let far_future = Utc::now() + chrono::Duration::hours(24);
|
||||
let end = far_future + chrono::Duration::hours(1);
|
||||
let event = json!({
|
||||
"id": "ev-far",
|
||||
"summary": "Future meeting",
|
||||
"start": { "dateTime": far_future.to_rfc3339() },
|
||||
"end": { "dateTime": end.to_rfc3339() },
|
||||
"hangoutLink": "https://meet.google.com/abc"
|
||||
});
|
||||
let map = event.as_object().unwrap();
|
||||
assert!(try_extract_meeting(map, start_window, end_window, "ask").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_size_always_uses_api_max() {
|
||||
// page_size must always return 100 (the API maximum) so that
|
||||
// link-bearing events that fall past the first `limit` raw calendar
|
||||
// entries (buried behind reminders, OOO blocks, etc.) survive the
|
||||
// conferencing-link filter. The actual limit is applied via truncate()
|
||||
// in fetch_upcoming_meetings after filtering.
|
||||
assert_eq!(page_size(1), 100);
|
||||
assert_eq!(page_size(5), 100);
|
||||
assert_eq!(page_size(20), 100);
|
||||
assert_eq!(page_size(100), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_event_past_first_n_raw_entries_is_returned() {
|
||||
// Core guarantee of finding #1: a link-bearing event sitting behind
|
||||
// 3 link-free events must still be collected even when limit=1.
|
||||
// Before the fix, page_size(1)=1 meant only the first raw entry was
|
||||
// fetched and the link event was never seen.
|
||||
let (start_window, end_window) = window();
|
||||
let no_link = |offset: i64, id: &str| {
|
||||
let start = Utc::now() + chrono::Duration::minutes(offset);
|
||||
let end = start + chrono::Duration::hours(1);
|
||||
json!({
|
||||
"id": id,
|
||||
"summary": format!("No link {offset}m"),
|
||||
"start": { "dateTime": start.to_rfc3339() },
|
||||
"end": { "dateTime": end.to_rfc3339() },
|
||||
"location": "Office"
|
||||
})
|
||||
};
|
||||
let link_start = Utc::now() + chrono::Duration::minutes(45);
|
||||
let link_end = link_start + chrono::Duration::hours(1);
|
||||
let link_event = json!({
|
||||
"id": "link-ev-1",
|
||||
"summary": "Zoom sync",
|
||||
"start": { "dateTime": link_start.to_rfc3339() },
|
||||
"end": { "dateTime": link_end.to_rfc3339() },
|
||||
"hangoutLink": "https://meet.google.com/abc-defg-hij"
|
||||
});
|
||||
// 3 link-free events, then 1 link event (position 4 in a raw page).
|
||||
let events = json!([
|
||||
no_link(10, "nl-1"),
|
||||
no_link(20, "nl-2"),
|
||||
no_link(30, "nl-3"),
|
||||
link_event
|
||||
]);
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
collect_recursive(
|
||||
&events,
|
||||
start_window,
|
||||
end_window,
|
||||
"ask",
|
||||
&mut seen,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(out.len(), 1, "link event at position 4 must survive filter");
|
||||
assert_eq!(out[0].calendar_event_id, "link-ev-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_upcoming_returns_empty_when_no_composio_config() {
|
||||
// When no Composio API key or backend URL is configured,
|
||||
// create_composio_client fails gracefully → fetch_upcoming_meetings
|
||||
// returns Ok([]) (no calendar = empty, not an error).
|
||||
let config = crate::openhuman::config::Config::default();
|
||||
let result = fetch_upcoming_meetings(&config, 60, 10, "ask").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"missing Composio config must not be an error: {result:?}"
|
||||
);
|
||||
assert!(result.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_in_progress_meeting() {
|
||||
// Started 10 min ago, ends in 20 min. `start_window` == now, so the old
|
||||
// `start_utc < start_window` rule dropped this; it must now be kept.
|
||||
let (start_window, end_window) = window();
|
||||
let start = Utc::now() - chrono::Duration::minutes(10);
|
||||
let end = Utc::now() + chrono::Duration::minutes(20);
|
||||
let event = json!({
|
||||
"id": "ev-inprogress",
|
||||
"summary": "Already running",
|
||||
"start": { "dateTime": start.to_rfc3339() },
|
||||
"end": { "dateTime": end.to_rfc3339() },
|
||||
"hangoutLink": "https://meet.google.com/in-progress"
|
||||
});
|
||||
let map = event.as_object().unwrap();
|
||||
let meeting = try_extract_meeting(map, start_window, end_window, "ask").unwrap();
|
||||
assert_eq!(meeting.title, "Already running");
|
||||
assert_eq!(meeting.calendar_event_id, "ev-inprogress");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_already_ended_meeting() {
|
||||
// Started 2h ago, ended 1h ago — must be dropped.
|
||||
let (start_window, end_window) = window();
|
||||
let start = Utc::now() - chrono::Duration::hours(2);
|
||||
let end = Utc::now() - chrono::Duration::hours(1);
|
||||
let event = json!({
|
||||
"id": "ev-ended",
|
||||
"summary": "Done",
|
||||
"start": { "dateTime": start.to_rfc3339() },
|
||||
"end": { "dateTime": end.to_rfc3339() },
|
||||
"hangoutLink": "https://meet.google.com/ended"
|
||||
});
|
||||
let map = event.as_object().unwrap();
|
||||
assert!(try_extract_meeting(map, start_window, end_window, "ask").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_less_events_stay_distinct() {
|
||||
// Two events with NO id/eventId/icalUID but different URLs + start times
|
||||
// must each get a distinct synthetic key and both survive dedup — the
|
||||
// old literal "unknown" fallback collapsed them to one.
|
||||
let (start_window, end_window) = window();
|
||||
let s1 = Utc::now() + chrono::Duration::minutes(15);
|
||||
let s2 = Utc::now() + chrono::Duration::minutes(45);
|
||||
let ev1 = json!({
|
||||
"summary": "Anon A",
|
||||
"start": { "dateTime": s1.to_rfc3339() },
|
||||
"end": { "dateTime": (s1 + chrono::Duration::hours(1)).to_rfc3339() },
|
||||
"hangoutLink": "https://meet.google.com/anon-a"
|
||||
});
|
||||
let ev2 = json!({
|
||||
"summary": "Anon B",
|
||||
"start": { "dateTime": s2.to_rfc3339() },
|
||||
"end": { "dateTime": (s2 + chrono::Duration::hours(1)).to_rfc3339() },
|
||||
"hangoutLink": "https://meet.google.com/anon-b"
|
||||
});
|
||||
let events = json!([ev1, ev2]);
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
collect_recursive(
|
||||
&events,
|
||||
start_window,
|
||||
end_window,
|
||||
"ask",
|
||||
&mut seen,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(out.len(), 2, "id-less events should not collapse");
|
||||
// Synthetic keys are URL@startMs and must differ.
|
||||
assert_ne!(out[0].calendar_event_id, out[1].calendar_event_id);
|
||||
assert!(out[0].calendar_event_id.contains('@'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_less_duplicate_event_still_dedups() {
|
||||
// The SAME id-less event appearing twice must still collapse to one
|
||||
// (stable synthetic key on URL + start time).
|
||||
let (start_window, end_window) = window();
|
||||
let s = Utc::now() + chrono::Duration::minutes(20);
|
||||
let ev = json!({
|
||||
"summary": "Anon dup",
|
||||
"start": { "dateTime": s.to_rfc3339() },
|
||||
"end": { "dateTime": (s + chrono::Duration::hours(1)).to_rfc3339() },
|
||||
"hangoutLink": "https://meet.google.com/anon-dup"
|
||||
});
|
||||
let events = json!([ev, ev]);
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
collect_recursive(
|
||||
&events,
|
||||
start_window,
|
||||
end_window,
|
||||
"ask",
|
||||
&mut seen,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
// ── platform inference ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn infers_platform_from_gmeet() {
|
||||
assert_eq!(
|
||||
infer_platform_from_url("https://meet.google.com/abc-defg-hij"),
|
||||
Some("gmeet")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_platform_from_zoom() {
|
||||
assert_eq!(
|
||||
infer_platform_from_url("https://zoom.us/j/123456789"),
|
||||
Some("zoom")
|
||||
);
|
||||
assert_eq!(
|
||||
infer_platform_from_url("https://company.zoom.us/j/123"),
|
||||
Some("zoom")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_platform_from_teams() {
|
||||
assert_eq!(
|
||||
infer_platform_from_url("https://teams.microsoft.com/l/meetup-join/abc"),
|
||||
Some("teams")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_platform_from_webex() {
|
||||
assert_eq!(
|
||||
infer_platform_from_url("https://meet.webex.com/meet/abc"),
|
||||
Some("webex")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_platform_none_for_unknown_url() {
|
||||
assert!(infer_platform_from_url("https://example.com/meeting").is_none());
|
||||
}
|
||||
|
||||
// ── location field extraction ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extracts_zoom_from_location_field() {
|
||||
let (start_window, end_window) = window();
|
||||
let start = Utc::now() + chrono::Duration::minutes(30);
|
||||
let end = start + chrono::Duration::hours(1);
|
||||
let event = json!({
|
||||
"id": "ev-zoom",
|
||||
"summary": "Zoom sync",
|
||||
"start": { "dateTime": start.to_rfc3339() },
|
||||
"end": { "dateTime": end.to_rfc3339() },
|
||||
"location": "Zoom Meeting: https://zoom.us/j/987654321"
|
||||
});
|
||||
let map = event.as_object().unwrap();
|
||||
let meeting = try_extract_meeting(map, start_window, end_window, "ask").unwrap();
|
||||
assert_eq!(
|
||||
meeting.meet_url.as_deref(),
|
||||
Some("https://zoom.us/j/987654321")
|
||||
);
|
||||
assert_eq!(meeting.platform.as_deref(), Some("zoom"));
|
||||
}
|
||||
|
||||
// ── lookahead / limit / sort ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_respects_window_and_sorts_by_start() {
|
||||
let (start_window, end_window) = window();
|
||||
let events = json!([future_event(120), future_event(30), future_event(60),]);
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
collect_recursive(
|
||||
&events,
|
||||
start_window,
|
||||
end_window,
|
||||
"ask",
|
||||
&mut seen,
|
||||
&mut out,
|
||||
);
|
||||
// All three are within the 8-hour window.
|
||||
assert_eq!(out.len(), 3);
|
||||
// They should NOT be sorted here (sorting is done in fetch_upcoming_meetings),
|
||||
// but IDs should match what we inserted.
|
||||
let ids: Vec<_> = out.iter().map(|m| m.calendar_event_id.as_str()).collect();
|
||||
// All ids should be present (order is input order for unsorted collection).
|
||||
for id in ["event-120", "event-30", "event-60"] {
|
||||
assert!(ids.contains(&id), "missing id: {id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_events_with_same_id() {
|
||||
let (start_window, end_window) = window();
|
||||
let event = future_event(30);
|
||||
let events = json!([event, event]);
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
collect_recursive(
|
||||
&events,
|
||||
start_window,
|
||||
end_window,
|
||||
"ask",
|
||||
&mut seen,
|
||||
&mut out,
|
||||
);
|
||||
// Same id should only appear once.
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
// ── attendee count and organizer ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extracts_attendee_count_and_organizer() {
|
||||
let (start_window, end_window) = window();
|
||||
let start = Utc::now() + chrono::Duration::minutes(30);
|
||||
let end = start + chrono::Duration::hours(1);
|
||||
let event = json!({
|
||||
"id": "ev-people",
|
||||
"summary": "Team standup",
|
||||
"start": { "dateTime": start.to_rfc3339() },
|
||||
"end": { "dateTime": end.to_rfc3339() },
|
||||
"hangoutLink": "https://meet.google.com/xyz-abcd",
|
||||
"attendees": [
|
||||
{ "email": "alice@x.com" },
|
||||
{ "email": "bob@x.com" },
|
||||
{ "email": "carol@x.com" }
|
||||
],
|
||||
"organizer": { "email": "alice@x.com", "displayName": "Alice" }
|
||||
});
|
||||
let map = event.as_object().unwrap();
|
||||
let meeting = try_extract_meeting(map, start_window, end_window, "ask").unwrap();
|
||||
assert_eq!(meeting.participant_count, Some(3));
|
||||
assert_eq!(meeting.organizer.as_deref(), Some("Alice"));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! UI-facing config operations: browser, screen intelligence, analytics, meet,
|
||||
//! search, dictation, voice server, onboarding flags.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::openhuman::config::{AutoJoinPolicy, AutoSummarizePolicy, Config};
|
||||
@@ -45,6 +47,12 @@ pub struct MeetSettingsPatch {
|
||||
pub listen_only_default: Option<bool>,
|
||||
/// When `true`, backend-bot transcripts are ingested into memory.
|
||||
pub ingest_backend_transcripts: Option<bool>,
|
||||
/// Per-platform auto-join policy overrides. Replaces the stored map wholesale
|
||||
/// when present. Keys: "gmeet", "zoom", "teams", "webex".
|
||||
pub platform_auto_join_policies: Option<HashMap<String, AutoJoinPolicy>>,
|
||||
/// Master switch for calendar-driven meeting actions (auto-join / ask-to-join).
|
||||
/// Decoupled from `heartbeat.notify_meetings` (plain reminder cards).
|
||||
pub watch_calendar: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -254,6 +262,12 @@ pub async fn apply_meet_settings(
|
||||
if let Some(ingest) = update.ingest_backend_transcripts {
|
||||
config.meet.ingest_backend_transcripts = ingest;
|
||||
}
|
||||
if let Some(policies) = update.platform_auto_join_policies {
|
||||
config.meet.platform_auto_join_policies = policies;
|
||||
}
|
||||
if let Some(watch_calendar) = update.watch_calendar {
|
||||
config.meet.watch_calendar = watch_calendar;
|
||||
}
|
||||
config.save().await.map_err(|e| e.to_string())?;
|
||||
let snapshot = snapshot_config_json(config)?;
|
||||
Ok(RpcOutcome::new(
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//!
|
||||
//! See epic tinyhumansai/openhuman#3505.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -84,6 +86,22 @@ pub struct MeetConfig {
|
||||
/// reply. Set `false` to fall back to a single buffered `bot:speak`.
|
||||
#[serde(default = "default_in_call_streaming")]
|
||||
pub in_call_streaming: bool,
|
||||
|
||||
/// Per-platform auto-join policy overrides.
|
||||
/// Keys are platform slugs: "gmeet", "zoom", "teams", "webex".
|
||||
/// Falls back to `auto_join_policy` when not set for a platform.
|
||||
#[serde(default)]
|
||||
pub platform_auto_join_policies: HashMap<String, AutoJoinPolicy>,
|
||||
|
||||
/// Master switch for calendar-driven meeting actions. When `true`, the
|
||||
/// heartbeat planner polls the connected calendar so `auto_join_policy`
|
||||
/// (plus per-event / per-platform overrides) can auto-join or prompt for
|
||||
/// meetings. Decoupled from `heartbeat.notify_meetings`, which controls
|
||||
/// only the plain reminder notifications — so a user can have OpenHuman
|
||||
/// join meetings without opting into reminder cards (and vice versa).
|
||||
/// Off by default.
|
||||
#[serde(default)]
|
||||
pub watch_calendar: bool,
|
||||
}
|
||||
|
||||
fn default_auto_orchestrator_handoff() -> bool {
|
||||
@@ -116,6 +134,8 @@ impl Default for MeetConfig {
|
||||
listen_only_default: true,
|
||||
enable_in_call_agency: false,
|
||||
in_call_streaming: true,
|
||||
platform_auto_join_policies: HashMap::new(),
|
||||
watch_calendar: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +224,8 @@ mod tests {
|
||||
listen_only_default: false,
|
||||
enable_in_call_agency: true,
|
||||
in_call_streaming: false,
|
||||
platform_auto_join_policies: HashMap::new(),
|
||||
watch_calendar: true,
|
||||
};
|
||||
let s = serde_json::to_string(&original).unwrap();
|
||||
let back: MeetConfig = serde_json::from_str(&s).unwrap();
|
||||
@@ -213,5 +235,57 @@ mod tests {
|
||||
assert_eq!(back.auto_summarize_policy, AutoSummarizePolicy::Always);
|
||||
assert!(!back.listen_only_default);
|
||||
assert!(back.enable_in_call_agency);
|
||||
assert!(back.watch_calendar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_calendar_defaults_to_false() {
|
||||
let cfg = MeetConfig::default();
|
||||
assert!(!cfg.watch_calendar);
|
||||
// A config that predates the field also defaults it off.
|
||||
let parsed: MeetConfig = serde_json::from_value(json!({})).unwrap();
|
||||
assert!(!parsed.watch_calendar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_calendar_round_trips_via_json() {
|
||||
// off → serialise → deserialise
|
||||
let off = MeetConfig {
|
||||
watch_calendar: false,
|
||||
..MeetConfig::default()
|
||||
};
|
||||
let s_off = serde_json::to_string(&off).unwrap();
|
||||
let back_off: MeetConfig = serde_json::from_str(&s_off).unwrap();
|
||||
assert!(!back_off.watch_calendar);
|
||||
|
||||
// on → serialise → deserialise
|
||||
let on = MeetConfig {
|
||||
watch_calendar: true,
|
||||
..MeetConfig::default()
|
||||
};
|
||||
let s_on = serde_json::to_string(&on).unwrap();
|
||||
let back_on: MeetConfig = serde_json::from_str(&s_on).unwrap();
|
||||
assert!(back_on.watch_calendar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_auto_join_policies_defaults_to_empty() {
|
||||
let config = MeetConfig::default();
|
||||
assert!(config.platform_auto_join_policies.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_with_platform_policies() {
|
||||
let json =
|
||||
r#"{"platform_auto_join_policies": {"zoom": "always", "gmeet": "ask_each_time"}}"#;
|
||||
let config: MeetConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(
|
||||
config.platform_auto_join_policies.get("zoom"),
|
||||
Some(&AutoJoinPolicy::Always)
|
||||
);
|
||||
assert_eq!(
|
||||
config.platform_auto_join_policies.get("gmeet"),
|
||||
Some(&AutoJoinPolicy::AskEachTime)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,6 +609,44 @@ fn handle_get_dashboard_settings(_params: Map<String, Value>) -> ControllerFutur
|
||||
Box::pin(async { to_json(config_rpc::get_dashboard_settings().await?) })
|
||||
}
|
||||
|
||||
/// Known platform slugs for per-platform auto-join policies.
|
||||
const KNOWN_PLATFORM_SLUGS: &[&str] = &["gmeet", "zoom", "teams", "webex"];
|
||||
|
||||
/// Parse and validate a raw `platform_auto_join_policies` map.
|
||||
///
|
||||
/// Rejects any unknown platform slug (not in `KNOWN_PLATFORM_SLUGS`) and any
|
||||
/// unknown policy value, returning a descriptive error. This keeps the config
|
||||
/// table free of unmappable entries that would silently persist.
|
||||
fn parse_platform_auto_join_policies(
|
||||
raw_map: std::collections::HashMap<String, String>,
|
||||
) -> Result<std::collections::HashMap<String, AutoJoinPolicy>, String> {
|
||||
let mut parsed = std::collections::HashMap::new();
|
||||
for (platform, policy_str) in raw_map {
|
||||
if !KNOWN_PLATFORM_SLUGS.contains(&platform.as_str()) {
|
||||
log::warn!("[config][rpc] update_meet_settings unknown platform slug: {platform}");
|
||||
return Err(format!(
|
||||
"unknown platform slug: {platform} (valid: {})",
|
||||
KNOWN_PLATFORM_SLUGS.join(", ")
|
||||
));
|
||||
}
|
||||
let policy = match policy_str.as_str() {
|
||||
"ask_each_time" => AutoJoinPolicy::AskEachTime,
|
||||
"always" => AutoJoinPolicy::Always,
|
||||
"never" => AutoJoinPolicy::Never,
|
||||
other => {
|
||||
log::warn!(
|
||||
"[config][rpc] update_meet_settings invalid platform policy {platform}={other}"
|
||||
);
|
||||
return Err(format!(
|
||||
"invalid policy for platform {platform}: {other} (valid: ask_each_time, always, never)"
|
||||
));
|
||||
}
|
||||
};
|
||||
parsed.insert(platform, policy);
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn handle_update_meet_settings(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
log::debug!("[config][rpc] update_meet_settings enter");
|
||||
@@ -645,13 +683,23 @@ fn handle_update_meet_settings(params: Map<String, Value>) -> ControllerFuture {
|
||||
));
|
||||
}
|
||||
};
|
||||
// Parse and validate platform_auto_join_policies: rejects unknown platform
|
||||
// slugs and invalid policy values before touching config.
|
||||
let platform_auto_join_policies = if let Some(raw_map) = update.platform_auto_join_policies
|
||||
{
|
||||
Some(parse_platform_auto_join_policies(raw_map)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
log::debug!(
|
||||
"[config][rpc] update_meet_settings patch auto_orchestrator_handoff={:?} auto_join_policy={:?} auto_summarize_policy={:?} listen_only_default={:?} ingest_backend_transcripts={:?}",
|
||||
"[config][rpc] update_meet_settings patch auto_orchestrator_handoff={:?} auto_join_policy={:?} auto_summarize_policy={:?} listen_only_default={:?} ingest_backend_transcripts={:?} platform_auto_join_policies={:?} watch_calendar={:?}",
|
||||
update.auto_orchestrator_handoff,
|
||||
auto_join_policy,
|
||||
auto_summarize_policy,
|
||||
update.listen_only_default,
|
||||
update.ingest_backend_transcripts
|
||||
update.ingest_backend_transcripts,
|
||||
platform_auto_join_policies.as_ref().map(|m| m.len()),
|
||||
update.watch_calendar,
|
||||
);
|
||||
let patch = config_rpc::MeetSettingsPatch {
|
||||
auto_orchestrator_handoff: update.auto_orchestrator_handoff,
|
||||
@@ -659,6 +707,8 @@ fn handle_update_meet_settings(params: Map<String, Value>) -> ControllerFuture {
|
||||
auto_summarize_policy,
|
||||
listen_only_default: update.listen_only_default,
|
||||
ingest_backend_transcripts: update.ingest_backend_transcripts,
|
||||
platform_auto_join_policies,
|
||||
watch_calendar: update.watch_calendar,
|
||||
};
|
||||
match config_rpc::load_and_apply_meet_settings(patch).await {
|
||||
Ok(outcome) => {
|
||||
@@ -686,11 +736,12 @@ fn handle_get_meet_settings(_params: Map<String, Value>) -> ControllerFuture {
|
||||
};
|
||||
let auto_orchestrator_handoff = config.meet.auto_orchestrator_handoff;
|
||||
log::debug!(
|
||||
"[config][rpc] get_meet_settings ok auto_orchestrator_handoff={auto_orchestrator_handoff} auto_join_policy={:?} auto_summarize_policy={:?} listen_only_default={} ingest_backend_transcripts={}",
|
||||
"[config][rpc] get_meet_settings ok auto_orchestrator_handoff={auto_orchestrator_handoff} auto_join_policy={:?} auto_summarize_policy={:?} listen_only_default={} ingest_backend_transcripts={} watch_calendar={}",
|
||||
config.meet.auto_join_policy,
|
||||
config.meet.auto_summarize_policy,
|
||||
config.meet.listen_only_default,
|
||||
config.meet.ingest_backend_transcripts
|
||||
config.meet.ingest_backend_transcripts,
|
||||
config.meet.watch_calendar,
|
||||
);
|
||||
// Enums serialize via `#[serde(rename_all = "snake_case")]` →
|
||||
// "ask_each_time"/"always"/"never" and "ask"/"always"/"never".
|
||||
@@ -700,6 +751,8 @@ fn handle_get_meet_settings(_params: Map<String, Value>) -> ControllerFuture {
|
||||
"auto_summarize_policy": config.meet.auto_summarize_policy,
|
||||
"listen_only_default": config.meet.listen_only_default,
|
||||
"ingest_backend_transcripts": config.meet.ingest_backend_transcripts,
|
||||
"platform_auto_join_policies": config.meet.platform_auto_join_policies,
|
||||
"watch_calendar": config.meet.watch_calendar,
|
||||
});
|
||||
to_json(RpcOutcome::new(
|
||||
result,
|
||||
@@ -992,3 +1045,74 @@ fn handle_update_sandbox_settings(params: Map<String, Value>) -> ControllerFutur
|
||||
to_json(config_rpc::load_and_apply_sandbox_settings(patch).await?)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── platform slug validation (finding #6) ───────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_platform_policies_accepts_all_known_slugs() {
|
||||
use std::collections::HashMap;
|
||||
let mut raw = HashMap::new();
|
||||
raw.insert("gmeet".to_string(), "always".to_string());
|
||||
raw.insert("zoom".to_string(), "ask_each_time".to_string());
|
||||
raw.insert("teams".to_string(), "never".to_string());
|
||||
raw.insert("webex".to_string(), "always".to_string());
|
||||
let result = parse_platform_auto_join_policies(raw).unwrap();
|
||||
assert_eq!(result.len(), 4);
|
||||
assert!(matches!(result["gmeet"], AutoJoinPolicy::Always));
|
||||
assert!(matches!(result["zoom"], AutoJoinPolicy::AskEachTime));
|
||||
assert!(matches!(result["teams"], AutoJoinPolicy::Never));
|
||||
assert!(matches!(result["webex"], AutoJoinPolicy::Always));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_platform_policies_rejects_unknown_slug() {
|
||||
use std::collections::HashMap;
|
||||
let mut raw = HashMap::new();
|
||||
raw.insert("discord".to_string(), "always".to_string());
|
||||
let err = parse_platform_auto_join_policies(raw).unwrap_err();
|
||||
assert!(
|
||||
err.contains("discord"),
|
||||
"error must identify the unknown slug: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.contains("gmeet") || err.contains("valid"),
|
||||
"error must hint at valid slugs: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_platform_policies_rejects_non_meeting_platforms() {
|
||||
use std::collections::HashMap;
|
||||
for bad in &["slack", "meet", "google", "microsoft", "jitsi", ""] {
|
||||
let mut raw = HashMap::new();
|
||||
raw.insert(bad.to_string(), "always".to_string());
|
||||
assert!(
|
||||
parse_platform_auto_join_policies(raw).is_err(),
|
||||
"slug {bad:?} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_platform_policies_rejects_invalid_policy_value() {
|
||||
use std::collections::HashMap;
|
||||
let mut raw = HashMap::new();
|
||||
raw.insert("zoom".to_string(), "sometimes".to_string());
|
||||
let err = parse_platform_auto_join_policies(raw).unwrap_err();
|
||||
assert!(
|
||||
err.contains("sometimes") || err.contains("invalid"),
|
||||
"error must identify the bad policy: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_platform_policies_empty_map_is_ok() {
|
||||
use std::collections::HashMap;
|
||||
let result = parse_platform_auto_join_policies(HashMap::new()).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,11 @@ pub(super) struct MeetSettingsUpdate {
|
||||
pub(super) auto_summarize_policy: Option<String>,
|
||||
pub(super) listen_only_default: Option<bool>,
|
||||
pub(super) ingest_backend_transcripts: Option<bool>,
|
||||
/// Per-platform policy overrides. Keys: "gmeet", "zoom", "teams", "webex".
|
||||
/// Values: `ask_each_time` | `always` | `never`.
|
||||
pub(super) platform_auto_join_policies: Option<std::collections::HashMap<String, String>>,
|
||||
/// Master switch for calendar-driven auto-join / ask-to-join.
|
||||
pub(super) watch_calendar: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -454,6 +454,16 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
"ingest_backend_transcripts",
|
||||
"When true, backend-bot meeting transcripts are ingested into memory.",
|
||||
),
|
||||
FieldSchema {
|
||||
name: "platform_auto_join_policies",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
|
||||
comment: "Per-platform auto-join overrides: { gmeet|zoom|teams|webex: ask_each_time | always | never }.",
|
||||
required: false,
|
||||
},
|
||||
optional_bool(
|
||||
"watch_calendar",
|
||||
"When true, the heartbeat watches the connected calendar to drive auto-join / ask-to-join, independent of meeting reminder notifications.",
|
||||
),
|
||||
],
|
||||
outputs: vec![json_output("snapshot", "Updated config snapshot.")],
|
||||
},
|
||||
@@ -493,6 +503,18 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
comment: "Whether backend-bot transcripts are ingested into memory.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "platform_auto_join_policies",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Per-platform auto-join overrides keyed by platform slug.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "watch_calendar",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Whether the heartbeat watches the calendar to drive auto-join / ask.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
"update_search_settings" => ControllerSchema {
|
||||
|
||||
@@ -153,7 +153,8 @@ impl HeartbeatEngine {
|
||||
|
||||
if !(config.heartbeat.notify_meetings
|
||||
|| config.heartbeat.notify_reminders
|
||||
|| config.heartbeat.notify_relevant_events)
|
||||
|| config.heartbeat.notify_relevant_events
|
||||
|| config.meet.watch_calendar)
|
||||
{
|
||||
tracing::debug!("[heartbeat] planner skipped: notification categories disabled");
|
||||
return;
|
||||
|
||||
@@ -44,7 +44,8 @@ pub async fn evaluate_and_dispatch(config: &Config, now: DateTime<Utc>) -> Plann
|
||||
|
||||
if !(config.heartbeat.notify_meetings
|
||||
|| config.heartbeat.notify_reminders
|
||||
|| config.heartbeat.notify_relevant_events)
|
||||
|| config.heartbeat.notify_relevant_events
|
||||
|| config.meet.watch_calendar)
|
||||
{
|
||||
tracing::debug!("[heartbeat:planner] all categories disabled; skipping tick");
|
||||
return summary;
|
||||
@@ -56,7 +57,10 @@ pub async fn evaluate_and_dispatch(config: &Config, now: DateTime<Utc>) -> Plann
|
||||
events.extend(collect_cron_reminders(config, now));
|
||||
}
|
||||
|
||||
if config.heartbeat.notify_meetings {
|
||||
// Calendar collection drives BOTH meeting reminder cards (notify_meetings)
|
||||
// and calendar auto-join / ask prompts (meet.watch_calendar). Poll when
|
||||
// either is on; the per-event handling below decides what to surface.
|
||||
if config.heartbeat.notify_meetings || config.meet.watch_calendar {
|
||||
events.extend(collect_calendar_meetings(config, now).await);
|
||||
}
|
||||
|
||||
@@ -161,6 +165,9 @@ pub async fn evaluate_and_dispatch(config: &Config, now: DateTime<Utc>) -> Plann
|
||||
// candidate handler resolves the reply anchor from the
|
||||
// signed-in account identity.
|
||||
None,
|
||||
// Pass the stable source event id so the per-event policy
|
||||
// tier can apply overrides set via meet_set_event_policy.
|
||||
Some(event.source_event_id.clone()),
|
||||
)
|
||||
.await;
|
||||
if owns_notification {
|
||||
@@ -185,6 +192,20 @@ pub async fn evaluate_and_dispatch(config: &Config, now: DateTime<Utc>) -> Plann
|
||||
}
|
||||
}
|
||||
|
||||
// Watch-calendar-only mode: the calendar was polled solely to drive
|
||||
// auto-join / ask (handled above). The user has NOT enabled meeting
|
||||
// reminders, so don't emit the plain reminder card for meetings.
|
||||
if event.category == types::HeartbeatCategory::Meetings && !config.heartbeat.notify_meetings
|
||||
{
|
||||
tracing::debug!(
|
||||
source = %event.source,
|
||||
source_event_id = %event.source_event_id,
|
||||
"[heartbeat:planner] watch_calendar-only; suppressing plain meeting reminder card"
|
||||
);
|
||||
summary.deliveries_sent += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
publish_core_notification(CoreNotificationEvent {
|
||||
id,
|
||||
category: event.category.notification_category(),
|
||||
|
||||
@@ -13612,3 +13612,184 @@ async fn json_rpc_threads_token_usage_reads_persisted_thread_totals() {
|
||||
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
/// `openhuman.meet_list_upcoming` — verifies the RPC is registered, accepts
|
||||
/// optional params, and returns the correct envelope shape.
|
||||
///
|
||||
/// In the test environment there is no backend session token, so
|
||||
/// `create_composio_client` fails gracefully and the handler returns an
|
||||
/// empty meetings list (ok=true, meetings=[]) rather than an error.
|
||||
/// This validates the graceful no-calendar path without requiring a live
|
||||
/// Composio connection.
|
||||
#[tokio::test]
|
||||
async fn json_rpc_meet_list_upcoming_returns_empty_when_no_calendar_connected() {
|
||||
let _env_lock = json_rpc_e2e_env_lock();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let home = tmp.path();
|
||||
let openhuman_home = home.join(".openhuman");
|
||||
|
||||
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
|
||||
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
|
||||
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
|
||||
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
|
||||
|
||||
write_min_config(&openhuman_home, "http://127.0.0.1:9");
|
||||
|
||||
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
||||
let rpc_base = format!("http://{rpc_addr}");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
// --- no params: defaults apply, returns ok=true with empty meetings ---
|
||||
let resp_default =
|
||||
post_json_rpc(&rpc_base, 9200, "openhuman.meet_list_upcoming", json!({})).await;
|
||||
let result = assert_no_jsonrpc_error(&resp_default, "meet_list_upcoming no-params");
|
||||
let body = result.get("result").unwrap_or(result);
|
||||
assert_eq!(
|
||||
body.get("ok"),
|
||||
Some(&json!(true)),
|
||||
"ok must be true when no calendar connected"
|
||||
);
|
||||
let meetings = body
|
||||
.get("meetings")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("meetings array must be present");
|
||||
assert!(
|
||||
meetings.is_empty(),
|
||||
"meetings must be empty when no Composio client available"
|
||||
);
|
||||
|
||||
// --- explicit lookahead_minutes + limit: still returns ok=true with empty ---
|
||||
let resp_explicit = post_json_rpc(
|
||||
&rpc_base,
|
||||
9201,
|
||||
"openhuman.meet_list_upcoming",
|
||||
json!({ "lookahead_minutes": 120, "limit": 5 }),
|
||||
)
|
||||
.await;
|
||||
let result2 = assert_no_jsonrpc_error(&resp_explicit, "meet_list_upcoming explicit params");
|
||||
let body2 = result2.get("result").unwrap_or(result2);
|
||||
assert_eq!(body2.get("ok"), Some(&json!(true)));
|
||||
assert!(body2
|
||||
.get("meetings")
|
||||
.and_then(|v| v.as_array())
|
||||
.is_some_and(|arr| arr.is_empty()));
|
||||
|
||||
// --- invalid param type: lookahead_minutes must be a number ---
|
||||
let resp_bad = post_json_rpc(
|
||||
&rpc_base,
|
||||
9202,
|
||||
"openhuman.meet_list_upcoming",
|
||||
json!({ "lookahead_minutes": "not-a-number" }),
|
||||
)
|
||||
.await;
|
||||
assert_jsonrpc_error(&resp_bad, "meet_list_upcoming bad lookahead type");
|
||||
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
/// `openhuman.meet_set_event_policy` / `openhuman.meet_get_event_policies` —
|
||||
/// verifies the per-event policy RPC round-trip.
|
||||
///
|
||||
/// Uses an in-process HTTP server so the store hits a real (temp) SQLite DB.
|
||||
#[tokio::test]
|
||||
async fn json_rpc_meet_event_policy_round_trip() {
|
||||
let _env_lock = json_rpc_e2e_env_lock();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let home = tmp.path();
|
||||
let openhuman_home = home.join(".openhuman");
|
||||
|
||||
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
|
||||
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
|
||||
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
|
||||
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
|
||||
|
||||
write_min_config(&openhuman_home, "http://127.0.0.1:9");
|
||||
|
||||
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
||||
let rpc_base = format!("http://{rpc_addr}");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
// --- set a policy for two events ---
|
||||
let resp_set1 = post_json_rpc(
|
||||
&rpc_base,
|
||||
9300,
|
||||
"openhuman.meet_set_event_policy",
|
||||
json!({ "calendar_event_id": "cal-evt-001", "policy": "auto" }),
|
||||
)
|
||||
.await;
|
||||
let result1 = assert_no_jsonrpc_error(&resp_set1, "set_event_policy cal-evt-001");
|
||||
let body1 = result1.get("result").unwrap_or(result1);
|
||||
assert_eq!(body1.get("ok"), Some(&json!(true)));
|
||||
|
||||
let resp_set2 = post_json_rpc(
|
||||
&rpc_base,
|
||||
9301,
|
||||
"openhuman.meet_set_event_policy",
|
||||
json!({ "calendar_event_id": "cal-evt-002", "policy": "skip" }),
|
||||
)
|
||||
.await;
|
||||
let result2 = assert_no_jsonrpc_error(&resp_set2, "set_event_policy cal-evt-002");
|
||||
let body2 = result2.get("result").unwrap_or(result2);
|
||||
assert_eq!(body2.get("ok"), Some(&json!(true)));
|
||||
|
||||
// --- retrieve them in a batch ---
|
||||
let resp_get = post_json_rpc(
|
||||
&rpc_base,
|
||||
9302,
|
||||
"openhuman.meet_get_event_policies",
|
||||
json!({ "calendar_event_ids": ["cal-evt-001", "cal-evt-002", "cal-evt-missing"] }),
|
||||
)
|
||||
.await;
|
||||
let result_get = assert_no_jsonrpc_error(&resp_get, "get_event_policies batch");
|
||||
let body_get = result_get.get("result").unwrap_or(result_get);
|
||||
assert_eq!(body_get.get("ok"), Some(&json!(true)));
|
||||
let policies = body_get.get("policies").expect("policies field");
|
||||
assert_eq!(policies.get("cal-evt-001"), Some(&json!("auto")));
|
||||
assert_eq!(policies.get("cal-evt-002"), Some(&json!("skip")));
|
||||
assert!(
|
||||
policies.get("cal-evt-missing").is_none(),
|
||||
"unknown event must be absent from policies map"
|
||||
);
|
||||
|
||||
// --- overwrite a policy and verify the new value is returned ---
|
||||
let resp_overwrite = post_json_rpc(
|
||||
&rpc_base,
|
||||
9303,
|
||||
"openhuman.meet_set_event_policy",
|
||||
json!({ "calendar_event_id": "cal-evt-001", "policy": "ask" }),
|
||||
)
|
||||
.await;
|
||||
assert_no_jsonrpc_error(&resp_overwrite, "set_event_policy overwrite");
|
||||
|
||||
let resp_get2 = post_json_rpc(
|
||||
&rpc_base,
|
||||
9304,
|
||||
"openhuman.meet_get_event_policies",
|
||||
json!({ "calendar_event_ids": ["cal-evt-001"] }),
|
||||
)
|
||||
.await;
|
||||
let result_get2 = assert_no_jsonrpc_error(&resp_get2, "get_event_policies after overwrite");
|
||||
let body_get2 = result_get2.get("result").unwrap_or(result_get2);
|
||||
let policies2 = body_get2
|
||||
.get("policies")
|
||||
.expect("policies field after overwrite");
|
||||
assert_eq!(
|
||||
policies2.get("cal-evt-001"),
|
||||
Some(&json!("ask")),
|
||||
"overwritten policy must reflect the new value"
|
||||
);
|
||||
|
||||
// --- invalid policy string is rejected ---
|
||||
let resp_bad = post_json_rpc(
|
||||
&rpc_base,
|
||||
9305,
|
||||
"openhuman.meet_set_event_policy",
|
||||
json!({ "calendar_event_id": "cal-evt-001", "policy": "invalid_policy" }),
|
||||
)
|
||||
.await;
|
||||
assert_jsonrpc_error(&resp_bad, "set_event_policy invalid policy");
|
||||
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user