feat(meet): Recall Calendar auto-join that replies, connect card on Meetings page, reply-name setting (#4391)

This commit is contained in:
YellowSnnowmann
2026-07-02 18:11:50 +05:30
committed by GitHub
parent f979bfa0a9
commit 8c58d3adab
46 changed files with 2136 additions and 34 deletions
@@ -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 -2
View File
@@ -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 */
});
+55 -5
View File
@@ -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();
});
});
+6
View File
@@ -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': 'عبارة التنشيط',
+7
View File
@@ -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': 'ওয়েক ফ্রেজ',
+6
View File
@@ -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',
+6
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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',
+6
View File
@@ -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': 'वेक फ्रेज़',
+7
View File
@@ -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',
+7
View File
@@ -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',
+6
View File
@@ -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': '웨이크 구문',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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': 'Фраза активации',
+6
View File
@@ -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': '唤醒词',
+111
View File
@@ -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:/));
});
});
+108
View File
@@ -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');
});
});
+8
View File
@@ -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(
+4
View File
@@ -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());
+203 -8
View File
@@ -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"
);
}
}
+47
View File
@@ -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
+104
View File
@@ -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);
+12
View File
@@ -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(
+4
View File
@@ -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]
+48
View File
@@ -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]
+1 -1
View File
@@ -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::{
+18 -1
View File
@@ -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,
+4
View File
@@ -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 {
+1
View File
@@ -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;
+19
View File
@@ -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,
};
+298
View File
@@ -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");
}
}
+162
View File
@@ -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?)
})
}
+61
View File
@@ -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(),
+62 -17
View File
@@ -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", ""));
}
}