mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(meet): Recall Calendar auto-join that replies, connect card on Meetings page, reply-name setting (#4391)
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
SettingsSelect,
|
||||
SettingsStatusLine,
|
||||
SettingsSwitch,
|
||||
SettingsTextField,
|
||||
} from '../settings/controls';
|
||||
|
||||
const log = debug('meetings:defaults-drawer');
|
||||
@@ -80,6 +81,9 @@ export function MeetDefaultsDrawer({ open, onClose }: MeetDefaultsDrawerProps) {
|
||||
const [autoSummarize, setAutoSummarize] = useState<MeetAutoSummarizePolicy>('ask');
|
||||
const [listenOnly, setListenOnly] = useState(true);
|
||||
const [ingestTranscripts, setIngestTranscripts] = useState(false);
|
||||
// The user's meeting display name — reused as the bot's reply anchor on every
|
||||
// join. Persisted on blur (a text field must not save per keystroke).
|
||||
const [replyDisplayName, setReplyDisplayName] = useState('');
|
||||
|
||||
// Per-platform overrides: key → MeetAutoJoinPolicy | undefined (undefined = use default)
|
||||
const [platformPolicies, setPlatformPolicies] = useState<Record<string, PlatformPolicy>>({});
|
||||
@@ -111,6 +115,7 @@ export function MeetDefaultsDrawer({ open, onClose }: MeetDefaultsDrawerProps) {
|
||||
setAutoSummarize(s.auto_summarize_policy);
|
||||
setListenOnly(s.listen_only_default);
|
||||
setIngestTranscripts(s.ingest_backend_transcripts);
|
||||
setReplyDisplayName(s.reply_display_name ?? '');
|
||||
// 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 ?? {};
|
||||
@@ -189,6 +194,14 @@ export function MeetDefaultsDrawer({ open, onClose }: MeetDefaultsDrawerProps) {
|
||||
void persist('listen_only_default', { listen_only_default: next }, () => setListenOnly(prev));
|
||||
};
|
||||
|
||||
// Persist the display name on blur (not per keystroke). Trim before saving so
|
||||
// the anchor match is clean; skip the write when nothing changed.
|
||||
const handleReplyDisplayNameBlur = () => {
|
||||
const trimmed = replyDisplayName.trim();
|
||||
if (trimmed !== replyDisplayName) setReplyDisplayName(trimmed);
|
||||
void persist('reply_display_name', { reply_display_name: trimmed });
|
||||
};
|
||||
|
||||
const handleIngestChange = (next: boolean) => {
|
||||
const prev = ingestTranscripts;
|
||||
setIngestTranscripts(next);
|
||||
@@ -291,6 +304,27 @@ export function MeetDefaultsDrawer({ open, onClose }: MeetDefaultsDrawerProps) {
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Reply anchor: the user's display name, reused on every join so
|
||||
the bot knows who to reply to (otherwise it stays listen-only). */}
|
||||
<SettingsSection>
|
||||
<SettingsRow
|
||||
stacked
|
||||
htmlFor="drawer-input-reply-display-name"
|
||||
label={t('skills.meetingBots.replyName.label')}
|
||||
description={t('skills.meetingBots.replyName.description')}
|
||||
control={
|
||||
<SettingsTextField
|
||||
id="drawer-input-reply-display-name"
|
||||
value={replyDisplayName}
|
||||
onChange={e => setReplyDisplayName(e.target.value)}
|
||||
onBlur={handleReplyDisplayNameBlur}
|
||||
placeholder={t('skills.meetingBots.replyName.placeholder')}
|
||||
aria-label={t('skills.meetingBots.replyName.label')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Global auto-join */}
|
||||
<SettingsSection title={t('skills.meetingBots.defaults.globalPolicy')}>
|
||||
<SettingsRow
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { selectBackendMeetStatus } from '../../store/backendMeetSlice';
|
||||
import { useAppSelector } from '../../store/hooks';
|
||||
import { isTauri, openhumanGetMeetSettings } from '../../utils/tauriCommands';
|
||||
import RecallCalendarCard from '../recallCalendar/RecallCalendarCard';
|
||||
import BetaBanner from '../ui/BetaBanner';
|
||||
import { ActiveMeetingBanner } from './ActiveMeetingBanner';
|
||||
import HistorySection from './HistorySection';
|
||||
@@ -36,6 +37,9 @@ export default function MeetingsPage({ onToast }: MeetingsPageProps) {
|
||||
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);
|
||||
// The saved meeting display name — passed to UpcomingTable so "Join now" uses
|
||||
// it as the reply anchor (and joins in reply mode instead of listen-only).
|
||||
const [replyDisplayName, setReplyDisplayName] = useState('');
|
||||
// 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.
|
||||
@@ -69,6 +73,7 @@ export default function MeetingsPage({ onToast }: MeetingsPageProps) {
|
||||
if (!cancelled) {
|
||||
log('[page] watch_calendar=%s', resp.result.watch_calendar);
|
||||
setWatchCalendar(resp.result.watch_calendar ?? false);
|
||||
setReplyDisplayName(resp.result.reply_display_name ?? '');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -115,7 +120,12 @@ export default function MeetingsPage({ onToast }: MeetingsPageProps) {
|
||||
<MeetComposer onToast={onToast} hasSubmittedRef={hasSubmittedRef} />
|
||||
)}
|
||||
|
||||
<UpcomingTable watchCalendar={watchCalendar} />
|
||||
{/* Recall Calendar connect tile — meeting-specific, so it lives here
|
||||
rather than on the OAuth/Connections page. Self-hides when the backend
|
||||
has the integration disabled. */}
|
||||
<RecallCalendarCard />
|
||||
|
||||
<UpcomingTable watchCalendar={watchCalendar} replyDisplayName={replyDisplayName} />
|
||||
|
||||
<HistorySection />
|
||||
|
||||
@@ -126,7 +136,10 @@ export default function MeetingsPage({ onToast }: MeetingsPageProps) {
|
||||
// Re-fetch watch_calendar after drawer closes so the hint updates.
|
||||
if (!isTauri()) return;
|
||||
openhumanGetMeetSettings()
|
||||
.then(resp => setWatchCalendar(resp.result.watch_calendar ?? false))
|
||||
.then(resp => {
|
||||
setWatchCalendar(resp.result.watch_calendar ?? false);
|
||||
setReplyDisplayName(resp.result.reply_display_name ?? '');
|
||||
})
|
||||
.catch(() => {
|
||||
/* leave unchanged */
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
setEventPolicy,
|
||||
type UpcomingMeeting,
|
||||
} from '../../services/meetCallService';
|
||||
import { selectBackendMeetStatus, selectBackendMeetUrl } from '../../store/backendMeetSlice';
|
||||
import { useAppSelector } from '../../store/hooks';
|
||||
import {
|
||||
selectCustomPrimaryColor,
|
||||
@@ -164,9 +165,17 @@ interface MeetingRowProps {
|
||||
onJoinPolicyChange: (v: JoinPolicy) => void;
|
||||
onJoin: (m: UpcomingMeeting) => void;
|
||||
joining: boolean;
|
||||
joined: boolean;
|
||||
}
|
||||
|
||||
function MeetingRow({ meeting, joinPolicy, onJoinPolicyChange, onJoin, joining }: MeetingRowProps) {
|
||||
function MeetingRow({
|
||||
meeting,
|
||||
joinPolicy,
|
||||
onJoinPolicyChange,
|
||||
onJoin,
|
||||
joining,
|
||||
joined,
|
||||
}: MeetingRowProps) {
|
||||
const { t } = useT();
|
||||
const imminent = isImminent(meeting.start_time_ms);
|
||||
const { relative, absolute } = formatWhen(meeting.start_time_ms, t);
|
||||
@@ -262,7 +271,15 @@ function MeetingRow({ meeting, joinPolicy, onJoinPolicyChange, onJoin, joining }
|
||||
|
||||
{/* ACTION */}
|
||||
<td className="py-2 px-3 whitespace-nowrap">
|
||||
{imminent ? (
|
||||
{joined ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-400">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('skills.meetingBots.liveBadge')}
|
||||
</span>
|
||||
) : imminent ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="xs"
|
||||
@@ -314,12 +331,19 @@ export interface UpcomingTableProps {
|
||||
* `true` = on — no hint needed.
|
||||
*/
|
||||
watchCalendar?: boolean | null;
|
||||
/**
|
||||
* The user's saved meeting display name (from meet settings). Used as the
|
||||
* bot's reply anchor when joining via "Join now" — when set, the bot joins in
|
||||
* reply mode instead of listen-only so it can respond to the user.
|
||||
*/
|
||||
replyDisplayName?: string;
|
||||
}
|
||||
|
||||
export function UpcomingTable({
|
||||
lookaheadMinutes,
|
||||
limit,
|
||||
watchCalendar = null,
|
||||
replyDisplayName = '',
|
||||
}: UpcomingTableProps) {
|
||||
const { t } = useT();
|
||||
const { meetings, loading, error, refresh } = useUpcomingMeetings(lookaheadMinutes, limit);
|
||||
@@ -336,6 +360,17 @@ export function UpcomingTable({
|
||||
const customPrimaryColor = useAppSelector(selectCustomPrimaryColor);
|
||||
const customSecondaryColor = useAppSelector(selectCustomSecondaryColor);
|
||||
|
||||
// Live in-call state — lets a row detect that its meeting is already joined
|
||||
// and suppress the "Join now" button. correlationId is a fresh per-join UUID
|
||||
// (#4338), so the backendMeet slice's meetingId never equals
|
||||
// calendar_event_id — match the joined meet_url instead.
|
||||
const backendMeetStatus = useAppSelector(selectBackendMeetStatus);
|
||||
const backendMeetUrl = useAppSelector(selectBackendMeetUrl);
|
||||
const isMeetingJoined = (m: UpcomingMeeting): boolean => {
|
||||
if (backendMeetStatus !== 'active' && backendMeetStatus !== 'joining') return false;
|
||||
return Boolean(backendMeetUrl && m.meet_url && backendMeetUrl === m.meet_url);
|
||||
};
|
||||
|
||||
// Resolve bot join params the same way MeetComposer does.
|
||||
const mascotId = selectedMascotId ?? (mascotColor === 'custom' ? undefined : mascotColor);
|
||||
const riveColors =
|
||||
@@ -346,6 +381,17 @@ export function UpcomingTable({
|
||||
const handleJoin = async (meeting: UpcomingMeeting) => {
|
||||
if (!meeting.meet_url) return;
|
||||
const platform = meeting.platform ?? inferPlatformFromUrl(meeting.meet_url) ?? undefined;
|
||||
// Reply anchor: the display name the user saved on the Meetings page. When
|
||||
// present the bot joins in reply mode (not listen-only) so it can respond to
|
||||
// the user; without it we keep the safe listen-only default (the bot has no
|
||||
// one to reply to and would otherwise talk to everyone).
|
||||
const anchor = replyDisplayName.trim();
|
||||
// Reply mode gates the bot behind a wake phrase so it only reacts when
|
||||
// addressed ("Hey Alex, …"), never to every caption from the anchor —
|
||||
// mirroring MeetComposer. The bot joins as `agentName`, so the phrase must
|
||||
// match it. Listen-only joins (no anchor) send no wake phrase.
|
||||
const agentName = personaDisplayName.trim() || 'Tiny';
|
||||
const wakePhrase = anchor ? `Hey ${agentName}` : undefined;
|
||||
// Mint a fresh correlation id per join. It becomes the call record's
|
||||
// `request_id` (recent-calls list key + per-call detail filename), so it
|
||||
// MUST be unique per join — reusing the deterministic `calendar_event_id`
|
||||
@@ -355,9 +401,10 @@ export function UpcomingTable({
|
||||
// setJoiningId), mirroring the background auto-join in calendar.rs.
|
||||
const correlationId = crypto.randomUUID();
|
||||
log(
|
||||
'[upcoming] joining %s platform=%s correlationId=%s',
|
||||
'[upcoming] joining %s platform=%s reply_mode=%s correlationId=%s',
|
||||
meeting.calendar_event_id,
|
||||
platform,
|
||||
Boolean(anchor),
|
||||
correlationId
|
||||
);
|
||||
setJoiningId(meeting.calendar_event_id);
|
||||
@@ -365,10 +412,12 @@ export function UpcomingTable({
|
||||
await joinMeetViaBackendBot({
|
||||
meetUrl: meeting.meet_url,
|
||||
platform: platform as MeetingPlatform | undefined,
|
||||
agentName: personaDisplayName || undefined,
|
||||
agentName,
|
||||
systemPrompt: personaDescription || undefined,
|
||||
mascotId: mascotId || undefined,
|
||||
listenOnly: true,
|
||||
respondToParticipant: anchor || undefined,
|
||||
wakePhrase,
|
||||
listenOnly: !anchor,
|
||||
correlationId,
|
||||
riveColors,
|
||||
});
|
||||
@@ -564,6 +613,7 @@ export function UpcomingTable({
|
||||
onJoinPolicyChange={v => handleJoinPolicyChange(m.calendar_event_id, v)}
|
||||
onJoin={handleJoin}
|
||||
joining={joiningId === m.calendar_event_id}
|
||||
joined={isMeetingJoined(m)}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -284,4 +284,32 @@ describe('MeetDefaultsDrawer', () => {
|
||||
expect(autoJoinSelect).toHaveValue('never');
|
||||
});
|
||||
});
|
||||
|
||||
// ── reply_display_name text field ──────────────────────────────────────────
|
||||
|
||||
it('loads the saved reply_display_name into the input', async () => {
|
||||
getMock.mockResolvedValueOnce({
|
||||
result: { ...DEFAULT_SETTINGS.result, reply_display_name: 'Saved Name' },
|
||||
});
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
const input = await screen.findByRole('textbox', { name: /your name in meetings/i });
|
||||
expect(input).toHaveValue('Saved Name');
|
||||
});
|
||||
|
||||
it('persists the trimmed reply_display_name on blur', async () => {
|
||||
getMock.mockResolvedValueOnce({ ...DEFAULT_SETTINGS });
|
||||
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
|
||||
const input = await screen.findByRole('textbox', { name: /your name in meetings/i });
|
||||
|
||||
fireEvent.change(input, { target: { value: ' Alex Kim ' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reply_display_name: 'Alex Kim' })
|
||||
)
|
||||
);
|
||||
// The input reflects the trimmed value after blur.
|
||||
expect(input).toHaveValue('Alex Kim');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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 MeetingsPage from '../MeetingsPage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock the Tauri bridge so the page believes it's running in Tauri and we
|
||||
// control the meet-settings payload it loads on mount / after the drawer closes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getMeetSettingsMock = vi.fn();
|
||||
|
||||
vi.mock('../../../utils/tauriCommands', () => ({
|
||||
isTauri: () => true,
|
||||
openhumanGetMeetSettings: (...args: unknown[]) => getMeetSettingsMock(...args),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub the heavy children so the test stays focused on MeetingsPage's own
|
||||
// settings-loading + drawer-close re-fetch wiring.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UpcomingTable is the consumer we assert on — surface the replyDisplayName prop.
|
||||
vi.mock('../UpcomingTable', () => ({
|
||||
UpcomingTable: ({ replyDisplayName }: { replyDisplayName: string }) => (
|
||||
<div data-testid="upcoming-table" data-reply-name={replyDisplayName} />
|
||||
),
|
||||
}));
|
||||
|
||||
// The remaining children carry no behavior relevant here — trivial stubs.
|
||||
vi.mock('../../recallCalendar/RecallCalendarCard', () => ({
|
||||
default: () => <div data-testid="recall-calendar-card-stub" />,
|
||||
}));
|
||||
vi.mock('../MeetComposer', () => ({
|
||||
MeetComposer: () => <div data-testid="meet-composer-stub" />,
|
||||
}));
|
||||
vi.mock('../ActiveMeetingBanner', () => ({
|
||||
ActiveMeetingBanner: () => <div data-testid="active-meeting-banner-stub" />,
|
||||
}));
|
||||
vi.mock('../HistorySection', () => ({ default: () => <div data-testid="history-section-stub" /> }));
|
||||
|
||||
// Lightweight drawer stub: renders a Close button (which calls onClose) only
|
||||
// while open, so the drawer-close re-fetch path in MeetingsPage can be driven
|
||||
// without pulling in the real drawer's own settings load.
|
||||
vi.mock('../MeetDefaultsDrawer', () => ({
|
||||
MeetDefaultsDrawer: ({ open, onClose }: { open: boolean; onClose: () => void }) =>
|
||||
open ? (
|
||||
<button type="button" data-testid="drawer-close-stub" onClick={onClose}>
|
||||
close-drawer
|
||||
</button>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
function mockSettings(reply_display_name: string, watch_calendar = true) {
|
||||
getMeetSettingsMock.mockResolvedValue({ result: { watch_calendar, reply_display_name } });
|
||||
}
|
||||
|
||||
describe('MeetingsPage', () => {
|
||||
beforeEach(() => {
|
||||
getMeetSettingsMock.mockReset();
|
||||
mockSettings('Alex Kim');
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('loads reply_display_name and passes it to UpcomingTable', async () => {
|
||||
renderWithProviders(<MeetingsPage />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('upcoming-table')).toHaveAttribute('data-reply-name', 'Alex Kim')
|
||||
);
|
||||
expect(getMeetSettingsMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('refreshes settings after the meeting-defaults drawer closes', async () => {
|
||||
renderWithProviders(<MeetingsPage />);
|
||||
|
||||
// On-mount fetch settles first.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('upcoming-table')).toHaveAttribute('data-reply-name', 'Alex Kim')
|
||||
);
|
||||
expect(getMeetSettingsMock).toHaveBeenCalledOnce();
|
||||
|
||||
// The gear button opens the drawer (aria-label from i18n → "Meeting defaults").
|
||||
fireEvent.click(screen.getByRole('button', { name: /meeting defaults/i }));
|
||||
|
||||
// A subsequent load returns an updated display name so we can prove the
|
||||
// re-fetch actually re-threads the value into UpcomingTable.
|
||||
mockSettings('Sam Rivers');
|
||||
fireEvent.click(screen.getByTestId('drawer-close-stub'));
|
||||
|
||||
await waitFor(() => expect(getMeetSettingsMock).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('upcoming-table')).toHaveAttribute('data-reply-name', 'Sam Rivers')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -387,4 +387,117 @@ describe('UpcomingTable', () => {
|
||||
// not a hardcoded English fallback.
|
||||
await waitFor(() => expect(screen.getByText(/^in \d+m$/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
// ── Live pill (backendMeet slice) + reply anchor ──────────────────────────
|
||||
|
||||
// Build a full backendMeet slice preloaded-state matching the slice shape.
|
||||
function activeMeetState(
|
||||
overrides: Partial<{ status: string; meetUrl: string | null; meetingId: string | null }> = {}
|
||||
) {
|
||||
return {
|
||||
backendMeet: {
|
||||
status: 'active',
|
||||
meetUrl: null,
|
||||
meetingId: null,
|
||||
listenOnly: false,
|
||||
lastReply: null,
|
||||
lastHarness: null,
|
||||
transcript: null,
|
||||
error: null,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('shows the Live pill (not a Join button) when active with a matching meet_url', async () => {
|
||||
listMock.mockResolvedValueOnce([
|
||||
makeMeeting({ calendar_event_id: 'evt-x', meet_url: 'https://meet.google.com/live-match' }),
|
||||
]);
|
||||
renderWithProviders(<UpcomingTable />, {
|
||||
preloadedState: activeMeetState({
|
||||
meetingId: null,
|
||||
meetUrl: 'https://meet.google.com/live-match',
|
||||
}),
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('Weekly Sync')).toBeInTheDocument());
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^join$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the Live pill when the backend status is joining (not only active)', async () => {
|
||||
listMock.mockResolvedValueOnce([
|
||||
makeMeeting({ calendar_event_id: 'evt-1', meet_url: 'https://meet.google.com/live-match' }),
|
||||
]);
|
||||
renderWithProviders(<UpcomingTable />, {
|
||||
preloadedState: activeMeetState({
|
||||
status: 'joining',
|
||||
meetUrl: 'https://meet.google.com/live-match',
|
||||
}),
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('Weekly Sync')).toBeInTheDocument());
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT show the Live pill when the status is active but nothing matches', async () => {
|
||||
listMock.mockResolvedValueOnce([makeMeeting({ calendar_event_id: 'evt-1' })]);
|
||||
renderWithProviders(<UpcomingTable />, {
|
||||
preloadedState: activeMeetState({ meetingId: 'other-evt', meetUrl: 'https://other.example' }),
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('Weekly Sync')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Live')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^join$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Join now (not Live) for an imminent meeting when not joined', async () => {
|
||||
listMock.mockResolvedValueOnce([makeMeeting({ start_time_ms: NOW + 2 * 60 * 1000 })]);
|
||||
renderWithProviders(<UpcomingTable />);
|
||||
await waitFor(() => expect(screen.getByText('Join now')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Live')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('joins in reply mode when replyDisplayName is set (respondToParticipant + listenOnly=false)', async () => {
|
||||
joinMock.mockResolvedValueOnce(undefined);
|
||||
listMock.mockResolvedValueOnce([makeMeeting()]);
|
||||
renderWithProviders(<UpcomingTable replyDisplayName=" Alex Kim " />);
|
||||
|
||||
const joinBtn = await screen.findByRole('button', { name: /^join$/i });
|
||||
fireEvent.click(joinBtn);
|
||||
|
||||
await waitFor(() => expect(joinMock).toHaveBeenCalledOnce());
|
||||
// The anchor is trimmed before being passed as respondToParticipant.
|
||||
// correlationId is a fresh per-join UUID (never the deterministic
|
||||
// calendar_event_id) — see the dedicated #4338 tests above.
|
||||
expect(joinMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ respondToParticipant: 'Alex Kim', listenOnly: false })
|
||||
);
|
||||
const { correlationId, wakePhrase } = joinMock.mock.calls[0][0] as {
|
||||
correlationId: string;
|
||||
wakePhrase?: string;
|
||||
};
|
||||
expect(correlationId).toBeTruthy();
|
||||
expect(correlationId).not.toBe('evt-1');
|
||||
// Reply mode must gate the bot behind a wake phrase so it only reacts when
|
||||
// addressed — otherwise every caption from the anchor becomes a command.
|
||||
expect(wakePhrase).toMatch(/^Hey /);
|
||||
});
|
||||
|
||||
it('joins listen-only when replyDisplayName is blank/whitespace (no respondToParticipant)', async () => {
|
||||
joinMock.mockResolvedValueOnce(undefined);
|
||||
listMock.mockResolvedValueOnce([makeMeeting()]);
|
||||
renderWithProviders(<UpcomingTable replyDisplayName=" " />);
|
||||
|
||||
const joinBtn = await screen.findByRole('button', { name: /^join$/i });
|
||||
fireEvent.click(joinBtn);
|
||||
|
||||
await waitFor(() => expect(joinMock).toHaveBeenCalledOnce());
|
||||
const arg = joinMock.mock.calls[0][0] as {
|
||||
listenOnly: boolean;
|
||||
respondToParticipant?: string;
|
||||
wakePhrase?: string;
|
||||
};
|
||||
expect(arg.listenOnly).toBe(true);
|
||||
expect(arg.respondToParticipant).toBeUndefined();
|
||||
// Listen-only: the bot never speaks, so no wake phrase is sent.
|
||||
expect(arg.wakePhrase).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { useRecallCalendar } from '../../lib/recallCalendar/hooks';
|
||||
import Button from '../ui/Button';
|
||||
import { Spinner } from '../ui/icons';
|
||||
|
||||
/**
|
||||
* Card for connecting a Google Calendar via Recall.ai Calendar V1.
|
||||
*
|
||||
* Lives on the Meetings page (calendar connection is meeting-specific). Renders
|
||||
* only when the backend advertises the integration as enabled
|
||||
* (`RECALL_CALENDAR_ENABLED`). Connecting opens the Google OAuth consent flow in
|
||||
* the browser; once the status poll flips to connected, the hook switches Google
|
||||
* Meet detection to the Recall calendar source.
|
||||
*/
|
||||
function CalendarGlyph() {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="text-primary-500">
|
||||
<rect x="3" y="4.5" width="18" height="16" rx="2.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M3 9h18" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M8 2.5v4M16 2.5v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecallCalendarCard() {
|
||||
const { t } = useT();
|
||||
const { status, loading, busy, error, beginConnect, disconnect } = useRecallCalendar();
|
||||
|
||||
// Hidden until the backend advertises the integration as enabled.
|
||||
if (loading || !status?.enabled) return null;
|
||||
|
||||
const connected = status.connected;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="recall-calendar-card"
|
||||
className="rounded-2xl border border-line bg-surface p-3 shadow-soft">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Leading icon tile */}
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary-500/10">
|
||||
<CalendarGlyph />
|
||||
</div>
|
||||
|
||||
{/* Title + status */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-content-primary">
|
||||
{t('skills.recallCalendar.title')}
|
||||
</div>
|
||||
{connected ? (
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate text-xs text-content-secondary">
|
||||
{status.email || t('skills.connected')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-0.5 truncate text-xs text-content-secondary">
|
||||
{t('skills.recallCalendar.description')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
{connected ? (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
tone="danger"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
leadingIcon={busy ? <Spinner className="h-3.5 w-3.5" /> : undefined}
|
||||
onClick={() => void disconnect()}
|
||||
data-testid="recall-calendar-disconnect">
|
||||
{t('skills.disconnect')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
leadingIcon={busy ? <Spinner className="h-3.5 w-3.5" /> : undefined}
|
||||
onClick={() => void beginConnect()}
|
||||
data-testid="recall-calendar-connect">
|
||||
{t('skills.connect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-2 rounded-lg bg-red-500/10 px-2.5 py-1.5 text-[11px] text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { cleanup, fireEvent, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import RecallCalendarCard from '../RecallCalendarCard';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock the connection hook so we drive card state directly. The card is a pure
|
||||
// presenter over useRecallCalendar; all network/poll behavior lives in the hook.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const beginConnect = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
const refresh = vi.fn();
|
||||
const useRecallCalendarMock = vi.fn();
|
||||
|
||||
vi.mock('../../../lib/recallCalendar/hooks', () => ({
|
||||
useRecallCalendar: (...args: unknown[]) => useRecallCalendarMock(...args),
|
||||
}));
|
||||
|
||||
interface HookState {
|
||||
status: { enabled: boolean; connected: boolean; email?: string } | null;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function setHook(overrides: Partial<HookState> = {}) {
|
||||
useRecallCalendarMock.mockReturnValue({
|
||||
status: { enabled: true, connected: false },
|
||||
loading: false,
|
||||
busy: false,
|
||||
error: null,
|
||||
beginConnect,
|
||||
disconnect,
|
||||
refresh,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('RecallCalendarCard', () => {
|
||||
beforeEach(() => {
|
||||
beginConnect.mockReset().mockResolvedValue(undefined);
|
||||
disconnect.mockReset().mockResolvedValue(undefined);
|
||||
refresh.mockReset().mockResolvedValue(undefined);
|
||||
useRecallCalendarMock.mockReset();
|
||||
setHook();
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders nothing while the hook is loading', () => {
|
||||
setHook({ loading: true });
|
||||
renderWithProviders(<RecallCalendarCard />);
|
||||
expect(screen.queryByTestId('recall-calendar-card')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when the integration is disabled', () => {
|
||||
setHook({ status: { enabled: false, connected: false } });
|
||||
renderWithProviders(<RecallCalendarCard />);
|
||||
expect(screen.queryByTestId('recall-calendar-card')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when the status is null', () => {
|
||||
setHook({ status: null });
|
||||
renderWithProviders(<RecallCalendarCard />);
|
||||
expect(screen.queryByTestId('recall-calendar-card')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the connect button when enabled but not connected, and calls beginConnect on click', () => {
|
||||
setHook({ status: { enabled: true, connected: false } });
|
||||
renderWithProviders(<RecallCalendarCard />);
|
||||
|
||||
expect(screen.getByTestId('recall-calendar-card')).toBeInTheDocument();
|
||||
// Description (not-connected state) is shown.
|
||||
expect(screen.getByText(/auto-join google meet/i)).toBeInTheDocument();
|
||||
// No disconnect action in the not-connected state.
|
||||
expect(screen.queryByTestId('recall-calendar-disconnect')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('recall-calendar-connect'));
|
||||
expect(beginConnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows the connected email + disconnect button and calls disconnect on click', () => {
|
||||
setHook({ status: { enabled: true, connected: true, email: 'me@example.com' } });
|
||||
renderWithProviders(<RecallCalendarCard />);
|
||||
|
||||
expect(screen.getByText('me@example.com')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('recall-calendar-connect')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('recall-calendar-disconnect'));
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('falls back to the connected label when connected without an email', () => {
|
||||
setHook({ status: { enabled: true, connected: true } });
|
||||
renderWithProviders(<RecallCalendarCard />);
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('recall-calendar-disconnect')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the error string when the hook reports an error', () => {
|
||||
setHook({ status: { enabled: true, connected: false }, error: 'Something broke' });
|
||||
renderWithProviders(<RecallCalendarCard />);
|
||||
expect(screen.getByText('Something broke')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the connect button and shows a spinner while busy', () => {
|
||||
setHook({ status: { enabled: true, connected: false }, busy: true });
|
||||
renderWithProviders(<RecallCalendarCard />);
|
||||
expect(screen.getByTestId('recall-calendar-connect')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables the disconnect button while busy', () => {
|
||||
setHook({ status: { enabled: true, connected: true, email: 'me@example.com' }, busy: true });
|
||||
renderWithProviders(<RecallCalendarCard />);
|
||||
expect(screen.getByTestId('recall-calendar-disconnect')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import type { TranslationMap } from './types';
|
||||
// Arabic (العربية) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'تقويم Google',
|
||||
'skills.recallCalendar.description': 'الانضمام تلقائيًا إلى مكالمات Google Meet عبر Recall.ai',
|
||||
// Cross-host vault (#4278)
|
||||
'crossHostVault.title': 'الخزنة موجودة على مضيف النواة.',
|
||||
'crossHostVault.message':
|
||||
@@ -5134,6 +5136,10 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.leavingButton': 'جارٍ المغادرة…',
|
||||
'skills.meetingBots.respondToParticipant': 'اسمك في هذا الاجتماع',
|
||||
'skills.meetingBots.respondToParticipantHint': 'مثال: أحمد (اسمك في المكالمة)',
|
||||
'skills.meetingBots.replyName.label': 'اسمك في الاجتماعات',
|
||||
'skills.meetingBots.replyName.description':
|
||||
'الاسم الذي يستمع إليه المساعد ويرد عليه. أدخل اسمك كما يظهر في المكالمة — يُطبَّق على كل اجتماع ينضم إليه.',
|
||||
'skills.meetingBots.replyName.placeholder': 'مثال: أليكس كيم',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'أدخل اسمك الظاهر بالضبط في الاجتماع. لن يستجيب البوت إلا عندما تنطق باسمه (عبارة التنشيط).',
|
||||
'skills.meetingBots.wakePhrase': 'عبارة التنشيط',
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { TranslationMap } from './types';
|
||||
// Bengali (বাংলা) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'Google Calendar',
|
||||
'skills.recallCalendar.description':
|
||||
'Recall.ai-এর মাধ্যমে Google Meet কলে স্বয়ংক্রিয়ভাবে যোগ দিন',
|
||||
// Cross-host vault (#4278)
|
||||
'crossHostVault.title': 'ভল্টটি কোর হোস্টে রয়েছে।',
|
||||
'crossHostVault.message':
|
||||
@@ -5239,6 +5242,10 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.leavingButton': 'বেরিয়ে যাচ্ছে…',
|
||||
'skills.meetingBots.respondToParticipant': 'এই মিটিংয়ে আপনার নাম',
|
||||
'skills.meetingBots.respondToParticipantHint': 'যেমন: রিয়া (কলে আপনার প্রদর্শনী নাম)',
|
||||
'skills.meetingBots.replyName.label': 'মিটিংয়ে আপনার নাম',
|
||||
'skills.meetingBots.replyName.description':
|
||||
'যে নামে বট শোনে ও উত্তর দেয়। কলে যেভাবে দেখায় সেভাবে আপনার নাম দিন — এটি যোগ দেওয়া প্রতিটি মিটিংয়ে প্রযোজ্য।',
|
||||
'skills.meetingBots.replyName.placeholder': 'যেমন: অ্যালেক্স কিম',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'মিটিং থেকে আপনার সঠিক প্রদর্শন নাম লিখুন। বট কেবল তখনই সাড়া দেয় যখন আপনি তার নাম বলেন (ওয়েক ফ্রেজ)।',
|
||||
'skills.meetingBots.wakePhrase': 'ওয়েক ফ্রেজ',
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { TranslationMap } from './types';
|
||||
// German (Deutsch) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'Google Kalender',
|
||||
'skills.recallCalendar.description': 'Google Meet-Anrufen automatisch über Recall.ai beitreten',
|
||||
// Cross-host vault (#4278)
|
||||
'crossHostVault.title': 'Der Vault liegt auf dem Core-Host.',
|
||||
'crossHostVault.message':
|
||||
@@ -5373,6 +5375,10 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.leavingButton': 'Wird verlassen…',
|
||||
'skills.meetingBots.respondToParticipant': 'Ihr Name in diesem Meeting',
|
||||
'skills.meetingBots.respondToParticipantHint': 'z. B. Max (Ihr Anzeigename im Anruf)',
|
||||
'skills.meetingBots.replyName.label': 'Ihr Name in Meetings',
|
||||
'skills.meetingBots.replyName.description':
|
||||
'Der Name, auf den der Bot hört und antwortet. Geben Sie Ihren Namen so ein, wie er im Anruf erscheint – gilt für jedes Meeting, dem er beitritt.',
|
||||
'skills.meetingBots.replyName.placeholder': 'z. B. Alex Kim',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Geben Sie Ihren genauen Anzeigenamen aus dem Meeting ein. Der Bot antwortet nur, wenn Sie seinen Namen sagen (Wake-Phrase).',
|
||||
'skills.meetingBots.wakePhrase': 'Wake-Phrase',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationMap } from './types';
|
||||
|
||||
const en: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'Google Calendar',
|
||||
'skills.recallCalendar.description': 'Auto-join Google Meet calls via Recall.ai',
|
||||
// Navigation
|
||||
'nav.home': 'Home',
|
||||
'nav.human': 'Human',
|
||||
@@ -5889,6 +5891,10 @@ const en: TranslationMap = {
|
||||
'skills.meetingBots.leavingButton': 'Leaving…',
|
||||
'skills.meetingBots.respondToParticipant': 'Your Name in This Meeting',
|
||||
'skills.meetingBots.respondToParticipantHint': 'e.g. Alice (your display name in the call)',
|
||||
'skills.meetingBots.replyName.label': 'Your name in meetings',
|
||||
'skills.meetingBots.replyName.description':
|
||||
'The name the bot listens for and replies to. Enter your name as it appears in the call — applied to every meeting it joins.',
|
||||
'skills.meetingBots.replyName.placeholder': 'e.g. Alex Kim',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Enter your exact display name from the meeting. The bot only responds when you say its name (wake phrase).',
|
||||
'skills.meetingBots.wakePhrase': 'Wake Phrase',
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { TranslationMap } from './types';
|
||||
// Spanish (Español) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'Google Calendar',
|
||||
'skills.recallCalendar.description':
|
||||
'Unirse automáticamente a las llamadas de Google Meet con Recall.ai',
|
||||
// Cross-host vault (#4278)
|
||||
'crossHostVault.title': 'El vault está en el host del core.',
|
||||
'crossHostVault.message':
|
||||
@@ -5337,6 +5340,10 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.leavingButton': 'Saliendo…',
|
||||
'skills.meetingBots.respondToParticipant': 'Tu nombre en esta reunión',
|
||||
'skills.meetingBots.respondToParticipantHint': 'p. ej. Ana (tu nombre visible en la llamada)',
|
||||
'skills.meetingBots.replyName.label': 'Tu nombre en las reuniones',
|
||||
'skills.meetingBots.replyName.description':
|
||||
'El nombre que el bot escucha y al que responde. Escribe tu nombre tal como aparece en la llamada: se aplica a cada reunión a la que se une.',
|
||||
'skills.meetingBots.replyName.placeholder': 'p. ej., Alex Kim',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Introduce tu nombre de visualización exacto de la reunión. El bot solo responde cuando dices su nombre (frase de activación).',
|
||||
'skills.meetingBots.wakePhrase': 'Frase de activación',
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { TranslationMap } from './types';
|
||||
// French (Français) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'Google Agenda',
|
||||
'skills.recallCalendar.description':
|
||||
'Rejoindre automatiquement les appels Google Meet via Recall.ai',
|
||||
// Cross-host vault (#4278)
|
||||
'crossHostVault.title': "Le coffre se trouve sur l'hôte du cœur.",
|
||||
'crossHostVault.message':
|
||||
@@ -5358,6 +5361,10 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.leavingButton': 'Sortie en cours…',
|
||||
'skills.meetingBots.respondToParticipant': 'Votre nom dans cette réunion',
|
||||
'skills.meetingBots.respondToParticipantHint': 'ex. Alice (votre nom affiché dans l\u2019appel)',
|
||||
'skills.meetingBots.replyName.label': 'Votre nom dans les réunions',
|
||||
'skills.meetingBots.replyName.description':
|
||||
"Le nom que le bot écoute et auquel il répond. Saisissez votre nom tel qu'il apparaît dans l'appel — appliqué à chaque réunion qu'il rejoint.",
|
||||
'skills.meetingBots.replyName.placeholder': 'p. ex. Alex Kim',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Saisissez votre nom d\u2019affichage exact dans la réunion. Le bot ne répond que lorsque vous prononcez son nom (phrase de réveil).',
|
||||
'skills.meetingBots.wakePhrase': 'Phrase de réveil',
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { TranslationMap } from './types';
|
||||
// Hindi (हिन्दी) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'Google Calendar',
|
||||
'skills.recallCalendar.description': 'Recall.ai के ज़रिए Google Meet कॉल में अपने-आप शामिल हों',
|
||||
// Cross-host vault (#4278)
|
||||
'crossHostVault.title': 'वॉल्ट कोर होस्ट पर है।',
|
||||
'crossHostVault.message':
|
||||
@@ -5244,6 +5246,10 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.leavingButton': 'छोड़ रहे हैं…',
|
||||
'skills.meetingBots.respondToParticipant': 'इस मीटिंग में आपका नाम',
|
||||
'skills.meetingBots.respondToParticipantHint': 'जैसे: अनीता (कॉल में आपका प्रदर्शन नाम)',
|
||||
'skills.meetingBots.replyName.label': 'मीटिंग में आपका नाम',
|
||||
'skills.meetingBots.replyName.description':
|
||||
'वह नाम जिसे बॉट सुनता है और जवाब देता है। कॉल में दिखने वाला अपना नाम दर्ज करें — यह हर मीटिंग पर लागू होता है जिसमें वह शामिल होता है।',
|
||||
'skills.meetingBots.replyName.placeholder': 'जैसे, एलेक्स किम',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'मीटिंग में अपना सटीक डिस्प्ले नाम दर्ज करें। बॉट केवल तभी जवाब देता है जब आप उसका नाम बोलते हैं (वेक फ्रेज़)।',
|
||||
'skills.meetingBots.wakePhrase': 'वेक फ्रेज़',
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { TranslationMap } from './types';
|
||||
// Indonesian (Bahasa Indonesia) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'Google Kalender',
|
||||
'skills.recallCalendar.description':
|
||||
'Bergabung otomatis ke panggilan Google Meet melalui Recall.ai',
|
||||
// Cross-host vault (#4278)
|
||||
'crossHostVault.title': 'Vault berada di host core.',
|
||||
'crossHostVault.message':
|
||||
@@ -5258,6 +5261,10 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.leavingButton': 'Keluar\u2026',
|
||||
'skills.meetingBots.respondToParticipant': 'Nama Anda di Rapat Ini',
|
||||
'skills.meetingBots.respondToParticipantHint': 'mis. Budi (nama tampilan Anda di panggilan)',
|
||||
'skills.meetingBots.replyName.label': 'Nama Anda di rapat',
|
||||
'skills.meetingBots.replyName.description':
|
||||
'Nama yang didengarkan dan dibalas oleh bot. Masukkan nama Anda seperti yang tampil di panggilan — berlaku untuk setiap rapat yang diikutinya.',
|
||||
'skills.meetingBots.replyName.placeholder': 'mis. Alex Kim',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Masukkan nama tampilan Anda yang tepat dari rapat. Bot hanya merespons ketika Anda menyebut namanya (frasa bangun).',
|
||||
'skills.meetingBots.wakePhrase': 'Frasa Bangun',
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { TranslationMap } from './types';
|
||||
// Italian (Italiano) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'Google Calendar',
|
||||
'skills.recallCalendar.description':
|
||||
'Partecipa automaticamente alle chiamate Google Meet tramite Recall.ai',
|
||||
// Cross-host vault (#4278)
|
||||
'crossHostVault.title': "Il vault è sull'host del core.",
|
||||
'crossHostVault.message':
|
||||
@@ -5327,6 +5330,10 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.respondToParticipant': 'Il tuo nome in questa riunione',
|
||||
'skills.meetingBots.respondToParticipantHint':
|
||||
'es. Mario (il tuo nome visualizzato nella chiamata)',
|
||||
'skills.meetingBots.replyName.label': 'Il tuo nome nelle riunioni',
|
||||
'skills.meetingBots.replyName.description':
|
||||
'Il nome che il bot ascolta e a cui risponde. Inserisci il tuo nome come appare nella chiamata: si applica a ogni riunione a cui partecipa.',
|
||||
'skills.meetingBots.replyName.placeholder': 'es. Alex Kim',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Inserisci il tuo nome visualizzato esatto nella riunione. Il bot risponde solo quando pronunci il suo nome (frase di attivazione).',
|
||||
'skills.meetingBots.wakePhrase': 'Frase di attivazione',
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { TranslationMap } from './types';
|
||||
// Korean (한국어) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'Google 캘린더',
|
||||
'skills.recallCalendar.description': 'Recall.ai를 통해 Google Meet 통화에 자동 참여',
|
||||
// Cross-host vault (#4278)
|
||||
'crossHostVault.title': '보관소가 코어 호스트에 있습니다.',
|
||||
'crossHostVault.message':
|
||||
@@ -5190,6 +5192,10 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.leavingButton': '나가는 중…',
|
||||
'skills.meetingBots.respondToParticipant': '이 회의에서 내 이름',
|
||||
'skills.meetingBots.respondToParticipantHint': '예: 김철수 (통화에서 표시되는 이름)',
|
||||
'skills.meetingBots.replyName.label': '회의에서 사용할 이름',
|
||||
'skills.meetingBots.replyName.description':
|
||||
'봇이 듣고 응답하는 이름입니다. 통화에 표시되는 이름을 입력하세요 — 참여하는 모든 회의에 적용됩니다.',
|
||||
'skills.meetingBots.replyName.placeholder': '예: Alex Kim',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'회의에서 사용하는 정확한 표시 이름을 입력하세요. 봇은 이름(웨이크 구문)을 말할 때만 응답합니다.',
|
||||
'skills.meetingBots.wakePhrase': '웨이크 구문',
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { TranslationMap } from './types';
|
||||
// Polish (Polski) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'Kalendarz Google',
|
||||
'skills.recallCalendar.description':
|
||||
'Automatyczne dołączanie do połączeń Google Meet przez Recall.ai',
|
||||
// Cross-host vault (#4278)
|
||||
'crossHostVault.title': 'Skarbiec znajduje się na hoście rdzenia.',
|
||||
'crossHostVault.message':
|
||||
@@ -5313,6 +5316,10 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.respondToParticipant': 'Twoje imię na tym spotkaniu',
|
||||
'skills.meetingBots.respondToParticipantHint':
|
||||
'np. Anna (Twoja nazwa wyświetlana podczas rozmowy)',
|
||||
'skills.meetingBots.replyName.label': 'Twoje imię na spotkaniach',
|
||||
'skills.meetingBots.replyName.description':
|
||||
'Imię, na które bot reaguje i odpowiada. Wpisz swoje imię tak, jak wyświetla się w rozmowie — dotyczy każdego spotkania, do którego dołącza.',
|
||||
'skills.meetingBots.replyName.placeholder': 'np. Alex Kim',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Wprowadź swój dokładny wyświetlany nick ze spotkania. Bot reaguje tylko wtedy, gdy wypowiesz jego nazwę (fraza aktywacji).',
|
||||
'skills.meetingBots.wakePhrase': 'Fraza aktywacji',
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { TranslationMap } from './types';
|
||||
// Portuguese (Português) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'Google Agenda',
|
||||
'skills.recallCalendar.description':
|
||||
'Entrar automaticamente nas chamadas do Google Meet via Recall.ai',
|
||||
// Cross-host vault (#4278)
|
||||
'crossHostVault.title': 'O vault está no host do core.',
|
||||
'crossHostVault.message':
|
||||
@@ -5329,6 +5332,10 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.leavingButton': 'Saindo…',
|
||||
'skills.meetingBots.respondToParticipant': 'Seu nome nesta reunião',
|
||||
'skills.meetingBots.respondToParticipantHint': 'ex. João (seu nome exibido na chamada)',
|
||||
'skills.meetingBots.replyName.label': 'Seu nome nas reuniões',
|
||||
'skills.meetingBots.replyName.description':
|
||||
'O nome que o bot escuta e ao qual responde. Digite seu nome como aparece na chamada — aplicado a cada reunião de que participa.',
|
||||
'skills.meetingBots.replyName.placeholder': 'ex.: Alex Kim',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Insira o seu nome de exibição exato da reunião. O bot só responde quando você diz o nome dele (frase de ativação).',
|
||||
'skills.meetingBots.wakePhrase': 'Frase de ativação',
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { TranslationMap } from './types';
|
||||
// Russian (Русский) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'Google Календарь',
|
||||
'skills.recallCalendar.description':
|
||||
'Автоматически подключаться к звонкам Google Meet через Recall.ai',
|
||||
// Cross-host vault (#4278)
|
||||
'crossHostVault.title': 'Хранилище находится на хосте ядра.',
|
||||
'crossHostVault.message':
|
||||
@@ -5285,6 +5288,10 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.leavingButton': 'Выход…',
|
||||
'skills.meetingBots.respondToParticipant': 'Ваше имя на этой встрече',
|
||||
'skills.meetingBots.respondToParticipantHint': 'напр. Иван (ваше отображаемое имя в звонке)',
|
||||
'skills.meetingBots.replyName.label': 'Ваше имя на встречах',
|
||||
'skills.meetingBots.replyName.description':
|
||||
'Имя, на которое бот откликается и отвечает. Введите своё имя так, как оно отображается в звонке — применяется к каждой встрече, к которой он подключается.',
|
||||
'skills.meetingBots.replyName.placeholder': 'напр. Алекс Ким',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Введите своё точное отображаемое имя из встречи. Бот реагирует только когда вы произносите его имя (фраза активации).',
|
||||
'skills.meetingBots.wakePhrase': 'Фраза активации',
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { TranslationMap } from './types';
|
||||
// Simplified Chinese (简体中文) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'skills.recallCalendar.title': 'Google 日历',
|
||||
'skills.recallCalendar.description': '通过 Recall.ai 自动加入 Google Meet 通话',
|
||||
// Cross-host vault (#4278)
|
||||
'crossHostVault.title': '记忆库位于核心主机上。',
|
||||
'crossHostVault.message':
|
||||
@@ -4980,6 +4982,10 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.leavingButton': '正在离开…',
|
||||
'skills.meetingBots.respondToParticipant': '您在此会议中的姓名',
|
||||
'skills.meetingBots.respondToParticipantHint': '例如:小明(通话中的显示名称)',
|
||||
'skills.meetingBots.replyName.label': '您在会议中的名称',
|
||||
'skills.meetingBots.replyName.description':
|
||||
'机器人识别并回复的名称。请输入您在通话中显示的名称——适用于它加入的每个会议。',
|
||||
'skills.meetingBots.replyName.placeholder': '例如:Alex Kim',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'输入您在会议中的确切显示名称。机器人仅在您说出其名称(唤醒词)时才会响应。',
|
||||
'skills.meetingBots.wakePhrase': '唤醒词',
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import * as recallCalendarApi from './recallCalendarApi';
|
||||
import { openhumanUpdateMeetSettings } from '../../utils/tauriCommands/config';
|
||||
import { useRecallCalendar } from './hooks';
|
||||
|
||||
vi.mock('../../utils/tauriCommands/config', () => ({ openhumanUpdateMeetSettings: vi.fn() }));
|
||||
|
||||
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
|
||||
|
||||
vi.mock('./recallCalendarApi', () => ({ status: vi.fn(), connect: vi.fn(), disconnect: vi.fn() }));
|
||||
|
||||
describe('useRecallCalendar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(openhumanUpdateMeetSettings).mockResolvedValue({
|
||||
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/config.toml' },
|
||||
logs: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('syncs Recall as provider when initial status is already connected', async () => {
|
||||
vi.mocked(recallCalendarApi.status).mockResolvedValue({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
renderHook(() => useRecallCalendar());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openhumanUpdateMeetSettings).toHaveBeenCalledWith({ calendar_provider: 'recall' });
|
||||
});
|
||||
});
|
||||
|
||||
test('clears a stale generic error once a later status poll succeeds', async () => {
|
||||
// Benign default: not connected → provider settles on 'composio'.
|
||||
vi.mocked(recallCalendarApi.status).mockResolvedValue({ enabled: false, connected: false });
|
||||
|
||||
const { result } = renderHook(() => useRecallCalendar());
|
||||
|
||||
// Mount poll syncs the provider once; no error.
|
||||
await waitFor(() =>
|
||||
expect(openhumanUpdateMeetSettings).toHaveBeenCalledWith({ calendar_provider: 'composio' })
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
// A transient fetch failure records a generic error.
|
||||
vi.mocked(recallCalendarApi.status).mockRejectedValueOnce(new Error('network blip'));
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
expect(result.current.error).toContain('network blip');
|
||||
|
||||
// A later successful poll — provider already 'composio', so the flip branch
|
||||
// is skipped — must still clear the stale error (regression guard).
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
await waitFor(() => expect(result.current.error).toBeNull());
|
||||
});
|
||||
|
||||
test('beginConnect surfaces an error when connect() rejects', async () => {
|
||||
vi.mocked(recallCalendarApi.status).mockResolvedValue({ enabled: false, connected: false });
|
||||
vi.mocked(recallCalendarApi.connect).mockRejectedValue(new Error('connect boom'));
|
||||
|
||||
const { result } = renderHook(() => useRecallCalendar());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.beginConnect();
|
||||
});
|
||||
|
||||
expect(result.current.error).toContain('connect boom');
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
|
||||
test('disconnect surfaces an error when disconnect() rejects', async () => {
|
||||
vi.mocked(recallCalendarApi.status).mockResolvedValue({ enabled: false, connected: false });
|
||||
vi.mocked(recallCalendarApi.disconnect).mockRejectedValue(new Error('disconnect boom'));
|
||||
|
||||
const { result } = renderHook(() => useRecallCalendar());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.disconnect();
|
||||
});
|
||||
|
||||
expect(result.current.error).toContain('disconnect boom');
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
|
||||
test('records a provider-switch error when the config flip fails', async () => {
|
||||
vi.mocked(recallCalendarApi.status).mockResolvedValue({
|
||||
enabled: true,
|
||||
connected: true,
|
||||
email: 'user@example.com',
|
||||
});
|
||||
vi.mocked(openhumanUpdateMeetSettings).mockRejectedValue(new Error('flip boom'));
|
||||
|
||||
const { result } = renderHook(() => useRecallCalendar());
|
||||
|
||||
await waitFor(() => expect(result.current.error).toMatch(/^calendar provider switch failed:/));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* React hook for the Recall.ai Calendar connection surface.
|
||||
*
|
||||
* Polls connection status (mirrors the Composio connect flow) and auto-flips
|
||||
* the core's `meet.calendar_provider` so Google Meet detection follows Recall
|
||||
* once connected — and reverts to Composio on disconnect. Provider is core
|
||||
* config, so the flip is a client-driven config update.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import * as recallCalendarApi from './recallCalendarApi';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
import { openhumanUpdateMeetSettings } from '../../utils/tauriCommands/config';
|
||||
import type { RecallCalendarStatus } from './recallCalendarApi';
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
type CalendarProvider = 'composio' | 'recall';
|
||||
|
||||
export interface UseRecallCalendar {
|
||||
status: RecallCalendarStatus | null;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
beginConnect: () => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useRecallCalendar(): UseRecallCalendar {
|
||||
const [status, setStatus] = useState<RecallCalendarStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const syncedProvider = useRef<CalendarProvider | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = await recallCalendarApi.status();
|
||||
setStatus(next);
|
||||
// A successful poll means the fetch itself recovered — clear any stale
|
||||
// generic fetch error from a previous blip. Provider-switch errors are
|
||||
// owned by the flip branch below (which clears them on its own success),
|
||||
// so leave those untouched here.
|
||||
setError(current =>
|
||||
current && !current.startsWith('calendar provider switch failed:') ? null : current
|
||||
);
|
||||
const desiredProvider: CalendarProvider =
|
||||
next.enabled && next.connected ? 'recall' : 'composio';
|
||||
// Keep the local core routing flag in sync even when the first status
|
||||
// poll already reports "connected" after an OAuth flow completed outside
|
||||
// this mounted component.
|
||||
if (syncedProvider.current !== desiredProvider) {
|
||||
try {
|
||||
await openhumanUpdateMeetSettings({ calendar_provider: desiredProvider });
|
||||
syncedProvider.current = desiredProvider;
|
||||
setError(current =>
|
||||
current?.startsWith('calendar provider switch failed:') ? null : current
|
||||
);
|
||||
} catch (flipErr) {
|
||||
// Non-fatal: the status itself is valid; surface for diagnostics
|
||||
// without discarding the connection state.
|
||||
setError(`calendar provider switch failed: ${String(flipErr)}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// Once the server reports the integration disabled (stable per session),
|
||||
// stop live polling — the initial refresh above still learns `enabled`.
|
||||
if (status && !status.enabled) return;
|
||||
const id = setInterval(() => void refresh(), POLL_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [refresh, status?.enabled]);
|
||||
|
||||
const beginConnect = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { connectUrl } = await recallCalendarApi.connect();
|
||||
await openUrl(connectUrl);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await recallCalendarApi.disconnect();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
return { status, loading, busy, error, beginConnect, disconnect, refresh };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { connect, disconnect, status } from './recallCalendarApi';
|
||||
|
||||
const callCoreRpc = vi.fn();
|
||||
vi.mock('../../services/coreRpcClient', () => ({
|
||||
callCoreRpc: (...args: unknown[]) => callCoreRpc(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => callCoreRpc.mockReset());
|
||||
|
||||
describe('recallCalendarApi', () => {
|
||||
it('connect returns connectUrl, unwrapping the CLI envelope', async () => {
|
||||
callCoreRpc.mockResolvedValue({ result: { connectUrl: 'https://consent' }, logs: [] });
|
||||
await expect(connect()).resolves.toEqual({ connectUrl: 'https://consent' });
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.recall_calendar_connect' });
|
||||
});
|
||||
|
||||
it('status passes through an already-flat response', async () => {
|
||||
callCoreRpc.mockResolvedValue({ enabled: true, connected: true, email: 'a@b.com' });
|
||||
await expect(status()).resolves.toEqual({ enabled: true, connected: true, email: 'a@b.com' });
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.recall_calendar_status' });
|
||||
});
|
||||
|
||||
it('disconnect unwraps an envelope carrying logs', async () => {
|
||||
callCoreRpc.mockResolvedValue({
|
||||
result: { disconnected: true },
|
||||
logs: ['calendar disconnected'],
|
||||
});
|
||||
await expect(disconnect()).resolves.toEqual({ disconnected: true });
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.recall_calendar_disconnect' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Imperative RPC wrapper for the Recall.ai Calendar domain — typed counterpart
|
||||
* to `src/openhuman/recall_calendar/*` on the Rust side.
|
||||
*
|
||||
* Every function calls the core via JSON-RPC. The core proxies to the
|
||||
* openhuman backend's `/agent-integrations/recall-calendar/*` routes, so the
|
||||
* frontend never talks to Recall.ai directly and never handles the API key.
|
||||
*/
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
|
||||
/**
|
||||
* Each `recall_calendar_*` op returns an `RpcOutcome` with a user-visible log
|
||||
* line, wrapped by `into_cli_compatible_json` as `{ result, logs }`. Peel that
|
||||
* envelope back off so callers work with the flat shapes. Mirrors the helper
|
||||
* in `lib/composio/composioApi.ts`.
|
||||
*/
|
||||
function unwrapCliEnvelope<T>(value: unknown): T {
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'result' in (value as Record<string, unknown>) &&
|
||||
'logs' in (value as Record<string, unknown>) &&
|
||||
Array.isArray((value as { logs: unknown }).logs)
|
||||
) {
|
||||
return (value as { result: T }).result;
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export interface RecallCalendarStatus {
|
||||
/** Whether the backend has the Recall calendar path enabled. */
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface RecallCalendarConnect {
|
||||
connectUrl: string;
|
||||
}
|
||||
|
||||
export interface RecallCalendarDisconnect {
|
||||
disconnected: boolean;
|
||||
}
|
||||
|
||||
/** Start the OAuth flow; returns the Google consent URL to open in a browser. */
|
||||
export async function connect(): Promise<RecallCalendarConnect> {
|
||||
const raw = await callCoreRpc<unknown>({ method: 'openhuman.recall_calendar_connect' });
|
||||
return unwrapCliEnvelope<RecallCalendarConnect>(raw);
|
||||
}
|
||||
|
||||
/** Current connection status for the settings UI. */
|
||||
export async function status(): Promise<RecallCalendarStatus> {
|
||||
const raw = await callCoreRpc<unknown>({ method: 'openhuman.recall_calendar_status' });
|
||||
return unwrapCliEnvelope<RecallCalendarStatus>(raw);
|
||||
}
|
||||
|
||||
/** Disconnect the user's Google Calendar from Recall. */
|
||||
export async function disconnect(): Promise<RecallCalendarDisconnect> {
|
||||
const raw = await callCoreRpc<unknown>({ method: 'openhuman.recall_calendar_disconnect' });
|
||||
return unwrapCliEnvelope<RecallCalendarDisconnect>(raw);
|
||||
}
|
||||
@@ -121,6 +121,7 @@ describe('tauriCommands/config', () => {
|
||||
auto_summarize_policy: 'never',
|
||||
listen_only_default: false,
|
||||
ingest_backend_transcripts: true,
|
||||
calendar_provider: 'recall',
|
||||
},
|
||||
logs: [],
|
||||
});
|
||||
@@ -133,6 +134,7 @@ describe('tauriCommands/config', () => {
|
||||
expect(out.result.auto_summarize_policy).toBe('never');
|
||||
expect(out.result.listen_only_default).toBe(false);
|
||||
expect(out.result.ingest_backend_transcripts).toBe(true);
|
||||
expect(out.result.calendar_provider).toBe('recall');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -798,6 +798,10 @@ export interface MeetSettings {
|
||||
* Decoupled from the heartbeat reminder-notification toggle.
|
||||
*/
|
||||
watch_calendar: boolean;
|
||||
/** Calendar detection source for Google Meet: composio (default) | recall. */
|
||||
calendar_provider?: 'composio' | 'recall';
|
||||
/** The user's meeting display name, reused as the bot's reply anchor on join. */
|
||||
reply_display_name?: string;
|
||||
}
|
||||
|
||||
/** Partial update accepted by `openhuman.config_update_meet_settings`. */
|
||||
@@ -811,6 +815,10 @@ export interface MeetSettingsUpdate {
|
||||
platform_auto_join_policies?: Record<string, MeetAutoJoinPolicy>;
|
||||
/** Master switch for calendar-driven auto-join / ask-to-join. */
|
||||
watch_calendar?: boolean;
|
||||
/** Calendar detection source for Google Meet: composio (default) | recall. */
|
||||
calendar_provider?: 'composio' | 'recall';
|
||||
/** The user's meeting display name, reused as the bot's reply anchor on join. */
|
||||
reply_display_name?: string;
|
||||
}
|
||||
|
||||
export async function openhumanUpdateMeetSettings(
|
||||
|
||||
@@ -114,6 +114,9 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
controllers.extend(crate::openhuman::audio_toolkit::all_audio_toolkit_registered_controllers());
|
||||
// Composio integration controllers
|
||||
controllers.extend(crate::openhuman::composio::all_composio_registered_controllers());
|
||||
// Recall.ai Calendar V1 (backend-proxied) controllers
|
||||
controllers
|
||||
.extend(crate::openhuman::recall_calendar::all_recall_calendar_registered_controllers());
|
||||
// Scheduled job management
|
||||
controllers.extend(crate::openhuman::cron::all_cron_registered_controllers());
|
||||
// Proactive task ingestion from external tools (github/notion/linear/clickup)
|
||||
@@ -363,6 +366,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas.extend(crate::openhuman::app_state::all_app_state_controller_schemas());
|
||||
schemas.extend(crate::openhuman::audio_toolkit::all_audio_toolkit_controller_schemas());
|
||||
schemas.extend(crate::openhuman::composio::all_composio_controller_schemas());
|
||||
schemas.extend(crate::openhuman::recall_calendar::all_recall_calendar_controller_schemas());
|
||||
schemas.extend(crate::openhuman::cron::all_cron_controller_schemas());
|
||||
schemas.extend(crate::openhuman::task_sources::all_task_sources_controller_schemas());
|
||||
schemas.extend(crate::openhuman::dashboard::all_dashboard_controller_schemas());
|
||||
|
||||
@@ -86,6 +86,20 @@ impl EventHandler for MeetCalendarSubscriber {
|
||||
return;
|
||||
}
|
||||
|
||||
// If Recall.ai calendar is the active source, ignore Composio calendar
|
||||
// triggers so meetings aren't double-detected.
|
||||
if let Ok(config) = crate::openhuman::config::rpc::load_config_with_timeout().await {
|
||||
if matches!(
|
||||
config.meet.calendar_provider,
|
||||
crate::openhuman::config::schema::CalendarProvider::Recall
|
||||
) {
|
||||
tracing::debug!(
|
||||
"[meet:calendar] ignoring googlecalendar trigger (recall provider active)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
trigger = %trigger,
|
||||
"[meet:calendar] received googlecalendar trigger"
|
||||
@@ -318,18 +332,12 @@ pub async fn handle_calendar_meeting_candidate(
|
||||
|
||||
// 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.
|
||||
// the bot still knows who to reply to. The user's saved Meetings-page
|
||||
// display name is applied as a final fallback below, once config is loaded.
|
||||
let owner_display_name = owner_display_name
|
||||
.or_else(fallback_owner_from_account)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let has_anchor = owner_display_name.is_some();
|
||||
if !has_anchor {
|
||||
tracing::warn!(
|
||||
meet_url = %meet_url,
|
||||
"[meet:calendar] no reply anchor resolved — auto-join will fall back to listen-only"
|
||||
);
|
||||
}
|
||||
|
||||
// Check the auto-join policy.
|
||||
let config = match config_rpc::load_config_with_timeout().await {
|
||||
@@ -353,6 +361,22 @@ pub async fn handle_calendar_meeting_candidate(
|
||||
}
|
||||
};
|
||||
|
||||
// Final anchor fallback: the display name the user saved on the Meetings
|
||||
// page. Applied after config load so a Recall-connected user (whose
|
||||
// heartbeat events carry no `self` attendee) still gets a reply anchor and
|
||||
// can speak — instead of being force-downgraded to listen-only.
|
||||
let owner_display_name = owner_display_name.or_else(|| {
|
||||
let saved = config.meet.reply_display_name.trim();
|
||||
(!saved.is_empty()).then(|| saved.to_string())
|
||||
});
|
||||
let has_anchor = owner_display_name.is_some();
|
||||
if !has_anchor {
|
||||
tracing::warn!(
|
||||
meet_url = %meet_url,
|
||||
"[meet:calendar] no reply anchor resolved — auto-join will fall back to listen-only"
|
||||
);
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -413,6 +437,16 @@ pub async fn handle_calendar_meeting_candidate(
|
||||
);
|
||||
}
|
||||
|
||||
// Active mode (listen_only = false) enables in-call agency for THIS
|
||||
// meeting so wake-word commands are actually dispatched — mirrors the
|
||||
// manual `handle_join` path (ops.rs). Without this the auto-joined bot
|
||||
// transcribes fine but the core drops every in-call reply request
|
||||
// (`config.meet.enable_in_call_agency` defaults off), so the bot never
|
||||
// speaks even after "Hey Tiny".
|
||||
if !listen_only {
|
||||
super::in_call::mark_meeting_active(Some(&correlation_id)).await;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -784,6 +818,13 @@ fn build_action_payload(
|
||||
/// Pure function so the `respondToParticipant` anchor wiring is unit-testable
|
||||
/// without a live socket. A `None`/empty owner omits `respondToParticipant`,
|
||||
/// which the backend bot treats as "respond to everyone".
|
||||
///
|
||||
/// Active mode (`listen_only = false`) also sets `wakePhrase` so the backend
|
||||
/// only forwards captions that address the bot (`"Hey Tiny, …"`) as in-call
|
||||
/// commands. Without it the bot joined `bot:join` with no wake gate, so every
|
||||
/// caption from `respondToParticipant` would be treated as a command — matching
|
||||
/// the manual reply-mode join (`ops::build_notification_join_map` /
|
||||
/// `MeetComposer`), which both pass a wake phrase.
|
||||
fn build_auto_join_payload(
|
||||
meet_url: &str,
|
||||
platform: &str,
|
||||
@@ -802,6 +843,12 @@ fn build_auto_join_payload(
|
||||
if let Some(owner) = owner_display_name.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
map.insert("respondToParticipant".to_string(), serde_json::json!(owner));
|
||||
}
|
||||
// Reply mode: gate in-call agency behind the "Hey Tiny" wake phrase so
|
||||
// the bot only reacts when addressed, never to every caption. The bot
|
||||
// joins as "Tiny" (see `displayName` above), so the phrase matches.
|
||||
if !listen_only {
|
||||
map.insert("wakePhrase".to_string(), serde_json::json!("Hey Tiny"));
|
||||
}
|
||||
}
|
||||
payload
|
||||
}
|
||||
@@ -1134,6 +1181,8 @@ mod tests {
|
||||
assert_eq!(p["displayName"], json!("Tiny"));
|
||||
assert_eq!(p["listenOnly"], json!(false));
|
||||
assert_eq!(p["correlationId"], json!("corr-1"));
|
||||
// Active mode gates in-call agency behind the "Hey Tiny" wake phrase.
|
||||
assert_eq!(p["wakePhrase"], json!("Hey Tiny"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1143,6 +1192,23 @@ mod tests {
|
||||
assert!(p.get("respondToParticipant").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_join_payload_sets_wake_phrase_only_in_active_mode() {
|
||||
// Listen-only auto-join: no wake phrase (bot never speaks anyway).
|
||||
let listen =
|
||||
build_auto_join_payload("https://meet.google.com/abc", "gmeet", "corr-1", true, None);
|
||||
assert!(listen.get("wakePhrase").is_none());
|
||||
// Active auto-join: wake phrase gates which captions become commands.
|
||||
let active = build_auto_join_payload(
|
||||
"https://meet.google.com/abc",
|
||||
"gmeet",
|
||||
"corr-1",
|
||||
false,
|
||||
Some("Aditya"),
|
||||
);
|
||||
assert_eq!(active["wakePhrase"], json!("Hey Tiny"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_join_payload_omits_respond_to_participant_when_blank() {
|
||||
let p = build_auto_join_payload(
|
||||
@@ -1330,4 +1396,133 @@ mod tests {
|
||||
"calendar_event_id must survive store round-trip (finding #3)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── reply-anchor fallback + Always in-call agency ───────────
|
||||
//
|
||||
// These exercise `handle_calendar_meeting_candidate` end-to-end against a
|
||||
// throwaway workspace so the changed config-driven branches actually run:
|
||||
// 1. the reply-anchor fallback to `config.meet.reply_display_name` when no
|
||||
// per-payload/account owner resolves, and
|
||||
// 2. `super::in_call::mark_meeting_active` on a reply-mode `Always` join.
|
||||
// They serialize on `TEST_ENV_LOCK` because they override the process-global
|
||||
// `OPENHUMAN_WORKSPACE` (same pattern as the config/ops tests).
|
||||
|
||||
/// RAII guard that points `OPENHUMAN_WORKSPACE` at a temp dir for the
|
||||
/// duration of a test and restores the prior value on drop.
|
||||
struct WorkspaceEnvGuard {
|
||||
previous: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl WorkspaceEnvGuard {
|
||||
fn set(path: &std::path::Path) -> Self {
|
||||
let previous = std::env::var_os("OPENHUMAN_WORKSPACE");
|
||||
std::env::set_var("OPENHUMAN_WORKSPACE", path);
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WorkspaceEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.previous.take() {
|
||||
Some(value) => std::env::set_var("OPENHUMAN_WORKSPACE", value),
|
||||
None => std::env::remove_var("OPENHUMAN_WORKSPACE"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn always_join_with_saved_reply_anchor_marks_meeting_active() {
|
||||
use crate::openhuman::config::schema::AutoJoinPolicy;
|
||||
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let _env = WorkspaceEnvGuard::set(tmp.path());
|
||||
|
||||
// Config: auto-join Always, reply-mode default (listen_only_default =
|
||||
// false), and a saved Meetings-page display name. A blank owner is
|
||||
// passed: it trims away to None *before* the account-identity peek is
|
||||
// consulted, so the ONLY way an anchor can resolve is the new
|
||||
// `config.meet.reply_display_name` fallback — proving `has_anchor` is
|
||||
// computed from the final value (and keeping the test independent of any
|
||||
// globally cached account identity).
|
||||
let mut cfg = crate::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.unwrap();
|
||||
cfg.meet.auto_join_policy = AutoJoinPolicy::Always;
|
||||
cfg.meet.listen_only_default = false;
|
||||
cfg.meet.reply_display_name = "Saved Anchor".to_string();
|
||||
cfg.save().await.unwrap();
|
||||
|
||||
let meet_url = "https://meet.google.com/always-anchor".to_string();
|
||||
let owned = handle_calendar_meeting_candidate(
|
||||
meet_url.clone(),
|
||||
"Anchored".to_string(),
|
||||
Some(" ".to_string()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
// Always never surfaces its own actionable card.
|
||||
assert!(!owned);
|
||||
|
||||
// The saved anchor made this a reply-mode join, so in-call agency must be
|
||||
// enabled for THIS meeting (mirrors the manual handle_join path).
|
||||
let session =
|
||||
crate::openhuman::agent_meetings::store::get_session_by_meet_url(&cfg, &meet_url)
|
||||
.unwrap()
|
||||
.expect("always-join must persist a session");
|
||||
assert!(
|
||||
crate::openhuman::agent_meetings::in_call::is_meeting_active(Some(session.id.as_str()))
|
||||
.await,
|
||||
"reply-mode auto-join must mark the meeting in-call-active"
|
||||
);
|
||||
// Don't leak the global active-set entry into sibling tests.
|
||||
crate::openhuman::agent_meetings::in_call::clear_meeting_agent(Some(session.id.as_str()))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn always_join_without_reply_anchor_stays_listen_only_and_unmarked() {
|
||||
use crate::openhuman::config::schema::AutoJoinPolicy;
|
||||
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let _env = WorkspaceEnvGuard::set(tmp.path());
|
||||
|
||||
// Always policy, reply-mode default on, but NO saved reply anchor. A
|
||||
// blank owner trims to None (short-circuiting the account-identity peek),
|
||||
// and the empty `reply_display_name` fallback also yields None → has_anchor
|
||||
// is false → the join is force-downgraded to listen-only, so in-call
|
||||
// agency must NOT be enabled.
|
||||
let mut cfg = crate::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.unwrap();
|
||||
cfg.meet.auto_join_policy = AutoJoinPolicy::Always;
|
||||
cfg.meet.listen_only_default = false;
|
||||
cfg.meet.reply_display_name = String::new();
|
||||
cfg.save().await.unwrap();
|
||||
|
||||
let meet_url = "https://meet.google.com/always-no-anchor".to_string();
|
||||
let owned = handle_calendar_meeting_candidate(
|
||||
meet_url.clone(),
|
||||
"No anchor".to_string(),
|
||||
Some(" ".to_string()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(!owned);
|
||||
|
||||
let session =
|
||||
crate::openhuman::agent_meetings::store::get_session_by_meet_url(&cfg, &meet_url)
|
||||
.unwrap()
|
||||
.expect("always-join persists a session even when listen-only");
|
||||
assert!(
|
||||
!crate::openhuman::agent_meetings::in_call::is_meeting_active(Some(
|
||||
session.id.as_str()
|
||||
))
|
||||
.await,
|
||||
"listen-only auto-join must not enable in-call agency"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1103,6 +1103,13 @@ pub async fn handle_notification_action(params: Map<String, Value>) -> Result<Va
|
||||
.ok_or_else(|| "[agent_meetings] payload.meetUrl is required".to_string())?;
|
||||
let config = crate::openhuman::config::ops::load_config_with_timeout().await?;
|
||||
|
||||
// Final anchor fallback: the display name the user saved on the
|
||||
// Meetings page, so the "Join & reply" button honors it too.
|
||||
let respond_to_participant = respond_to_participant.or_else(|| {
|
||||
let saved = config.meet.reply_display_name.trim();
|
||||
(!saved.is_empty()).then(|| saved.to_string())
|
||||
});
|
||||
|
||||
if action_id == "always_join" {
|
||||
let mut cfg = config.clone();
|
||||
cfg.meet.auto_join_policy =
|
||||
@@ -1644,6 +1651,46 @@ mod tests {
|
||||
assert!(err.contains("meetUrl"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_join_falls_back_to_saved_reply_display_name() {
|
||||
// Join branch anchor resolution: when the notification payload carries
|
||||
// no `respondToParticipant` and no cached account identity resolves, the
|
||||
// reply anchor must fall back to the display name the user saved on the
|
||||
// Meetings page (`config.meet.reply_display_name`). We can't inspect the
|
||||
// resolved anchor without a live socket, but the handler must load
|
||||
// config, run the fallback, build the join map, and reach `handle_join`
|
||||
// — which then fails only at the (absent) socket. Reaching that error
|
||||
// proves the changed fallback lines executed.
|
||||
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let _env = EnvGuard::set_workspace(tmp.path());
|
||||
|
||||
let mut cfg = crate::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.unwrap();
|
||||
cfg.meet.reply_display_name = "Saved Anchor".to_string();
|
||||
cfg.save().await.unwrap();
|
||||
|
||||
let mut params = Map::new();
|
||||
params.insert("action_id".to_string(), json!("join_active"));
|
||||
params.insert(
|
||||
"payload".to_string(),
|
||||
json!({
|
||||
"meetingId": "m-anchor-1",
|
||||
"meetUrl": "https://meet.google.com/anchor-fallback",
|
||||
// deliberately no respondToParticipant → config fallback path
|
||||
}),
|
||||
);
|
||||
|
||||
let err = handle_notification_action(params).await.unwrap_err();
|
||||
assert!(
|
||||
err.contains("socket not connected"),
|
||||
"expected socket error after anchor fallback, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_action_skip_without_meeting_id_is_ok() {
|
||||
// No meetingId → nothing to update; must succeed without touching
|
||||
|
||||
@@ -33,6 +33,48 @@ use super::types::UpcomingMeeting;
|
||||
pub(crate) const DEFAULT_LOOKAHEAD_MINUTES: u32 = 480; // 8 hours
|
||||
pub(crate) const DEFAULT_LIMIT: u32 = 20;
|
||||
|
||||
/// Fetch upcoming meetings from Recall.ai Calendar V1 (backend-proxied) and map
|
||||
/// them through the shared `extract_upcoming_meetings` parser so the RPC
|
||||
/// response shape matches the Composio path exactly. Degrades to an empty list
|
||||
/// when the calendar is not connected — same contract as the Composio branch.
|
||||
async fn fetch_recall_upcoming(
|
||||
config: &Config,
|
||||
now: DateTime<Utc>,
|
||||
end_window: DateTime<Utc>,
|
||||
limit: u32,
|
||||
join_policy: &str,
|
||||
) -> Result<Vec<UpcomingMeeting>, String> {
|
||||
let meetings = match crate::openhuman::recall_calendar::ops::fetch_recall_meetings(config).await
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::info!(error = %e, "[meet:upcoming] recall calendar unavailable — skipping");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
let out = build_recall_upcoming(&meetings, now, end_window, limit, join_policy);
|
||||
tracing::info!(total = out.len(), "[meet:upcoming] recall fetch complete");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Pure transform: Recall meetings → `UpcomingMeeting`s via the shared parser,
|
||||
/// then soonest-first + limit. Split out so it is unit-testable without a
|
||||
/// backend session.
|
||||
fn build_recall_upcoming(
|
||||
meetings: &[crate::openhuman::recall_calendar::types::RecallMeeting],
|
||||
now: DateTime<Utc>,
|
||||
end_window: DateTime<Utc>,
|
||||
limit: u32,
|
||||
join_policy: &str,
|
||||
) -> Vec<UpcomingMeeting> {
|
||||
let data = crate::openhuman::recall_calendar::ops::meetings_to_gcal_json(meetings);
|
||||
let mut seen_ids = HashSet::new();
|
||||
let mut out = extract_upcoming_meetings(&data, now, end_window, join_policy, &mut seen_ids);
|
||||
out.sort_by_key(|m| m.start_time_ms);
|
||||
out.truncate(limit as usize);
|
||||
out
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -62,6 +104,23 @@ pub(crate) async fn fetch_upcoming_meetings(
|
||||
"[meet:upcoming] fetch start"
|
||||
);
|
||||
|
||||
// Recall.ai Calendar V1 as the (less-invasive) meeting source when
|
||||
// selected. Also auto-detect a connected Recall calendar so the meetings
|
||||
// page does not depend on the Skills-page settings card being mounted long
|
||||
// enough to sync `meet.calendar_provider`.
|
||||
let recall_selected = matches!(
|
||||
config.meet.calendar_provider,
|
||||
crate::openhuman::config::schema::CalendarProvider::Recall
|
||||
);
|
||||
let recall_connected = if recall_selected {
|
||||
true
|
||||
} else {
|
||||
crate::openhuman::recall_calendar::ops::is_connected_cached(config).await
|
||||
};
|
||||
if recall_connected {
|
||||
return fetch_recall_upcoming(config, now, end_window, limit, join_policy).await;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -510,6 +569,51 @@ mod tests {
|
||||
use crate::openhuman::agent_meetings::ops::infer_platform_from_url;
|
||||
use serde_json::json;
|
||||
|
||||
fn recall_meeting(
|
||||
id: &str,
|
||||
url: &str,
|
||||
mins: i64,
|
||||
) -> crate::openhuman::recall_calendar::types::RecallMeeting {
|
||||
crate::openhuman::recall_calendar::types::RecallMeeting {
|
||||
id: id.to_string(),
|
||||
title: Some("Recall sync".to_string()),
|
||||
meeting_url: Some(url.to_string()),
|
||||
start_time: Some((Utc::now() + chrono::Duration::minutes(mins)).to_rfc3339()),
|
||||
end_time: None,
|
||||
platform: Some("google_meet".to_string()),
|
||||
bot_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_recall_upcoming_maps_and_orders() {
|
||||
let now = Utc::now();
|
||||
let end = now + chrono::Duration::hours(3);
|
||||
let meetings = vec![
|
||||
recall_meeting("r-2", "https://meet.google.com/bbb-bbbb-bbb", 90),
|
||||
recall_meeting("r-1", "https://meet.google.com/aaa-aaaa-aaa", 30),
|
||||
];
|
||||
let out = build_recall_upcoming(&meetings, now, end, 10, "auto");
|
||||
assert_eq!(out.len(), 2);
|
||||
// Soonest-first ordering preserved.
|
||||
assert_eq!(
|
||||
out[0].meet_url.as_deref(),
|
||||
Some("https://meet.google.com/aaa-aaaa-aaa")
|
||||
);
|
||||
assert_eq!(out[0].join_policy, "auto");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_upcoming_routes_to_recall_and_degrades_empty() {
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.meet.calendar_provider = crate::openhuman::config::schema::CalendarProvider::Recall;
|
||||
// No backend session in tests → recall fetch fails → empty (not an error).
|
||||
let out = fetch_upcoming_meetings(&config, 60, 10, "ask")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
fn window() -> (DateTime<Utc>, DateTime<Utc>) {
|
||||
let now = Utc::now();
|
||||
let end = now + chrono::Duration::hours(8);
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::openhuman::config::schema::CalendarProvider;
|
||||
use crate::openhuman::config::{AutoJoinPolicy, AutoSummarizePolicy, Config};
|
||||
use crate::openhuman::screen_intelligence;
|
||||
use crate::rpc::RpcOutcome;
|
||||
@@ -53,6 +54,11 @@ pub struct MeetSettingsPatch {
|
||||
/// 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>,
|
||||
/// Calendar detection source: `Composio` (default) or `Recall`. Flipped to
|
||||
/// `Recall` when the user connects a calendar via Recall.ai.
|
||||
pub calendar_provider: Option<CalendarProvider>,
|
||||
/// User's meeting display name, reused as the bot's reply anchor on join.
|
||||
pub reply_display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -268,6 +274,12 @@ pub async fn apply_meet_settings(
|
||||
if let Some(watch_calendar) = update.watch_calendar {
|
||||
config.meet.watch_calendar = watch_calendar;
|
||||
}
|
||||
if let Some(provider) = update.calendar_provider {
|
||||
config.meet.calendar_provider = provider;
|
||||
}
|
||||
if let Some(name) = update.reply_display_name {
|
||||
config.meet.reply_display_name = name.trim().to_string();
|
||||
}
|
||||
config.save().await.map_err(|e| e.to_string())?;
|
||||
let snapshot = snapshot_config_json(config)?;
|
||||
Ok(RpcOutcome::new(
|
||||
|
||||
@@ -1249,6 +1249,8 @@ async fn apply_meet_settings_updates_all_meeting_assistant_fields() {
|
||||
auto_summarize_policy: Some(AutoSummarizePolicy::Never),
|
||||
listen_only_default: Some(false),
|
||||
ingest_backend_transcripts: Some(true),
|
||||
// Whitespace is trimmed on apply so the anchor match is clean.
|
||||
reply_display_name: Some(" Alex Kim ".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -1258,6 +1260,7 @@ async fn apply_meet_settings_updates_all_meeting_assistant_fields() {
|
||||
assert_eq!(cfg.meet.auto_summarize_policy, AutoSummarizePolicy::Never);
|
||||
assert!(!cfg.meet.listen_only_default);
|
||||
assert!(cfg.meet.ingest_backend_transcripts);
|
||||
assert_eq!(cfg.meet.reply_display_name, "Alex Kim");
|
||||
|
||||
// No-op patch must leave the prior values untouched.
|
||||
let _ = apply_meet_settings(&mut cfg, MeetSettingsPatch::default())
|
||||
@@ -1267,6 +1270,7 @@ async fn apply_meet_settings_updates_all_meeting_assistant_fields() {
|
||||
assert_eq!(cfg.meet.auto_summarize_policy, AutoSummarizePolicy::Never);
|
||||
assert!(!cfg.meet.listen_only_default);
|
||||
assert!(cfg.meet.ingest_backend_transcripts);
|
||||
assert_eq!(cfg.meet.reply_display_name, "Alex Kim");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -47,6 +47,22 @@ impl Default for AutoSummarizePolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which calendar data source feeds Google Meet detection and auto-join.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CalendarProvider {
|
||||
/// Composio-based Google Calendar sync (default; broad OAuth scopes).
|
||||
Composio,
|
||||
/// Recall.ai Calendar V1 OAuth (less-invasive: read-only events + email).
|
||||
Recall,
|
||||
}
|
||||
|
||||
impl Default for CalendarProvider {
|
||||
fn default() -> Self {
|
||||
Self::Composio
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(default)]
|
||||
pub struct MeetConfig {
|
||||
@@ -102,6 +118,21 @@ pub struct MeetConfig {
|
||||
/// Off by default.
|
||||
#[serde(default)]
|
||||
pub watch_calendar: bool,
|
||||
|
||||
/// Which calendar source drives Google Meet detection and auto-join.
|
||||
/// `Composio` (default) uses Composio Google Calendar; `Recall` uses
|
||||
/// Recall.ai Calendar V1 (less-invasive scopes). Flipped to `Recall`
|
||||
/// automatically when the user connects their calendar via Recall.
|
||||
#[serde(default)]
|
||||
pub calendar_provider: CalendarProvider,
|
||||
|
||||
/// The user's display name as it appears in meetings (e.g. their Google
|
||||
/// Meet caption label). Set once from the Meetings page and reused as the
|
||||
/// bot's reply anchor (`respondToParticipant`) on every join — auto-join and
|
||||
/// manual. Empty = no saved anchor (bot falls back to the calendar `self`
|
||||
/// attendee / account identity, and stays listen-only if none resolves).
|
||||
#[serde(default)]
|
||||
pub reply_display_name: String,
|
||||
}
|
||||
|
||||
fn default_auto_orchestrator_handoff() -> bool {
|
||||
@@ -136,6 +167,8 @@ impl Default for MeetConfig {
|
||||
in_call_streaming: true,
|
||||
platform_auto_join_policies: HashMap::new(),
|
||||
watch_calendar: false,
|
||||
calendar_provider: CalendarProvider::default(),
|
||||
reply_display_name: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,6 +178,17 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn calendar_provider_defaults_and_parses() {
|
||||
assert_eq!(
|
||||
MeetConfig::default().calendar_provider,
|
||||
CalendarProvider::Composio
|
||||
);
|
||||
let cfg: MeetConfig =
|
||||
serde_json::from_value(json!({ "calendar_provider": "recall" })).unwrap();
|
||||
assert_eq!(cfg.calendar_provider, CalendarProvider::Recall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_disables_handoff() {
|
||||
let cfg = MeetConfig::default();
|
||||
@@ -226,6 +270,8 @@ mod tests {
|
||||
in_call_streaming: false,
|
||||
platform_auto_join_policies: HashMap::new(),
|
||||
watch_calendar: true,
|
||||
calendar_provider: CalendarProvider::Recall,
|
||||
reply_display_name: "Alex Kim".to_string(),
|
||||
};
|
||||
let s = serde_json::to_string(&original).unwrap();
|
||||
let back: MeetConfig = serde_json::from_str(&s).unwrap();
|
||||
@@ -236,6 +282,8 @@ mod tests {
|
||||
assert!(!back.listen_only_default);
|
||||
assert!(back.enable_in_call_agency);
|
||||
assert!(back.watch_calendar);
|
||||
assert_eq!(back.calendar_provider, CalendarProvider::Recall);
|
||||
assert_eq!(back.reply_display_name, "Alex Kim");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -66,7 +66,7 @@ pub use heartbeat_cron::{CronConfig, HeartbeatConfig, SubconsciousMode};
|
||||
pub use identity_cost::{CostConfig, ModelPricing};
|
||||
pub use learning::{LearningConfig, ReflectionSource};
|
||||
pub use local_ai::{LocalAiConfig, LocalAiUsage};
|
||||
pub use meet::{AutoJoinPolicy, AutoSummarizePolicy, MeetConfig};
|
||||
pub use meet::{AutoJoinPolicy, AutoSummarizePolicy, CalendarProvider, MeetConfig};
|
||||
pub use node::NodeConfig;
|
||||
pub use observability::{AgentTracingBackend, AgentTracingConfig, ObservabilityConfig};
|
||||
pub use proxy::{
|
||||
|
||||
@@ -3,6 +3,7 @@ use serde_json::{Map, Value};
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::ControllerSchema;
|
||||
use crate::openhuman::config::rpc as config_rpc;
|
||||
use crate::openhuman::config::schema::CalendarProvider;
|
||||
use crate::openhuman::config::{AutoJoinPolicy, AutoSummarizePolicy};
|
||||
|
||||
use super::helpers::{
|
||||
@@ -701,6 +702,17 @@ fn handle_update_meet_settings(params: Map<String, Value>) -> ControllerFuture {
|
||||
platform_auto_join_policies.as_ref().map(|m| m.len()),
|
||||
update.watch_calendar,
|
||||
);
|
||||
let calendar_provider = match update.calendar_provider.as_deref() {
|
||||
Some("composio") => Some(CalendarProvider::Composio),
|
||||
Some("recall") => Some(CalendarProvider::Recall),
|
||||
None => None,
|
||||
Some(other) => {
|
||||
log::warn!("[config][rpc] update_meet_settings invalid calendar_provider: {other}");
|
||||
return Err(format!(
|
||||
"invalid calendar_provider: {other} (valid: composio, recall)"
|
||||
));
|
||||
}
|
||||
};
|
||||
let patch = config_rpc::MeetSettingsPatch {
|
||||
auto_orchestrator_handoff: update.auto_orchestrator_handoff,
|
||||
auto_join_policy,
|
||||
@@ -709,6 +721,8 @@ fn handle_update_meet_settings(params: Map<String, Value>) -> ControllerFuture {
|
||||
ingest_backend_transcripts: update.ingest_backend_transcripts,
|
||||
platform_auto_join_policies,
|
||||
watch_calendar: update.watch_calendar,
|
||||
calendar_provider,
|
||||
reply_display_name: update.reply_display_name,
|
||||
};
|
||||
match config_rpc::load_and_apply_meet_settings(patch).await {
|
||||
Ok(outcome) => {
|
||||
@@ -736,12 +750,13 @@ 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={} watch_calendar={}",
|
||||
"[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={} calendar_provider={:?}",
|
||||
config.meet.auto_join_policy,
|
||||
config.meet.auto_summarize_policy,
|
||||
config.meet.listen_only_default,
|
||||
config.meet.ingest_backend_transcripts,
|
||||
config.meet.watch_calendar,
|
||||
config.meet.calendar_provider,
|
||||
);
|
||||
// Enums serialize via `#[serde(rename_all = "snake_case")]` →
|
||||
// "ask_each_time"/"always"/"never" and "ask"/"always"/"never".
|
||||
@@ -753,6 +768,8 @@ fn handle_get_meet_settings(_params: Map<String, Value>) -> ControllerFuture {
|
||||
"ingest_backend_transcripts": config.meet.ingest_backend_transcripts,
|
||||
"platform_auto_join_policies": config.meet.platform_auto_join_policies,
|
||||
"watch_calendar": config.meet.watch_calendar,
|
||||
"calendar_provider": config.meet.calendar_provider,
|
||||
"reply_display_name": config.meet.reply_display_name,
|
||||
});
|
||||
to_json(RpcOutcome::new(
|
||||
result,
|
||||
|
||||
@@ -130,6 +130,10 @@ pub(super) struct MeetSettingsUpdate {
|
||||
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>,
|
||||
/// Calendar detection source as a string: `composio` | `recall`.
|
||||
pub(super) calendar_provider: Option<String>,
|
||||
/// User's meeting display name, reused as the bot's reply anchor.
|
||||
pub(super) reply_display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -464,6 +464,14 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
"watch_calendar",
|
||||
"When true, the heartbeat watches the connected calendar to drive auto-join / ask-to-join, independent of meeting reminder notifications.",
|
||||
),
|
||||
optional_string(
|
||||
"calendar_provider",
|
||||
"Calendar detection source for Google Meet: composio | recall.",
|
||||
),
|
||||
optional_string(
|
||||
"reply_display_name",
|
||||
"The user's meeting display name, reused as the bot's reply anchor on join.",
|
||||
),
|
||||
],
|
||||
outputs: vec![json_output("snapshot", "Updated config snapshot.")],
|
||||
},
|
||||
@@ -515,6 +523,18 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
comment: "Whether the heartbeat watches the calendar to drive auto-join / ask.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "calendar_provider",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Calendar detection source for Google Meet: composio | recall.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "reply_display_name",
|
||||
ty: TypeSchema::String,
|
||||
comment: "The user's meeting display name, reused as the bot's reply anchor on join.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
"update_search_settings" => ControllerSchema {
|
||||
|
||||
@@ -93,6 +93,7 @@ pub mod plan_review;
|
||||
pub mod profiles;
|
||||
pub mod prompt_injection;
|
||||
pub mod provider_surfaces;
|
||||
pub mod recall_calendar;
|
||||
pub mod redirect_links;
|
||||
pub mod referral;
|
||||
pub mod routing;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Recall.ai Calendar V1 integration (backend-proxied).
|
||||
//!
|
||||
//! A less-invasive replacement for Composio-based Google Calendar sync as the
|
||||
//! Google Meet detection source. The user connects their Google Calendar once
|
||||
//! via Recall's hosted OAuth (only `calendar.events.readonly` + `userinfo.email`
|
||||
//! scopes); the core reads upcoming meetings through the openhuman backend and
|
||||
//! feeds them into the existing meeting auto-join path. Bots are still scheduled
|
||||
//! by us (credit-gated, per-user mascot), not by Recall.
|
||||
//!
|
||||
//! Selected via `config.meet.calendar_provider == Recall`.
|
||||
|
||||
pub mod ops;
|
||||
pub mod schemas;
|
||||
pub mod types;
|
||||
|
||||
pub use schemas::{
|
||||
all_controller_schemas as all_recall_calendar_controller_schemas,
|
||||
all_registered_controllers as all_recall_calendar_registered_controllers,
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Business logic for the `recall_calendar` domain.
|
||||
//!
|
||||
//! The core never talks to Recall.ai directly — every call proxies to the
|
||||
//! openhuman backend's `/agent-integrations/recall-calendar/*` routes through
|
||||
//! the shared [`IntegrationClient`], which attaches the app-session JWT and
|
||||
//! unwraps the `{ success, data }` envelope (including 401 → session-expiry
|
||||
//! handling). This mirrors the Composio domain's backend-proxied design.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::integrations::client::IntegrationClient;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::types::{
|
||||
RecallCalendarConnect, RecallCalendarDisconnect, RecallCalendarStatus, RecallMeeting,
|
||||
RecallMeetingsResponse,
|
||||
};
|
||||
|
||||
const CONNECT_PATH: &str = "/agent-integrations/recall-calendar/connect";
|
||||
const STATUS_PATH: &str = "/agent-integrations/recall-calendar/status";
|
||||
const DISCONNECT_PATH: &str = "/agent-integrations/recall-calendar/disconnect";
|
||||
const MEETINGS_PATH: &str = "/agent-integrations/recall-calendar/meetings";
|
||||
|
||||
fn recall_client(config: &Config) -> Result<Arc<IntegrationClient>, String> {
|
||||
crate::openhuman::integrations::build_client(config)
|
||||
.ok_or_else(|| "[recall_calendar] backend client unavailable (no session)".to_string())
|
||||
}
|
||||
|
||||
/// Start the Recall Calendar V1 OAuth flow; returns the Google consent URL.
|
||||
pub async fn connect(config: &Config) -> Result<RpcOutcome<RecallCalendarConnect>, String> {
|
||||
tracing::debug!("[recall_calendar] rpc connect");
|
||||
let client = recall_client(config)?;
|
||||
let resp = client
|
||||
.post::<RecallCalendarConnect>(CONNECT_PATH, &json!({}))
|
||||
.await
|
||||
.map_err(|e| format!("[recall_calendar] connect failed: {e:#}"))?;
|
||||
// Connection state is about to change — drop the memoized probe so the
|
||||
// heartbeat/meetings fallback re-detects on its next tick instead of
|
||||
// serving a stale "not connected" for up to RECALL_DETECT_TTL.
|
||||
invalidate_detect_cache();
|
||||
Ok(RpcOutcome::new(
|
||||
resp,
|
||||
vec!["recall_calendar: connect flow started".to_string()],
|
||||
))
|
||||
}
|
||||
|
||||
/// Fetch the user's Recall calendar connection status.
|
||||
pub async fn status(config: &Config) -> Result<RpcOutcome<RecallCalendarStatus>, String> {
|
||||
tracing::debug!("[recall_calendar] rpc status");
|
||||
let client = recall_client(config)?;
|
||||
let resp = client
|
||||
.get::<RecallCalendarStatus>(STATUS_PATH)
|
||||
.await
|
||||
.map_err(|e| format!("[recall_calendar] status failed: {e:#}"))?;
|
||||
Ok(RpcOutcome::new(resp, Vec::new()))
|
||||
}
|
||||
|
||||
/// Return whether Recall Calendar is both enabled server-side and connected for
|
||||
/// the current backend user. This is used by core meeting fetch paths that
|
||||
/// should not depend on the settings UI being mounted long enough to sync the
|
||||
/// local `meet.calendar_provider` flag.
|
||||
pub async fn is_connected(config: &Config) -> Result<bool, String> {
|
||||
let outcome = status(config).await?;
|
||||
Ok(outcome.value.enabled && outcome.value.connected)
|
||||
}
|
||||
|
||||
/// How long a Recall connectivity probe is trusted before it is re-checked.
|
||||
/// Bounds the live `status` RPC on the hot path to at most once per window for
|
||||
/// users who have not flipped `calendar_provider` to Recall — otherwise a
|
||||
/// connected (or absent) Recall calendar is re-detected on *every* heartbeat
|
||||
/// tick and *every* meetings-page fetch, a recurring external round-trip for
|
||||
/// all logged-in non-Recall users (#4391 review). The settings UI still flips
|
||||
/// the provider immediately on connect; this only rate-limits the
|
||||
/// mount-independent fallback shared by both call sites.
|
||||
const RECALL_DETECT_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
/// Process-wide memo of the last Recall probe: `(taken_at, connected)`.
|
||||
static RECALL_DETECT_CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
|
||||
|
||||
/// True when a probe taken at `at` is still within `ttl` as of `now`. Pure so
|
||||
/// the TTL boundary is unit-testable without controlling the wall clock.
|
||||
fn recall_probe_fresh(at: Instant, now: Instant, ttl: Duration) -> bool {
|
||||
now.saturating_duration_since(at) < ttl
|
||||
}
|
||||
|
||||
/// Detect whether a Recall calendar is connected, memoized for
|
||||
/// [`RECALL_DETECT_TTL`] and shared by every mount-independent caller (heartbeat
|
||||
/// planner + meetings-page fetch). A probe error (no backend session /
|
||||
/// unavailable) is treated — and cached — as "not connected", so a Recall-less
|
||||
/// user stops issuing a `status` RPC on every hot-path invocation.
|
||||
pub async fn is_connected_cached(config: &Config) -> bool {
|
||||
let now = Instant::now();
|
||||
{
|
||||
let guard = RECALL_DETECT_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((at, connected)) = *guard {
|
||||
if recall_probe_fresh(at, now, RECALL_DETECT_TTL) {
|
||||
return connected;
|
||||
}
|
||||
}
|
||||
}
|
||||
let connected = match is_connected(config).await {
|
||||
Ok(connected) => connected,
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
error = %error,
|
||||
"[recall_calendar] status unavailable — treating as not connected"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
*RECALL_DETECT_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some((now, connected));
|
||||
connected
|
||||
}
|
||||
|
||||
/// Drop the memoized connectivity probe. Called whenever the connection state
|
||||
/// is (about to be) mutated — `connect` / `disconnect` — so the next
|
||||
/// [`is_connected_cached`] call issues a fresh probe rather than serving a stale
|
||||
/// value for the remainder of [`RECALL_DETECT_TTL`]. A transient error is still
|
||||
/// cached as "not connected" (bounds probing for no-session users), but that
|
||||
/// staleness is now cleared the moment the user actually connects/disconnects.
|
||||
fn invalidate_detect_cache() {
|
||||
*RECALL_DETECT_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// Disconnect the user's Google calendar from Recall.
|
||||
pub async fn disconnect(config: &Config) -> Result<RpcOutcome<RecallCalendarDisconnect>, String> {
|
||||
tracing::debug!("[recall_calendar] rpc disconnect");
|
||||
let client = recall_client(config)?;
|
||||
let resp = client
|
||||
.post::<RecallCalendarDisconnect>(DISCONNECT_PATH, &json!({}))
|
||||
.await
|
||||
.map_err(|e| format!("[recall_calendar] disconnect failed: {e:#}"))?;
|
||||
// Just disconnected — drop the memoized probe so the fallback stops routing
|
||||
// to Recall within one tick instead of after up to RECALL_DETECT_TTL.
|
||||
invalidate_detect_cache();
|
||||
Ok(RpcOutcome::new(
|
||||
resp,
|
||||
vec!["recall_calendar: calendar disconnected".to_string()],
|
||||
))
|
||||
}
|
||||
|
||||
/// Fetch upcoming meetings from the connected calendar (raw list).
|
||||
pub async fn fetch_recall_meetings(config: &Config) -> Result<Vec<RecallMeeting>, String> {
|
||||
let client = recall_client(config)?;
|
||||
let resp = client
|
||||
.get::<RecallMeetingsResponse>(MEETINGS_PATH)
|
||||
.await
|
||||
.map_err(|e| format!("[recall_calendar] list meetings failed: {e:#}"))?;
|
||||
Ok(resp.meetings)
|
||||
}
|
||||
|
||||
/// RPC wrapper around [`fetch_recall_meetings`].
|
||||
pub async fn list_meetings(config: &Config) -> Result<RpcOutcome<RecallMeetingsResponse>, String> {
|
||||
tracing::debug!("[recall_calendar] rpc list_meetings");
|
||||
let meetings = fetch_recall_meetings(config).await?;
|
||||
let count = meetings.len();
|
||||
Ok(RpcOutcome::new(
|
||||
RecallMeetingsResponse { meetings },
|
||||
vec![format!("recall_calendar: {count} upcoming meeting(s)")],
|
||||
))
|
||||
}
|
||||
|
||||
/// Reshape backend-normalized Recall meetings into a Google-Calendar
|
||||
/// `events.list`-style payload so the existing calendar extractors
|
||||
/// (`agent_meetings::upcoming::extract_upcoming_meetings` and
|
||||
/// `heartbeat::planner::collectors::extract_calendar_events`) parse them
|
||||
/// unchanged. Only meetings with both a join URL and a start time survive.
|
||||
pub fn meetings_to_gcal_json(meetings: &[RecallMeeting]) -> Value {
|
||||
let items: Vec<Value> = meetings
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let url = m
|
||||
.meeting_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())?;
|
||||
let start = m
|
||||
.start_time
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())?;
|
||||
let mut item = json!({
|
||||
"id": m.id,
|
||||
"summary": m.title.clone().unwrap_or_else(|| "Meeting".to_string()),
|
||||
"start": { "dateTime": start },
|
||||
"hangoutLink": url,
|
||||
"htmlLink": url,
|
||||
});
|
||||
if let Some(end) = m
|
||||
.end_time
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
item["end"] = json!({ "dateTime": end });
|
||||
}
|
||||
Some(item)
|
||||
})
|
||||
.collect();
|
||||
json!({ "items": items })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meeting(id: &str, url: Option<&str>, start: Option<&str>) -> RecallMeeting {
|
||||
RecallMeeting {
|
||||
id: id.to_string(),
|
||||
title: Some("Standup".to_string()),
|
||||
meeting_url: url.map(ToString::to_string),
|
||||
start_time: start.map(ToString::to_string),
|
||||
end_time: Some("2026-07-01T10:30:00Z".to_string()),
|
||||
platform: Some("google_meet".to_string()),
|
||||
bot_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recall_probe_freshness_respects_ttl() {
|
||||
let ttl = Duration::from_secs(600);
|
||||
let now = Instant::now();
|
||||
// A probe 60s old is inside a 600s TTL → reuse the cached result.
|
||||
let fresh_at = now.checked_sub(Duration::from_secs(60)).unwrap();
|
||||
assert!(recall_probe_fresh(fresh_at, now, ttl));
|
||||
// A probe 601s old is past the TTL → re-probe.
|
||||
let stale_at = now.checked_sub(Duration::from_secs(601)).unwrap();
|
||||
assert!(!recall_probe_fresh(stale_at, now, ttl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidate_detect_cache_clears_memo() {
|
||||
// Prime the memo with a fresh "connected" result, then invalidate.
|
||||
*RECALL_DETECT_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some((Instant::now(), true));
|
||||
invalidate_detect_cache();
|
||||
assert!(
|
||||
RECALL_DETECT_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_none(),
|
||||
"connect/disconnect must drop the memoized probe so the next \
|
||||
is_connected_cached re-detects instead of serving a stale value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_fields_into_gcal_shape() {
|
||||
let m = meeting(
|
||||
"evt-1",
|
||||
Some("https://meet.google.com/abc"),
|
||||
Some("2026-07-01T10:00:00Z"),
|
||||
);
|
||||
let json = meetings_to_gcal_json(&[m]);
|
||||
let items = json["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
let it = &items[0];
|
||||
assert_eq!(it["id"], "evt-1");
|
||||
assert_eq!(it["summary"], "Standup");
|
||||
assert_eq!(it["start"]["dateTime"], "2026-07-01T10:00:00Z");
|
||||
assert_eq!(it["end"]["dateTime"], "2026-07-01T10:30:00Z");
|
||||
assert_eq!(it["hangoutLink"], "https://meet.google.com/abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_meetings_without_url_or_start() {
|
||||
let items = meetings_to_gcal_json(&[
|
||||
meeting("no-url", None, Some("2026-07-01T10:00:00Z")),
|
||||
meeting("no-start", Some("https://meet.google.com/x"), None),
|
||||
meeting("blank-url", Some(" "), Some("2026-07-01T10:00:00Z")),
|
||||
]);
|
||||
assert_eq!(items["items"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_missing_title() {
|
||||
let mut m = meeting(
|
||||
"evt-2",
|
||||
Some("https://meet.google.com/y"),
|
||||
Some("2026-07-01T10:00:00Z"),
|
||||
);
|
||||
m.title = None;
|
||||
let json = meetings_to_gcal_json(&[m]);
|
||||
assert_eq!(json["items"][0]["summary"], "Meeting");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Controller schemas + registered handlers for the `recall_calendar` domain.
|
||||
//!
|
||||
//! Exposes the backend-proxied Recall.ai Calendar V1 surface over the shared
|
||||
//! registry at `openhuman.recall_calendar_*`:
|
||||
//! - `recall_calendar.connect` → `openhuman.recall_calendar_connect`
|
||||
//! - `recall_calendar.status` → `openhuman.recall_calendar_status`
|
||||
//! - `recall_calendar.disconnect` → `openhuman.recall_calendar_disconnect`
|
||||
//! - `recall_calendar.list_meetings` → `openhuman.recall_calendar_list_meetings`
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::config::rpc as config_rpc;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
pub fn schemas(function: &str) -> ControllerSchema {
|
||||
match function {
|
||||
"connect" => ControllerSchema {
|
||||
namespace: "recall_calendar",
|
||||
function: "connect",
|
||||
description:
|
||||
"Start the Recall.ai Calendar V1 OAuth flow and return the Google consent URL.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "connectUrl",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Google OAuth consent URL to open in a browser.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"status" => ControllerSchema {
|
||||
namespace: "recall_calendar",
|
||||
function: "status",
|
||||
description: "Report whether the integration is enabled and the calendar is connected.",
|
||||
inputs: vec![],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "enabled",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Whether the backend has the Recall calendar path enabled.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "connected",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Whether this user has a connected Google Calendar.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "email",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Connected calendar email, when known.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
"disconnect" => ControllerSchema {
|
||||
namespace: "recall_calendar",
|
||||
function: "disconnect",
|
||||
description: "Disconnect the user's Google calendar from Recall.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "disconnected",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True once the calendar has been disconnected.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"list_meetings" => ControllerSchema {
|
||||
namespace: "recall_calendar",
|
||||
function: "list_meetings",
|
||||
description: "List upcoming meetings from the connected calendar (join URLs only).",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "meetings",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Array of {id, title, meetingUrl, startTime, endTime, platform, botId}.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
_other => ControllerSchema {
|
||||
namespace: "recall_calendar",
|
||||
function: "unknown",
|
||||
description: "Unknown recall_calendar controller function.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "function",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Unknown function requested for schema lookup.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "error",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Lookup error details.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
schemas("connect"),
|
||||
schemas("status"),
|
||||
schemas("disconnect"),
|
||||
schemas("list_meetings"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: schemas("connect"),
|
||||
handler: handle_connect,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("status"),
|
||||
handler: handle_status,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("disconnect"),
|
||||
handler: handle_disconnect,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("list_meetings"),
|
||||
handler: handle_list_meetings,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
fn handle_connect(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(super::ops::connect(&config).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(super::ops::status(&config).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_disconnect(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(super::ops::disconnect(&config).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_meetings(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(super::ops::list_meetings(&config).await?)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Serde types for the `recall_calendar` domain — the backend-proxied
|
||||
//! Recall.ai Calendar V1 surface exposed at `openhuman.recall_calendar_*`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Connection status surfaced to the settings UI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RecallCalendarStatus {
|
||||
/// Whether the backend has the Recall calendar integration enabled
|
||||
/// (`RECALL_CALENDAR_ENABLED`). When `false` the UI hides the connect tile.
|
||||
pub enabled: bool,
|
||||
/// Whether this user has a connected Google Calendar.
|
||||
pub connected: bool,
|
||||
/// Connected calendar email, when known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of the connect op — the Google OAuth consent URL to open.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecallCalendarConnect {
|
||||
/// Google OAuth consent URL the client opens in the browser.
|
||||
#[serde(rename = "connectUrl")]
|
||||
pub connect_url: String,
|
||||
}
|
||||
|
||||
/// Result of the disconnect op.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecallCalendarDisconnect {
|
||||
pub disconnected: bool,
|
||||
}
|
||||
|
||||
/// A single upcoming meeting, already normalized by the backend's
|
||||
/// `/agent-integrations/recall-calendar/meetings` endpoint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RecallMeeting {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(rename = "meetingUrl", default)]
|
||||
pub meeting_url: Option<String>,
|
||||
/// RFC3339 start time.
|
||||
#[serde(rename = "startTime", default)]
|
||||
pub start_time: Option<String>,
|
||||
/// RFC3339 end time.
|
||||
#[serde(rename = "endTime", default)]
|
||||
pub end_time: Option<String>,
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
/// Populated only if Recall itself scheduled a bot (should stay empty in
|
||||
/// our detection-only model — we schedule via the existing bot path).
|
||||
#[serde(rename = "botId", default)]
|
||||
pub bot_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Envelope of the meetings endpoint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecallMeetingsResponse {
|
||||
#[serde(default)]
|
||||
pub meetings: Vec<RecallMeeting>,
|
||||
}
|
||||
@@ -324,6 +324,45 @@ async fn collect_calendar_events_buffered(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Recall.ai Calendar V1 detection: fetch upcoming meetings from the backend
|
||||
/// and reshape them into the same event stream the Composio path produces, so
|
||||
/// downstream planning + dedup stay unchanged.
|
||||
async fn collect_recall_calendar_meetings(
|
||||
config: &Config,
|
||||
now: DateTime<Utc>,
|
||||
) -> Vec<PendingEvent> {
|
||||
let lookahead = config.heartbeat.meeting_lookahead_minutes.max(1);
|
||||
let end_window = now + chrono::Duration::minutes(i64::from(lookahead));
|
||||
let meetings = match crate::openhuman::recall_calendar::ops::fetch_recall_meetings(config).await
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "[heartbeat:planner] recall calendar fetch failed");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let events = build_recall_pending(&meetings, now, end_window);
|
||||
tracing::debug!(
|
||||
fetched = meetings.len(),
|
||||
within_window = events.len(),
|
||||
"[heartbeat:planner] recall calendar events collected"
|
||||
);
|
||||
events
|
||||
}
|
||||
|
||||
/// Pure transform: Recall meetings → `PendingEvent`s by reshaping to a
|
||||
/// Google-Calendar payload and reusing the shared `extract_calendar_events`
|
||||
/// parser (so dedup/overlap keys match the Composio path). Unit-testable
|
||||
/// without a backend session.
|
||||
fn build_recall_pending(
|
||||
meetings: &[crate::openhuman::recall_calendar::types::RecallMeeting],
|
||||
now: DateTime<Utc>,
|
||||
end_window: DateTime<Utc>,
|
||||
) -> Vec<PendingEvent> {
|
||||
let data = crate::openhuman::recall_calendar::ops::meetings_to_gcal_json(meetings);
|
||||
extract_calendar_events(&data, "googlecalendar", "recall", now, end_window)
|
||||
}
|
||||
|
||||
pub(crate) async fn collect_calendar_meetings(
|
||||
config: &Config,
|
||||
now: DateTime<Utc>,
|
||||
@@ -334,6 +373,25 @@ pub(crate) async fn collect_calendar_meetings(
|
||||
// collector hard-bound to `build_composio_client` (backend-only)
|
||||
// and silently returned an empty meeting list for direct-mode
|
||||
// users.
|
||||
// Auto-detect a connected Recall calendar even when `meet.calendar_provider`
|
||||
// has not been flipped yet, mirroring the meetings-page fetch path
|
||||
// (`agent_meetings::upcoming::fetch_upcoming_meetings`). Without this
|
||||
// fallback the heartbeat auto-join would keep polling Composio (which a
|
||||
// Recall-only user never connected) and silently never fire, even though the
|
||||
// meetings page shows the Recall meetings.
|
||||
let recall_selected = matches!(
|
||||
config.meet.calendar_provider,
|
||||
crate::openhuman::config::schema::CalendarProvider::Recall
|
||||
);
|
||||
let recall_connected = if recall_selected {
|
||||
true
|
||||
} else {
|
||||
crate::openhuman::recall_calendar::ops::is_connected_cached(config).await
|
||||
};
|
||||
if recall_connected {
|
||||
return collect_recall_calendar_meetings(config, now).await;
|
||||
}
|
||||
|
||||
let kind = match create_composio_client(config) {
|
||||
Ok(kind) => kind,
|
||||
Err(error) => {
|
||||
@@ -719,6 +777,35 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_recall_pending_maps_meetings() {
|
||||
let now = chrono::Utc::now();
|
||||
let end = now + chrono::Duration::hours(2);
|
||||
let meetings = vec![crate::openhuman::recall_calendar::types::RecallMeeting {
|
||||
id: "r1".to_string(),
|
||||
title: Some("Sync".to_string()),
|
||||
meeting_url: Some("https://meet.google.com/aaa-bbbb-ccc".to_string()),
|
||||
start_time: Some((now + chrono::Duration::minutes(30)).to_rfc3339()),
|
||||
end_time: None,
|
||||
platform: None,
|
||||
bot_id: None,
|
||||
}];
|
||||
let events = build_recall_pending(&meetings, now, end);
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0].meeting_url.as_deref(),
|
||||
Some("https://meet.google.com/aaa-bbbb-ccc")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_routes_to_recall_and_degrades_empty() {
|
||||
let mut config = Config::default();
|
||||
config.meet.calendar_provider = crate::openhuman::config::schema::CalendarProvider::Recall;
|
||||
let out = collect_calendar_meetings(&config, chrono::Utc::now()).await;
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
fn conn(id: &str, toolkit: &str, status: &str) -> ComposioConnection {
|
||||
ComposioConnection {
|
||||
id: id.to_string(),
|
||||
|
||||
@@ -424,12 +424,21 @@ async fn transcribe_and_deliver(config: &Config, samples_16k: Vec<f32>) {
|
||||
deliver_command(config, cmd).await;
|
||||
}
|
||||
None => {
|
||||
// Visible at info so the user can see WHAT was heard when the
|
||||
// wake word didn't match (diagnoses "Hey Tiny not responding").
|
||||
log::info!(
|
||||
"{LOG_PREFIX} no wake word ({:?}) in transcript={text:?}; ignored",
|
||||
config.voice_server.wake_word
|
||||
);
|
||||
if wake_word_present(&text, &config.voice_server.wake_word) {
|
||||
// Wake word spoken with no trailing command ("Hey Tiny").
|
||||
// Acknowledge with an agent turn so the user gets a reply
|
||||
// instead of silence, then they can follow up.
|
||||
log::info!("{LOG_PREFIX} bare wake word → acknowledging");
|
||||
notch_status("Listening…", 8000);
|
||||
deliver_command(config, "hello".to_string()).await;
|
||||
} else {
|
||||
// Visible at info so the user can see WHAT was heard when the
|
||||
// wake word didn't match (diagnoses "Hey Tiny not responding").
|
||||
log::info!(
|
||||
"{LOG_PREFIX} no wake word ({:?}) in transcript={text:?}; ignored",
|
||||
config.voice_server.wake_word
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,18 +583,35 @@ async fn osa(script: &str) -> Result<(), String> {
|
||||
/// the agent). An empty `wake_word` disables the gate (every utterance passes).
|
||||
/// Matching is tolerant: case-insensitive, punctuation-insensitive, and the
|
||||
/// phrase may appear after leading filler ("um, hey tiny, play music").
|
||||
/// Tokenize into lowercase alphanumeric words — shared by the wake-word matcher
|
||||
/// and the bare-wake detector so both apply identical normalization.
|
||||
fn wake_tokens(s: &str) -> Vec<String> {
|
||||
s.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { ' ' })
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// True when the configured wake word appears near the start of the transcript,
|
||||
/// regardless of whether a command follows. Lets the caller acknowledge a bare
|
||||
/// wake word ("Hey Tiny" with nothing after it) instead of silently dropping it.
|
||||
pub(crate) fn wake_word_present(transcript: &str, wake_word: &str) -> bool {
|
||||
let wake = wake_tokens(wake_word);
|
||||
if wake.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let t = wake_tokens(transcript);
|
||||
let anchor = wake.iter().max_by_key(|w| w.len()).cloned().unwrap();
|
||||
let max_dist = if anchor.chars().count() <= 4 { 1 } else { 2 };
|
||||
(0..t.len().min(3)).any(|i| levenshtein(&t[i], &anchor) <= max_dist)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_command(transcript: &str, wake_word: &str) -> Option<String> {
|
||||
let tokens = |s: &str| -> Vec<String> {
|
||||
s.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { ' ' })
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
};
|
||||
let wake = tokens(wake_word);
|
||||
let t = tokens(transcript);
|
||||
let wake = wake_tokens(wake_word);
|
||||
let t = wake_tokens(transcript);
|
||||
if wake.is_empty() {
|
||||
// No wake word configured → deliver everything (non-empty).
|
||||
return if t.is_empty() {
|
||||
@@ -1038,4 +1064,23 @@ mod tests {
|
||||
);
|
||||
assert_eq!(extract_command(" ", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_word_present_detects_bare_and_fuzzy() {
|
||||
// Bare wake word (no command) is still detected so the caller can ack.
|
||||
assert!(wake_word_present("Hey Tiny", "Hey Tiny"));
|
||||
assert!(wake_word_present("hey tiny!", "Hey Tiny"));
|
||||
// Fuzzy anchor (STT mangles "tiny" → "tony"/"tinny").
|
||||
assert!(wake_word_present("hey tony", "Hey Tiny"));
|
||||
// Wake word followed by a command also counts as present.
|
||||
assert!(wake_word_present("Hey Tiny, play music", "Hey Tiny"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_word_present_false_when_absent() {
|
||||
assert!(!wake_word_present("play some music", "Hey Tiny"));
|
||||
assert!(!wake_word_present("", "Hey Tiny"));
|
||||
// No wake word configured → never a bare-wake ack.
|
||||
assert!(!wake_word_present("anything at all", ""));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user