diff --git a/app/src/components/meetings/MeetDefaultsDrawer.tsx b/app/src/components/meetings/MeetDefaultsDrawer.tsx index fb34eb9783..ba0c848c9b 100644 --- a/app/src/components/meetings/MeetDefaultsDrawer.tsx +++ b/app/src/components/meetings/MeetDefaultsDrawer.tsx @@ -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('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>({}); @@ -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 = {}; 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) { /> + {/* Reply anchor: the user's display name, reused on every join so + the bot knows who to reply to (otherwise it stays listen-only). */} + + setReplyDisplayName(e.target.value)} + onBlur={handleReplyDisplayNameBlur} + placeholder={t('skills.meetingBots.replyName.placeholder')} + aria-label={t('skills.meetingBots.replyName.label')} + /> + } + /> + + {/* Global auto-join */} (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) { )} - + {/* 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. */} + + + @@ -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 */ }); diff --git a/app/src/components/meetings/UpcomingTable.tsx b/app/src/components/meetings/UpcomingTable.tsx index 1e7c02a9f3..971f70400c 100644 --- a/app/src/components/meetings/UpcomingTable.tsx +++ b/app/src/components/meetings/UpcomingTable.tsx @@ -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 */} - {imminent ? ( + {joined ? ( + + + ) : imminent ? ( + ) : 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(); + + 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(); + + // 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') + ); + }); +}); diff --git a/app/src/components/meetings/__tests__/UpcomingTable.test.tsx b/app/src/components/meetings/__tests__/UpcomingTable.test.tsx index 26504ec87b..c56507dea0 100644 --- a/app/src/components/meetings/__tests__/UpcomingTable.test.tsx +++ b/app/src/components/meetings/__tests__/UpcomingTable.test.tsx @@ -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(, { + 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(, { + 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(, { + 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(); + 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(); + + 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(); + + 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(); + }); }); diff --git a/app/src/components/recallCalendar/RecallCalendarCard.tsx b/app/src/components/recallCalendar/RecallCalendarCard.tsx new file mode 100644 index 0000000000..f8bb9b7fa1 --- /dev/null +++ b/app/src/components/recallCalendar/RecallCalendarCard.tsx @@ -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 ( + + ); +} + +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 ( +
+
+ {/* Leading icon tile */} +
+ +
+ + {/* Title + status */} +
+
+ {t('skills.recallCalendar.title')} +
+ {connected ? ( +
+
+ ) : ( +
+ {t('skills.recallCalendar.description')} +
+ )} +
+ + {/* Action */} + {connected ? ( + + ) : ( + + )} +
+ + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/app/src/components/recallCalendar/__tests__/RecallCalendarCard.test.tsx b/app/src/components/recallCalendar/__tests__/RecallCalendarCard.test.tsx new file mode 100644 index 0000000000..fd11b03ca3 --- /dev/null +++ b/app/src/components/recallCalendar/__tests__/RecallCalendarCard.test.tsx @@ -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 = {}) { + 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(); + expect(screen.queryByTestId('recall-calendar-card')).not.toBeInTheDocument(); + }); + + it('renders nothing when the integration is disabled', () => { + setHook({ status: { enabled: false, connected: false } }); + renderWithProviders(); + expect(screen.queryByTestId('recall-calendar-card')).not.toBeInTheDocument(); + }); + + it('renders nothing when the status is null', () => { + setHook({ status: null }); + renderWithProviders(); + 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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + expect(screen.getByTestId('recall-calendar-disconnect')).toBeDisabled(); + }); +}); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index f532b8fbef..c0a0b6f0c6 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': @@ -5100,6 +5102,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': 'عبارة التنشيط', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 3026f7d71a..fb562306c1 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': @@ -5205,6 +5208,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': 'ওয়েক ফ্রেজ', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index ccc1332c4e..e33e736a35 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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': @@ -5338,6 +5340,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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 40a4c5cd3a..a28712bc73 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', @@ -5855,6 +5857,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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index e5c44cbf6f..7d45b1d86d 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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': @@ -5302,6 +5305,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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 1a55f71495..8a100a25dd 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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': @@ -5323,6 +5326,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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 4177313425..7457fdf626 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': @@ -5210,6 +5212,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': 'वेक फ्रेज़', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index d1c4b2dc69..21c7ff7eeb 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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': @@ -5224,6 +5227,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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 440ec4d92c..96d3884f75 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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': @@ -5292,6 +5295,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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 08f9891f87..0dec8e52c5 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': @@ -5156,6 +5158,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': '웨이크 구문', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index abe6af349f..cea0f50484 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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': @@ -5279,6 +5282,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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index ad8ffb2974..92a36f81d6 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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': @@ -5294,6 +5297,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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 5e56de2bd7..6021d4fe6e 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': @@ -5251,6 +5254,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': 'Фраза активации', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index d4987729fe..a6732537c9 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': @@ -4946,6 +4948,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': '唤醒词', diff --git a/app/src/lib/recallCalendar/hooks.test.ts b/app/src/lib/recallCalendar/hooks.test.ts new file mode 100644 index 0000000000..abd2e7224f --- /dev/null +++ b/app/src/lib/recallCalendar/hooks.test.ts @@ -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:/)); + }); +}); diff --git a/app/src/lib/recallCalendar/hooks.ts b/app/src/lib/recallCalendar/hooks.ts new file mode 100644 index 0000000000..6f1f088f97 --- /dev/null +++ b/app/src/lib/recallCalendar/hooks.ts @@ -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; + disconnect: () => Promise; + refresh: () => Promise; +} + +export function useRecallCalendar(): UseRecallCalendar { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const syncedProvider = useRef(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 }; +} diff --git a/app/src/lib/recallCalendar/recallCalendarApi.test.ts b/app/src/lib/recallCalendar/recallCalendarApi.test.ts new file mode 100644 index 0000000000..20758e3553 --- /dev/null +++ b/app/src/lib/recallCalendar/recallCalendarApi.test.ts @@ -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' }); + }); +}); diff --git a/app/src/lib/recallCalendar/recallCalendarApi.ts b/app/src/lib/recallCalendar/recallCalendarApi.ts new file mode 100644 index 0000000000..8004cc4477 --- /dev/null +++ b/app/src/lib/recallCalendar/recallCalendarApi.ts @@ -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(value: unknown): T { + if ( + value !== null && + typeof value === 'object' && + 'result' in (value as Record) && + 'logs' in (value as Record) && + 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 { + const raw = await callCoreRpc({ method: 'openhuman.recall_calendar_connect' }); + return unwrapCliEnvelope(raw); +} + +/** Current connection status for the settings UI. */ +export async function status(): Promise { + const raw = await callCoreRpc({ method: 'openhuman.recall_calendar_status' }); + return unwrapCliEnvelope(raw); +} + +/** Disconnect the user's Google Calendar from Recall. */ +export async function disconnect(): Promise { + const raw = await callCoreRpc({ method: 'openhuman.recall_calendar_disconnect' }); + return unwrapCliEnvelope(raw); +} diff --git a/app/src/utils/tauriCommands/config.test.ts b/app/src/utils/tauriCommands/config.test.ts index d0b6b03dcc..551da4adb8 100644 --- a/app/src/utils/tauriCommands/config.test.ts +++ b/app/src/utils/tauriCommands/config.test.ts @@ -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'); }); }); diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index beaaa50930..08ed3e5bb4 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -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; /** 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( diff --git a/src/core/all.rs b/src/core/all.rs index 3b9eadad8d..9906056a61 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -114,6 +114,9 @@ fn build_registered_controllers() -> Vec { 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) @@ -360,6 +363,7 @@ fn build_declared_controller_schemas() -> Vec { 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()); diff --git a/src/openhuman/agent_meetings/calendar.rs b/src/openhuman/agent_meetings/calendar.rs index e1bccd3685..886fb9b226 100644 --- a/src/openhuman/agent_meetings/calendar.rs +++ b/src/openhuman/agent_meetings/calendar.rs @@ -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, + } + + 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" + ); + } } diff --git a/src/openhuman/agent_meetings/ops.rs b/src/openhuman/agent_meetings/ops.rs index 48d57f43d2..7ddf39cc56 100644 --- a/src/openhuman/agent_meetings/ops.rs +++ b/src/openhuman/agent_meetings/ops.rs @@ -1103,6 +1103,13 @@ pub async fn handle_notification_action(params: Map) -> Result, + end_window: DateTime, + limit: u32, + join_policy: &str, +) -> Result, 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, + end_window: DateTime, + limit: u32, + join_policy: &str, +) -> Vec { + 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, DateTime) { let now = Utc::now(); let end = now + chrono::Duration::hours(8); diff --git a/src/openhuman/config/ops/ui.rs b/src/openhuman/config/ops/ui.rs index 5cc52adb2a..ecd809aad2 100644 --- a/src/openhuman/config/ops/ui.rs +++ b/src/openhuman/config/ops/ui.rs @@ -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, + /// Calendar detection source: `Composio` (default) or `Recall`. Flipped to + /// `Recall` when the user connects a calendar via Recall.ai. + pub calendar_provider: Option, + /// User's meeting display name, reused as the bot's reply anchor on join. + pub reply_display_name: Option, } #[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( diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index d6434a4926..e4d391429b 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -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] diff --git a/src/openhuman/config/schema/meet.rs b/src/openhuman/config/schema/meet.rs index 69bd282767..dbad27675b 100644 --- a/src/openhuman/config/schema/meet.rs +++ b/src/openhuman/config/schema/meet.rs @@ -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] diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index bc3b0ce7eb..d9968ef1ae 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -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::{ diff --git a/src/openhuman/config/schemas/controllers.rs b/src/openhuman/config/schemas/controllers.rs index eddafd7da8..4fa458f06f 100644 --- a/src/openhuman/config/schemas/controllers.rs +++ b/src/openhuman/config/schemas/controllers.rs @@ -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) -> 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) -> 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) -> 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) -> 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, diff --git a/src/openhuman/config/schemas/helpers.rs b/src/openhuman/config/schemas/helpers.rs index 78becad7ad..fd6cd072e8 100644 --- a/src/openhuman/config/schemas/helpers.rs +++ b/src/openhuman/config/schemas/helpers.rs @@ -130,6 +130,10 @@ pub(super) struct MeetSettingsUpdate { pub(super) platform_auto_join_policies: Option>, /// Master switch for calendar-driven auto-join / ask-to-join. pub(super) watch_calendar: Option, + /// Calendar detection source as a string: `composio` | `recall`. + pub(super) calendar_provider: Option, + /// User's meeting display name, reused as the bot's reply anchor. + pub(super) reply_display_name: Option, } #[derive(Debug, Deserialize)] diff --git a/src/openhuman/config/schemas/schema_defs.rs b/src/openhuman/config/schemas/schema_defs.rs index 61e282e5e7..6fccdedae3 100644 --- a/src/openhuman/config/schemas/schema_defs.rs +++ b/src/openhuman/config/schemas/schema_defs.rs @@ -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 { diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index f05555148d..741ef5ea67 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -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; diff --git a/src/openhuman/recall_calendar/mod.rs b/src/openhuman/recall_calendar/mod.rs new file mode 100644 index 0000000000..33034b4c00 --- /dev/null +++ b/src/openhuman/recall_calendar/mod.rs @@ -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, +}; diff --git a/src/openhuman/recall_calendar/ops.rs b/src/openhuman/recall_calendar/ops.rs new file mode 100644 index 0000000000..654e3cbd53 --- /dev/null +++ b/src/openhuman/recall_calendar/ops.rs @@ -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, 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, String> { + tracing::debug!("[recall_calendar] rpc connect"); + let client = recall_client(config)?; + let resp = client + .post::(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, String> { + tracing::debug!("[recall_calendar] rpc status"); + let client = recall_client(config)?; + let resp = client + .get::(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 { + 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> = 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, String> { + tracing::debug!("[recall_calendar] rpc disconnect"); + let client = recall_client(config)?; + let resp = client + .post::(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, String> { + let client = recall_client(config)?; + let resp = client + .get::(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, 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 = 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"); + } +} diff --git a/src/openhuman/recall_calendar/schemas.rs b/src/openhuman/recall_calendar/schemas.rs new file mode 100644 index 0000000000..800e782696 --- /dev/null +++ b/src/openhuman/recall_calendar/schemas.rs @@ -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 { + vec![ + schemas("connect"), + schemas("status"), + schemas("disconnect"), + schemas("list_meetings"), + ] +} + +pub fn all_registered_controllers() -> Vec { + 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(outcome: RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +} + +fn handle_connect(_params: Map) -> 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) -> 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) -> 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) -> ControllerFuture { + Box::pin(async { + let config = config_rpc::load_config_with_timeout().await?; + to_json(super::ops::list_meetings(&config).await?) + }) +} diff --git a/src/openhuman/recall_calendar/types.rs b/src/openhuman/recall_calendar/types.rs new file mode 100644 index 0000000000..55a071ef3f --- /dev/null +++ b/src/openhuman/recall_calendar/types.rs @@ -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, +} + +/// 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, + #[serde(rename = "meetingUrl", default)] + pub meeting_url: Option, + /// RFC3339 start time. + #[serde(rename = "startTime", default)] + pub start_time: Option, + /// RFC3339 end time. + #[serde(rename = "endTime", default)] + pub end_time: Option, + #[serde(default)] + pub platform: Option, + /// 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, +} + +/// Envelope of the meetings endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecallMeetingsResponse { + #[serde(default)] + pub meetings: Vec, +} diff --git a/src/openhuman/subconscious/heartbeat/planner/collectors.rs b/src/openhuman/subconscious/heartbeat/planner/collectors.rs index 187ede7032..efc27919da 100644 --- a/src/openhuman/subconscious/heartbeat/planner/collectors.rs +++ b/src/openhuman/subconscious/heartbeat/planner/collectors.rs @@ -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, +) -> Vec { + 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, + end_window: DateTime, +) -> Vec { + 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, @@ -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(), diff --git a/src/openhuman/voice/always_on.rs b/src/openhuman/voice/always_on.rs index ead080c9d9..6c22b79582 100644 --- a/src/openhuman/voice/always_on.rs +++ b/src/openhuman/voice/always_on.rs @@ -424,12 +424,21 @@ async fn transcribe_and_deliver(config: &Config, samples_16k: Vec) { 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 { + s.to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { ' ' }) + .collect::() + .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 { - let tokens = |s: &str| -> Vec { - s.to_lowercase() - .chars() - .map(|c| if c.is_alphanumeric() { c } else { ' ' }) - .collect::() - .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", "")); + } }