diff --git a/app/src/components/notifications/CoreNotificationCard.test.tsx b/app/src/components/notifications/CoreNotificationCard.test.tsx new file mode 100644 index 000000000..8bbb448b5 --- /dev/null +++ b/app/src/components/notifications/CoreNotificationCard.test.tsx @@ -0,0 +1,160 @@ +/** + * Action-button contract for core notification cards (issue #3507). + * Asserts that the calendar auto-join prompt renders its buttons, dispatches + * the `openhuman.agent_meetings_notification_action` RPC with the right + * params on click, and surfaces an error when the RPC rejects. + */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { store } from '../../store'; +import { type NotificationItem } from '../../store/notificationSlice'; +import CoreNotificationCard from './CoreNotificationCard'; + +const callCoreRpc = vi.hoisted(() => vi.fn()); +vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc })); + +function makeItem(overrides: Partial = {}): NotificationItem { + return { + id: 'meet-auto-join:m1', + category: 'meetings', + title: 'Meeting starting: Standup', + body: 'Add Tiny to this meeting?', + timestamp: Date.now(), + read: false, + actions: [ + { actionId: 'join_listen', label: 'Join (listen only)', payload: { meetingId: 'm1' } }, + { actionId: 'join_active', label: 'Join & reply', payload: { meetingId: 'm1' } }, + { actionId: 'skip', label: 'Not this one', payload: { meetingId: 'm1' } }, + { actionId: 'always_join', label: 'Always join', payload: { meetingId: 'm1' } }, + ], + ...overrides, + }; +} + +function renderCard(item: NotificationItem) { + return render( + + + + ); +} + +describe('CoreNotificationCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + store.dispatch({ type: 'notifications/clearAll' }); + }); + + it('renders a localized button per action', () => { + renderCard(makeItem()); + expect(screen.getByRole('button', { name: 'Join (listen only)' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Join & reply' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Not this one' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Always join' })).toBeInTheDocument(); + }); + + it('dispatches the notification-action RPC with action_id + payload on click', async () => { + callCoreRpc.mockResolvedValue({ ok: true }); + renderCard(makeItem()); + + fireEvent.click(screen.getByRole('button', { name: 'Join (listen only)' })); + + await waitFor(() => expect(callCoreRpc).toHaveBeenCalledTimes(1)); + expect(callCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.agent_meetings_notification_action', + params: { action_id: 'join_listen', payload: { meetingId: 'm1' } }, + }); + }); + + it('marks the notification read in the store after a successful action', async () => { + callCoreRpc.mockResolvedValue({ ok: true }); + store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() }); + renderCard(makeItem()); + + fireEvent.click(screen.getByRole('button', { name: 'Not this one' })); + + await waitFor(() => { + const item = store.getState().notifications.items.find(i => i.id === 'meet-auto-join:m1'); + expect(item?.read).toBe(true); + }); + }); + + it('clears the action buttons after a successful action so it cannot be re-clicked', async () => { + callCoreRpc.mockResolvedValue({ ok: true }); + store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() }); + renderCard(makeItem()); + + fireEvent.click(screen.getByRole('button', { name: 'Not this one' })); + + await waitFor(() => { + const item = store.getState().notifications.items.find(i => i.id === 'meet-auto-join:m1'); + expect(item?.actions ?? []).toHaveLength(0); + }); + }); + + it('keeps the action buttons when the RPC rejects', async () => { + callCoreRpc.mockRejectedValue(new Error('boom')); + store.dispatch({ type: 'notifications/notificationReceived', payload: makeItem() }); + renderCard(makeItem()); + + fireEvent.click(screen.getByRole('button', { name: 'Not this one' })); + + await waitFor(() => + expect( + screen.getByText('Could not complete that action. Please try again.') + ).toBeInTheDocument() + ); + const item = store.getState().notifications.items.find(i => i.id === 'meet-auto-join:m1'); + expect(item?.actions).toHaveLength(4); + }); + + it('surfaces an error message when the RPC rejects', async () => { + callCoreRpc.mockRejectedValue(new Error('boom')); + renderCard(makeItem()); + + fireEvent.click(screen.getByRole('button', { name: 'Always join' })); + + await waitFor(() => + expect( + screen.getByText('Could not complete that action. Please try again.') + ).toBeInTheDocument() + ); + }); + + it('renders nothing actionable when there are no actions', () => { + renderCard(makeItem({ actions: [] })); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('disables all buttons while an action RPC is in flight', async () => { + let resolve!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(r => { + resolve = r; + }) + ); + renderCard(makeItem()); + + fireEvent.click(screen.getByRole('button', { name: 'Join (listen only)' })); + + // All buttons should be disabled while the call is pending. + const buttons = screen.getAllByRole('button'); + buttons.forEach(btn => expect(btn).toBeDisabled()); + + resolve({ ok: true }); + await waitFor(() => buttons.forEach(btn => expect(btn).not.toBeDisabled())); + }); + + it('shows no unread dot when notification is already read', () => { + renderCard(makeItem({ read: true })); + expect(document.querySelector('[aria-hidden="true"]')).toBeNull(); + }); + + it('shows unread dot when notification is unread', () => { + renderCard(makeItem({ read: false })); + expect(document.querySelector('[aria-hidden="true"]')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/notifications/CoreNotificationCard.tsx b/app/src/components/notifications/CoreNotificationCard.tsx new file mode 100644 index 000000000..436ade2db --- /dev/null +++ b/app/src/components/notifications/CoreNotificationCard.tsx @@ -0,0 +1,157 @@ +import debug from 'debug'; +import { useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { callCoreRpc } from '../../services/coreRpcClient'; +import { useAppDispatch } from '../../store/hooks'; +import { + clearNotificationActions, + markRead, + type NotificationItem, +} from '../../store/notificationSlice'; +import NotificationBody from './NotificationBody'; + +// Namespaced debug per project logging rules (mirrors nativeNotifications). +const log = debug('notifications:core-card'); + +/** Relative human-readable time string from epoch ms, e.g. "2m ago". */ +function relativeTime(timestampMs: number): string { + const diff = Math.max(0, Date.now() - timestampMs); + const s = Math.floor(diff / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +/** + * Map a known meeting auto-join action id to its i18n key so button labels + * are localized rather than trusting the (English) label the core sends. + * Unknown action ids fall back to the server-provided label. + */ +const ACTION_LABEL_KEYS: Record = { + join_listen: 'notifications.meeting.joinListen', + join_active: 'notifications.meeting.joinActive', + skip: 'notifications.meeting.skip', + always_join: 'notifications.meeting.alwaysJoin', +}; + +/** Primary (filled) vs secondary (outline) styling per action id. */ +function isPrimaryAction(actionId: string): boolean { + return actionId === 'join_listen' || actionId === 'join_active'; +} + +interface Props { + notification: NotificationItem; +} + +/** + * Renders a core-originated notification (from `state.notifications.items`) + * that carries action buttons — e.g. the calendar auto-join prompt + * (issue #3507). Clicking a button dispatches the + * `openhuman.agent_meetings_notification_action` RPC and marks the item read. + */ +const CoreNotificationCard = ({ notification: n }: Props) => { + const { t } = useT(); + const dispatch = useAppDispatch(); + const [pendingActionId, setPendingActionId] = useState(null); + const [error, setError] = useState(null); + + const handleAction = async (actionId: string, payload: unknown) => { + if (pendingActionId) return; // ignore double-clicks while a call is in flight + setPendingActionId(actionId); + setError(null); + log('action click id=%s notification=%s', actionId, n.id); + try { + await callCoreRpc<{ ok: boolean }>({ + method: 'openhuman.agent_meetings_notification_action', + params: { action_id: actionId, payload }, + }); + log('action ok id=%s', actionId); + dispatch(markRead({ id: n.id })); + // Remove the buttons so the handled prompt can't be re-clicked (which would + // re-fire bot:join, or flip always_join after a skip). Without this the + // card stays pinned in NotificationCenter with live actions. + dispatch(clearNotificationActions({ id: n.id })); + } catch (err) { + log('action failed id=%s err=%o', actionId, err); + setError(t('notifications.meeting.actionError')); + } finally { + setPendingActionId(null); + } + }; + + return ( +
+
+ {/* Unread dot — reserve space so text stays aligned whether read or unread */} +
+ {!n.read && ( +
+ +
+ {/* Header row: category badge + timestamp */} +
+ + {t(`notifications.category.${n.category}`)} + + + {relativeTime(n.timestamp)} + +
+ + {/* Title */} +

{n.title}

+ + {/* Body */} + {n.body && ( +

+ +

+ )} + + {/* Action buttons */} + {n.actions && n.actions.length > 0 && ( +
+ {n.actions.map(action => { + const labelKey = ACTION_LABEL_KEYS[action.actionId]; + const label = labelKey ? t(labelKey) : action.label; + const primary = isPrimaryAction(action.actionId); + return ( + + ); + })} +
+ )} + + {error &&

{error}

} +
+
+
+ ); +}; + +export default CoreNotificationCard; diff --git a/app/src/components/notifications/NotificationCenter.tsx b/app/src/components/notifications/NotificationCenter.tsx index 544176845..a033e7e38 100644 --- a/app/src/components/notifications/NotificationCenter.tsx +++ b/app/src/components/notifications/NotificationCenter.tsx @@ -18,6 +18,7 @@ import { setIntegrationLoading, setIntegrationNotifications, } from '../../store/notificationSlice'; +import CoreNotificationCard from './CoreNotificationCard'; import NotificationCard from './NotificationCard'; // ───────────────────────────────────────────────────────────────────────────── @@ -32,7 +33,12 @@ const NotificationCenter = () => { integrationItems: items, integrationLoading: loading, integrationError: error, + items: coreItems, } = useAppSelector(s => s.notifications); + // Core-originated notifications that carry action buttons (e.g. the calendar + // auto-join prompt, issue #3507). These live in a separate Redux array from + // the server-fetched integration notifications and are surfaced at the top. + const coreActionItems = coreItems.filter(i => i.actions && i.actions.length > 0); const [selectedProvider, setSelectedProvider] = useState(undefined); // All providers seen across unfiltered loads — kept separate so the filter // pill row doesn't collapse when a provider filter is active. @@ -172,6 +178,16 @@ const NotificationCenter = () => { {/* Content */}
+ {/* Actionable core notifications (e.g. meeting auto-join prompt) — + always shown first, independent of integration load state. */} + {coreActionItems.length > 0 && ( +
+ {coreActionItems.map(item => ( + + ))} +
+ )} + {loading && (
{t('common.loading')} @@ -184,7 +200,7 @@ const NotificationCenter = () => {
)} - {!loading && !error && visibleItems.length === 0 && ( + {!loading && !error && visibleItems.length === 0 && coreActionItems.length === 0 && (
= { 'M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z' ) ), + meetings: icon( + stroke( + 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z' + ) + ), 'developer-options': icon(stroke('M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4')), about: icon(stroke('M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z')), usage: icon( diff --git a/app/src/components/settings/panels/MeetingSettingsPanel.tsx b/app/src/components/settings/panels/MeetingSettingsPanel.tsx new file mode 100644 index 000000000..174c9b855 --- /dev/null +++ b/app/src/components/settings/panels/MeetingSettingsPanel.tsx @@ -0,0 +1,269 @@ +import debug from 'debug'; +import { useEffect, useRef, useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { + isTauri, + type MeetAutoJoinPolicy, + type MeetAutoSummarizePolicy, + openhumanGetMeetSettings, + openhumanUpdateMeetSettings, +} from '../../../utils/tauriCommands'; +import PanelPage from '../../layout/PanelPage'; +import SettingsBackButton from '../components/SettingsBackButton'; +import { + SettingsRow, + SettingsSection, + SettingsSelect, + SettingsStatusLine, + SettingsSwitch, +} from '../controls'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; + +const log = debug('settings:meetings'); + +const AUTO_JOIN_OPTIONS: MeetAutoJoinPolicy[] = ['ask_each_time', 'always', 'never']; +const AUTO_SUMMARIZE_OPTIONS: MeetAutoSummarizePolicy[] = ['ask', 'always', 'never']; + +const AUTO_JOIN_LABEL_KEY: Record = { + ask_each_time: 'settings.meetings.autoJoin.askEachTime', + always: 'settings.meetings.autoJoin.always', + never: 'settings.meetings.autoJoin.never', +}; + +const AUTO_SUMMARIZE_LABEL_KEY: Record = { + ask: 'settings.meetings.autoSummarize.ask', + always: 'settings.meetings.autoSummarize.always', + never: 'settings.meetings.autoSummarize.never', +}; + +/** + * Meeting Assistant settings (issue #3511 / epic #3505 PR-5). + * + * Surfaces four `MeetConfig` fields via `openhuman.config_{get,update}_meet_settings`: + * auto-join policy, post-call summary policy, listen-only default, and backend + * transcript ingestion. The orchestrator-handoff privacy gate stays in the + * Privacy panel and is intentionally not duplicated here. + */ +const MeetingSettingsPanel = () => { + const { t } = useT(); + const { navigateBack } = useSettingsNavigation(); + + const [isLoading, setIsLoading] = useState(isTauri()); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [savedNote, setSavedNote] = useState(null); + + const [autoJoin, setAutoJoin] = useState('ask_each_time'); + const [autoSummarize, setAutoSummarize] = useState('ask'); + const [listenOnly, setListenOnly] = useState(true); + const [ingestTranscripts, setIngestTranscripts] = useState(false); + + const persistSeqRef = useRef(0); + + useEffect(() => { + let cancelled = false; + const load = async () => { + if (!isTauri()) return; + log('load start'); + try { + const resp = await openhumanGetMeetSettings(); + if (cancelled) return; + const s = resp.result; + log( + 'load ok auto_join=%s auto_summarize=%s listen_only=%s', + s.auto_join_policy, + s.auto_summarize_policy, + s.listen_only_default + ); + setAutoJoin(s.auto_join_policy); + setAutoSummarize(s.auto_summarize_policy); + setListenOnly(s.listen_only_default); + setIngestTranscripts(s.ingest_backend_transcripts); + } catch (e) { + log('load failed err=%o', e); + if (!cancelled) setError(e instanceof Error ? e.message : t('settings.meetings.loadError')); + } finally { + if (!cancelled) setIsLoading(false); + } + }; + void load(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const persist = async ( + patch: Parameters[0], + onFailure?: () => void + ) => { + const seq = ++persistSeqRef.current; + if (!isTauri()) return; + log('persist patch=%o seq=%d', patch, seq); + setError(null); + setSavedNote(null); + setIsSaving(true); + try { + await openhumanUpdateMeetSettings(patch); + if (seq !== persistSeqRef.current) return; + log('persist ok seq=%d', seq); + setSavedNote(t('settings.meetings.saved')); + } catch (e) { + if (seq !== persistSeqRef.current) return; + log('persist failed seq=%d err=%o', seq, e); + onFailure?.(); + setError(e instanceof Error ? e.message : t('settings.meetings.saveError')); + } finally { + if (seq === persistSeqRef.current) setIsSaving(false); + } + }; + + const handleAutoJoinChange = (next: MeetAutoJoinPolicy) => { + const prev = autoJoin; + setAutoJoin(next); + void persist({ auto_join_policy: next }, () => setAutoJoin(prev)); + }; + + const handleAutoSummarizeChange = (next: MeetAutoSummarizePolicy) => { + const prev = autoSummarize; + setAutoSummarize(next); + void persist({ auto_summarize_policy: next }, () => setAutoSummarize(prev)); + }; + + const handleListenOnlyChange = (next: boolean) => { + const prev = listenOnly; + setListenOnly(next); + void persist({ listen_only_default: next }, () => setListenOnly(prev)); + }; + + const handleIngestChange = (next: boolean) => { + const prev = ingestTranscripts; + setIngestTranscripts(next); + void persist({ ingest_backend_transcripts: next }, () => setIngestTranscripts(prev)); + }; + + if (!isTauri()) { + return ( + }> +
+

+ {t('settings.meetings.desktopOnly')} +

+
+ + ); + } + + if (isLoading) { + return ( + }> +
+

+ {t('settings.meetings.loading')} +

+
+
+ ); + } + + return ( + }> +
+ {/* Auto-join policy */} + + handleAutoJoinChange(e.target.value as MeetAutoJoinPolicy)} + aria-label={t('settings.meetings.autoJoin.title')}> + {AUTO_JOIN_OPTIONS.map(opt => ( + + ))} + + } + /> + + + {/* Auto-summarize policy */} + + handleAutoSummarizeChange(e.target.value as MeetAutoSummarizePolicy)} + aria-label={t('settings.meetings.autoSummarize.title')}> + {AUTO_SUMMARIZE_OPTIONS.map(opt => ( + + ))} + + } + /> + + + {/* Toggles */} + + + } + /> + + } + /> + + + {/* Status line */} + +
+
+ ); +}; + +export default MeetingSettingsPanel; diff --git a/app/src/components/settings/panels/__tests__/MeetingSettingsPanel.test.tsx b/app/src/components/settings/panels/__tests__/MeetingSettingsPanel.test.tsx new file mode 100644 index 000000000..9ba6bbcd5 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/MeetingSettingsPanel.test.tsx @@ -0,0 +1,186 @@ +/** + * Meeting Assistant settings panel (issue #3511). Verifies each radio/toggle + * reads its initial value from `openhumanGetMeetSettings` and writes via + * `openhumanUpdateMeetSettings`. + */ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../../test/test-utils'; +import { + isTauri, + type MeetSettings, + openhumanGetMeetSettings, + openhumanUpdateMeetSettings, +} from '../../../../utils/tauriCommands'; +import MeetingSettingsPanel from '../MeetingSettingsPanel'; + +const meetSettings = (overrides: Partial = {}): MeetSettings => ({ + auto_orchestrator_handoff: false, + auto_join_policy: 'ask_each_time', + auto_summarize_policy: 'ask', + listen_only_default: true, + ingest_backend_transcripts: false, + ...overrides, +}); + +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ + navigateBack: vi.fn(), + navigateToSettings: vi.fn(), + breadcrumbs: [], + }), +})); + +vi.mock('../../../../utils/tauriCommands', async () => { + const actual = await vi.importActual( + '../../../../utils/tauriCommands' + ); + return { + ...actual, + isTauri: vi.fn(() => true), + openhumanGetMeetSettings: vi.fn(), + openhumanUpdateMeetSettings: vi.fn(), + }; +}); + +const mockGet = vi.mocked(openhumanGetMeetSettings); +const mockUpdate = vi.mocked(openhumanUpdateMeetSettings); + +describe('MeetingSettingsPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isTauri).mockReturnValue(true); + mockGet.mockResolvedValue({ result: meetSettings(), logs: [] }); + mockUpdate.mockResolvedValue({ result: {} as never, logs: [] }); + }); + + it('loads settings on mount', async () => { + renderWithProviders(); + await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(1)); + }); + + it('renders the auto-join select with the current value', async () => { + renderWithProviders(); + const select = await screen.findByRole('combobox', { name: /auto-join policy/i }); + expect(select).toHaveValue('ask_each_time'); + }); + + it('changing auto-join persists the selection', async () => { + renderWithProviders(); + const select = await screen.findByRole('combobox', { name: /auto-join policy/i }); + fireEvent.change(select, { target: { value: 'always' } }); + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ auto_join_policy: 'always' }) + ) + ); + }); + + it('renders the auto-summarize select with the current value', async () => { + renderWithProviders(); + const select = await screen.findByRole('combobox', { name: /post-call summary/i }); + expect(select).toHaveValue('ask'); + }); + + it('changing auto-summarize persists the selection', async () => { + renderWithProviders(); + const select = await screen.findByRole('combobox', { name: /post-call summary/i }); + fireEvent.change(select, { target: { value: 'never' } }); + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ auto_summarize_policy: 'never' }) + ) + ); + }); + + it('toggling listen-only persists the change', async () => { + renderWithProviders(); + const toggle = await screen.findByRole('switch', { name: /listen-only mode/i }); + fireEvent.click(toggle); + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ listen_only_default: false }) + ) + ); + }); + + it('toggling transcript ingestion persists the change', async () => { + renderWithProviders(); + const toggle = await screen.findByRole('switch', { name: /ingest backend transcripts/i }); + fireEvent.click(toggle); + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ ingest_backend_transcripts: true }) + ) + ); + }); + + it('shows desktop-only message when not in Tauri', async () => { + vi.mocked(isTauri).mockReturnValue(false); + renderWithProviders(); + expect(await screen.findByText(/available on desktop only/i)).toBeInTheDocument(); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('shows an error when settings fail to load', async () => { + mockGet.mockRejectedValue(new Error('RPC timeout')); + renderWithProviders(); + expect(await screen.findByText('RPC timeout')).toBeInTheDocument(); + }); + + it('shows an error note when persist fails', async () => { + mockUpdate.mockRejectedValue(new Error('Save failed')); + renderWithProviders(); + const select = await screen.findByRole('combobox', { name: /auto-join policy/i }); + fireEvent.change(select, { target: { value: 'never' } }); + expect(await screen.findByText('Save failed')).toBeInTheDocument(); + }); + + it('shows "Saved" note after a successful persist', async () => { + renderWithProviders(); + const select = await screen.findByRole('combobox', { name: /auto-join policy/i }); + fireEvent.change(select, { target: { value: 'always' } }); + expect(await screen.findByText('Saved')).toBeInTheDocument(); + }); + + it('rolls back auto-join select to previous value when persist fails', async () => { + mockUpdate.mockRejectedValue(new Error('Save failed')); + renderWithProviders(); + const select = await screen.findByRole('combobox', { name: /auto-join policy/i }); + expect(select).toHaveValue('ask_each_time'); + fireEvent.change(select, { target: { value: 'never' } }); + await screen.findByText('Save failed'); + expect(select).toHaveValue('ask_each_time'); + }); + + it('rolls back auto-summarize select to previous value when persist fails', async () => { + mockUpdate.mockRejectedValue(new Error('Save failed')); + renderWithProviders(); + const select = await screen.findByRole('combobox', { name: /post-call summary/i }); + expect(select).toHaveValue('ask'); + fireEvent.change(select, { target: { value: 'never' } }); + await screen.findByText('Save failed'); + expect(select).toHaveValue('ask'); + }); + + it('rolls back listen-only toggle to previous value when persist fails', async () => { + mockUpdate.mockRejectedValue(new Error('Save failed')); + renderWithProviders(); + const toggle = await screen.findByRole('switch', { name: /listen-only mode/i }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(toggle); + await screen.findByText('Save failed'); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + }); + + it('rolls back transcript ingestion toggle to previous value when persist fails', async () => { + mockUpdate.mockRejectedValue(new Error('Save failed')); + renderWithProviders(); + const toggle = await screen.findByRole('switch', { name: /ingest backend transcripts/i }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + fireEvent.click(toggle); + await screen.findByText('Save failed'); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + }); +}); diff --git a/app/src/components/settings/settingsRouteRegistry.ts b/app/src/components/settings/settingsRouteRegistry.ts index 8a3cc35c0..a4c8885a8 100644 --- a/app/src/components/settings/settingsRouteRegistry.ts +++ b/app/src/components/settings/settingsRouteRegistry.ts @@ -473,6 +473,26 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ navGroup: 'connections', navOrder: 4, }, + { + // meetings: Meeting Assistant settings (issue #3511 / epic #3505 PR-5). + id: 'meetings', + titleKey: 'settings.meetings.title', + descriptionKey: 'settings.meetings.menuDesc', + section: 'features', + searchKeywords: [ + 'meeting', + 'meet', + 'google meet', + 'auto join', + 'auto-join', + 'summarize', + 'summary', + 'listen only', + 'transcript', + ], + navGroup: 'connections', + navOrder: 5, + }, // ========================================================================= // NOTIFICATIONS section leaf panels diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 8f83d1cf2..c3dbd1586 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3067,6 +3067,11 @@ const messages: TranslationMap = { 'notifications.center.filterAll': 'تصفية الكل', 'notifications.center.markAllRead': 'تحديد الكل كمقروء', 'notifications.center.title': 'الإشعارات', + 'notifications.meeting.joinListen': 'الانضمام (استماع فقط)', + 'notifications.meeting.joinActive': 'الانضمام والرد', + 'notifications.meeting.skip': 'ليس هذا', + 'notifications.meeting.alwaysJoin': 'الانضمام دائماً', + 'notifications.meeting.actionError': 'تعذّر إكمال هذا الإجراء. يرجى المحاولة مرة أخرى.', 'oauth.button.connecting': 'جارٍ الاتصال...', 'oauth.button.loopbackTimeout': 'انتهت مهلة تسجيل الدخول — لم يكتمل المتصفح إعادة توجيه OAuth. يرجى المحاولة مرة أخرى.', @@ -4176,6 +4181,28 @@ const messages: TranslationMap = { 'settings.permissions.foldersDesc': 'المجلد الافتراضي الذي يقرأ ويكتب فيه المساعد. يمكنك إضافة مجلدات أخرى في الإعدادات المتقدمة.', + 'settings.meetings.title': 'الاجتماعات', + 'settings.meetings.menuDesc': 'انضمام تلقائي وملخصات ونصوص لمساعد الاجتماعات', + 'settings.meetings.desktopOnly': 'إعدادات الاجتماعات متاحة على سطح المكتب فقط.', + 'settings.meetings.loading': 'جارٍ التحميل…', + 'settings.meetings.loadError': 'فشل تحميل إعدادات الاجتماعات.', + 'settings.meetings.saveError': 'فشل حفظ إعدادات الاجتماعات.', + 'settings.meetings.saved': 'تم الحفظ', + 'settings.meetings.saving': 'جارٍ الحفظ…', + 'settings.meetings.autoJoin.title': 'سياسة الانضمام التلقائي', + 'settings.meetings.autoJoin.desc': 'عند وجود رابط Google Meet في حدث التقويم', + 'settings.meetings.autoJoin.askEachTime': 'اسأل كل مرة', + 'settings.meetings.autoJoin.always': 'الانضمام دائماً', + 'settings.meetings.autoJoin.never': 'عدم الانضمام أبداً', + 'settings.meetings.autoSummarize.title': 'ملخص بعد المكالمة', + 'settings.meetings.autoSummarize.desc': 'إنشاء ملخص بعد انتهاء المكالمة', + 'settings.meetings.autoSummarize.ask': 'اسأل بعد المكالمة', + 'settings.meetings.autoSummarize.always': 'تلخيص دائماً', + 'settings.meetings.autoSummarize.never': 'عدم التلخيص أبداً', + 'settings.meetings.listenOnly': 'وضع الاستماع فقط', + 'settings.meetings.listenOnlyDesc': 'الانضمام مع كتم الميكروفون', + 'settings.meetings.ingestTranscripts': 'استيعاب نصوص الخلفية', + 'settings.meetings.ingestTranscriptsDesc': 'تخزين نصوص الاجتماعات في الذاكرة', 'settings.sandbox.title': 'تنفيذ Sandbox', 'settings.sandbox.menuDesc': 'تكوين خلفيات Sandbox لعزل أدوات الوكيل.', 'settings.sandbox.loading': 'جارٍ التحميل…', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 7ccc57814..3e1201ed5 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3137,6 +3137,11 @@ const messages: TranslationMap = { 'notifications.center.filterAll': 'সব ফিল্টার', 'notifications.center.markAllRead': 'সব পঠিত চিহ্নিত করুন', 'notifications.center.title': 'বিজ্ঞপ্তি', + 'notifications.meeting.joinListen': 'যোগ দিন (শুধু শুনুন)', + 'notifications.meeting.joinActive': 'যোগ দিন ও উত্তর দিন', + 'notifications.meeting.skip': 'এটি নয়', + 'notifications.meeting.alwaysJoin': 'সবসময় যোগ দিন', + 'notifications.meeting.actionError': 'এই কাজটি সম্পন্ন করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।', 'oauth.button.connecting': 'সংযোগ হচ্ছে...', 'oauth.button.loopbackTimeout': 'সাইন-ইন টাইম আউট হয়েছে — ব্রাউজার OAuth পুনর্নির্দেশনা সম্পন্ন করেনি। অনুগ্রহ করে আবার চেষ্টা করুন।', @@ -4261,6 +4266,29 @@ const messages: TranslationMap = { 'settings.permissions.foldersDesc': 'সহকারী যে ডিফল্ট ফোল্ডার পড়ে এবং লেখে। উন্নত সেটিংসে আপনি আরও ফোল্ডার যোগ করতে পারেন।', + 'settings.meetings.title': 'মিটিং', + 'settings.meetings.menuDesc': + 'মিটিং অ্যাসিস্ট্যান্ট স্বয়ংক্রিয় যোগদান, সারাংশ ও ট্রান্সক্রিপ্ট', + 'settings.meetings.desktopOnly': 'মিটিং সেটিংস কেবল ডেস্কটপে উপলব্ধ।', + 'settings.meetings.loading': 'লোড হচ্ছে…', + 'settings.meetings.loadError': 'মিটিং সেটিংস লোড করা যায়নি।', + 'settings.meetings.saveError': 'মিটিং সেটিংস সংরক্ষণ করা যায়নি।', + 'settings.meetings.saved': 'সংরক্ষিত', + 'settings.meetings.saving': 'সংরক্ষণ হচ্ছে…', + 'settings.meetings.autoJoin.title': 'স্বয়ংক্রিয় যোগদান নীতি', + 'settings.meetings.autoJoin.desc': 'ক্যালেন্ডার ইভেন্টে Google Meet লিঙ্ক থাকলে', + 'settings.meetings.autoJoin.askEachTime': 'প্রতিবার জিজ্ঞাসা করুন', + 'settings.meetings.autoJoin.always': 'সবসময় যোগ দিন', + 'settings.meetings.autoJoin.never': 'কখনও যোগ দেবেন না', + 'settings.meetings.autoSummarize.title': 'কল-পরবর্তী সারাংশ', + 'settings.meetings.autoSummarize.desc': 'কল শেষ হওয়ার পর একটি সারাংশ তৈরি করুন', + 'settings.meetings.autoSummarize.ask': 'কলের পরে জিজ্ঞাসা করুন', + 'settings.meetings.autoSummarize.always': 'সবসময় সারাংশ করুন', + 'settings.meetings.autoSummarize.never': 'কখনও সারাংশ করবেন না', + 'settings.meetings.listenOnly': 'শুধু শোনার মোড', + 'settings.meetings.listenOnlyDesc': 'মাইক্রোফোন মিউট করে যোগ দিন', + 'settings.meetings.ingestTranscripts': 'ব্যাকএন্ড ট্রান্সক্রিপ্ট সংগ্রহ করুন', + 'settings.meetings.ingestTranscriptsDesc': 'মিটিং ট্রান্সক্রিপ্ট মেমরিতে সংরক্ষণ করুন', 'settings.sandbox.title': 'Sandbox কার্যকরী', 'settings.sandbox.menuDesc': 'এজেন্ট টুল আইসোলেশনের জন্য Sandbox ব্যাকএন্ড কনফিগার করুন।', 'settings.sandbox.loading': 'লোড হচ্ছে…', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 2e8e44cb7..b24becc72 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3210,6 +3210,12 @@ const messages: TranslationMap = { 'notifications.center.filterAll': 'Alles filtern', 'notifications.center.markAllRead': 'Alles als gelesen markieren', 'notifications.center.title': 'Benachrichtigungen', + 'notifications.meeting.joinListen': 'Beitreten (nur zuhören)', + 'notifications.meeting.joinActive': 'Beitreten und antworten', + 'notifications.meeting.skip': 'Nicht dieses', + 'notifications.meeting.alwaysJoin': 'Immer beitreten', + 'notifications.meeting.actionError': + 'Diese Aktion konnte nicht abgeschlossen werden. Bitte versuche es erneut.', 'oauth.button.connecting': 'Verbinden...', 'oauth.button.loopbackTimeout': 'Anmeldung abgelaufen — der Browser hat die OAuth-Weiterleitung nicht abgeschlossen. Bitte versuche es erneut.', @@ -4367,6 +4373,29 @@ const messages: TranslationMap = { 'settings.permissions.foldersDesc': 'Der Standardordner, den der Assistent liest und beschreibt. In den erweiterten Einstellungen kannst du weitere Ordner hinzufügen.', + 'settings.meetings.title': 'Besprechungen', + 'settings.meetings.menuDesc': + 'Auto-Beitritt, Zusammenfassungen und Transkripte des Besprechungsassistenten', + 'settings.meetings.desktopOnly': 'Besprechungseinstellungen sind nur auf dem Desktop verfügbar.', + 'settings.meetings.loading': 'Wird geladen…', + 'settings.meetings.loadError': 'Besprechungseinstellungen konnten nicht geladen werden.', + 'settings.meetings.saveError': 'Besprechungseinstellungen konnten nicht gespeichert werden.', + 'settings.meetings.saved': 'Gespeichert', + 'settings.meetings.saving': 'Wird gespeichert…', + 'settings.meetings.autoJoin.title': 'Auto-Beitritt-Richtlinie', + 'settings.meetings.autoJoin.desc': 'Wenn ein Kalendereintrag einen Google-Meet-Link hat', + 'settings.meetings.autoJoin.askEachTime': 'Jedes Mal fragen', + 'settings.meetings.autoJoin.always': 'Immer beitreten', + 'settings.meetings.autoJoin.never': 'Nie beitreten', + 'settings.meetings.autoSummarize.title': 'Zusammenfassung nach dem Anruf', + 'settings.meetings.autoSummarize.desc': 'Nach Anrufende eine Zusammenfassung erstellen', + 'settings.meetings.autoSummarize.ask': 'Nach dem Anruf fragen', + 'settings.meetings.autoSummarize.always': 'Immer zusammenfassen', + 'settings.meetings.autoSummarize.never': 'Nie zusammenfassen', + 'settings.meetings.listenOnly': 'Nur-Zuhören-Modus', + 'settings.meetings.listenOnlyDesc': 'Mit stummgeschaltetem Mikrofon beitreten', + 'settings.meetings.ingestTranscripts': 'Backend-Transkripte aufnehmen', + 'settings.meetings.ingestTranscriptsDesc': 'Besprechungstranskripte im Speicher ablegen', 'settings.sandbox.title': 'Sandbox-Ausführung', 'settings.sandbox.menuDesc': 'Sandbox-Backends für die Isolation von Agentenwerkzeugen konfigurieren.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index aa1d8d213..388e7843b 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3727,6 +3727,11 @@ const en: TranslationMap = { 'notifications.center.filterAll': 'Filter all', 'notifications.center.markAllRead': 'Mark all read', 'notifications.center.title': 'Notifications', + 'notifications.meeting.joinListen': 'Join (listen only)', + 'notifications.meeting.joinActive': 'Join & reply', + 'notifications.meeting.skip': 'Not this one', + 'notifications.meeting.alwaysJoin': 'Always join', + 'notifications.meeting.actionError': 'Could not complete that action. Please try again.', 'oauth.button.connecting': 'Connecting...', 'oauth.button.loopbackTimeout': 'Sign-in timed out — the browser did not complete the OAuth redirect. Please try again.', @@ -4855,6 +4860,28 @@ const en: TranslationMap = { 'The default folder the assistant reads and writes. You can add more folders in Advanced settings.', // ── Sandbox execution backend ───────────────────────────────────── + 'settings.meetings.title': 'Meetings', + 'settings.meetings.menuDesc': 'Meeting Assistant auto-join, summaries, and transcripts', + 'settings.meetings.desktopOnly': 'Meeting settings are available on desktop only.', + 'settings.meetings.loading': 'Loading…', + 'settings.meetings.loadError': 'Failed to load meeting settings.', + 'settings.meetings.saveError': 'Failed to save meeting settings.', + 'settings.meetings.saved': 'Saved', + 'settings.meetings.saving': 'Saving…', + 'settings.meetings.autoJoin.title': 'Auto-join policy', + 'settings.meetings.autoJoin.desc': 'When a calendar event has a Google Meet link', + 'settings.meetings.autoJoin.askEachTime': 'Ask each time', + 'settings.meetings.autoJoin.always': 'Always join', + 'settings.meetings.autoJoin.never': 'Never join', + 'settings.meetings.autoSummarize.title': 'Post-call summary', + 'settings.meetings.autoSummarize.desc': 'Generate a summary after the call ends', + 'settings.meetings.autoSummarize.ask': 'Ask after call', + 'settings.meetings.autoSummarize.always': 'Always summarize', + 'settings.meetings.autoSummarize.never': 'Never summarize', + 'settings.meetings.listenOnly': 'Listen-only mode', + 'settings.meetings.listenOnlyDesc': 'Join with the microphone muted', + 'settings.meetings.ingestTranscripts': 'Ingest backend transcripts', + 'settings.meetings.ingestTranscriptsDesc': 'Store meeting transcripts in memory', 'settings.sandbox.title': 'Sandbox execution', 'settings.sandbox.menuDesc': 'Configure sandbox backends for agent tool isolation.', 'settings.sandbox.loading': 'Loading…', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 0fb1d0388..1952dab38 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3193,6 +3193,11 @@ const messages: TranslationMap = { 'notifications.center.filterAll': 'Filtrar todo', 'notifications.center.markAllRead': 'Marcar todo como leído', 'notifications.center.title': 'Notificaciones', + 'notifications.meeting.joinListen': 'Unirse (solo escuchar)', + 'notifications.meeting.joinActive': 'Unirse y responder', + 'notifications.meeting.skip': 'Esta no', + 'notifications.meeting.alwaysJoin': 'Unirse siempre', + 'notifications.meeting.actionError': 'No se pudo completar esa acción. Inténtalo de nuevo.', 'oauth.button.connecting': 'Conectando...', 'oauth.button.loopbackTimeout': 'El inicio de sesión expiró — el navegador no completó la redirección OAuth. Por favor, inténtalo de nuevo.', @@ -4338,6 +4343,32 @@ const messages: TranslationMap = { 'settings.permissions.foldersDesc': 'La carpeta predeterminada que el asistente lee y escribe. Puedes añadir más carpetas en los ajustes avanzados.', + 'settings.meetings.title': 'Reuniones', + 'settings.meetings.menuDesc': + 'Auto-unión, resúmenes y transcripciones del Asistente de reuniones', + 'settings.meetings.desktopOnly': + 'Los ajustes de reuniones solo están disponibles en el escritorio.', + 'settings.meetings.loading': 'Cargando…', + 'settings.meetings.loadError': 'No se pudieron cargar los ajustes de reuniones.', + 'settings.meetings.saveError': 'No se pudieron guardar los ajustes de reuniones.', + 'settings.meetings.saved': 'Guardado', + 'settings.meetings.saving': 'Guardando…', + 'settings.meetings.autoJoin.title': 'Política de auto-unión', + 'settings.meetings.autoJoin.desc': + 'Cuando un evento del calendario tiene un enlace de Google Meet', + 'settings.meetings.autoJoin.askEachTime': 'Preguntar cada vez', + 'settings.meetings.autoJoin.always': 'Unirse siempre', + 'settings.meetings.autoJoin.never': 'No unirse nunca', + 'settings.meetings.autoSummarize.title': 'Resumen tras la llamada', + 'settings.meetings.autoSummarize.desc': 'Generar un resumen al terminar la llamada', + 'settings.meetings.autoSummarize.ask': 'Preguntar tras la llamada', + 'settings.meetings.autoSummarize.always': 'Resumir siempre', + 'settings.meetings.autoSummarize.never': 'No resumir nunca', + 'settings.meetings.listenOnly': 'Modo solo escucha', + 'settings.meetings.listenOnlyDesc': 'Unirse con el micrófono silenciado', + 'settings.meetings.ingestTranscripts': 'Ingerir transcripciones del backend', + 'settings.meetings.ingestTranscriptsDesc': + 'Guardar las transcripciones de reuniones en la memoria', 'settings.sandbox.title': 'Ejecución en sandbox', 'settings.sandbox.menuDesc': 'Configurar backends de sandbox para el aislamiento de herramientas del agente.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 1a2ae620e..890558f64 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3205,6 +3205,11 @@ const messages: TranslationMap = { 'notifications.center.filterAll': 'Tout filtrer', 'notifications.center.markAllRead': 'Tout marquer comme lu', 'notifications.center.title': 'Notifications', + 'notifications.meeting.joinListen': 'Rejoindre (écoute seule)', + 'notifications.meeting.joinActive': 'Rejoindre en participant', + 'notifications.meeting.skip': 'Pas celle-ci', + 'notifications.meeting.alwaysJoin': 'Toujours rejoindre', + 'notifications.meeting.actionError': 'Impossible de terminer cette action. Veuillez réessayer.', 'oauth.button.connecting': 'Connexion en cours…', 'oauth.button.loopbackTimeout': "La connexion a expiré — le navigateur n'a pas complété la redirection OAuth. Veuillez réessayer.", @@ -4356,6 +4361,29 @@ const messages: TranslationMap = { 'settings.permissions.foldersDesc': "Le dossier par défaut que l'assistant lit et écrit. Vous pouvez ajouter d'autres dossiers dans les paramètres avancés.", + 'settings.meetings.title': 'Réunions', + 'settings.meetings.menuDesc': 'Connexion automatique, résumés et transcriptions des réunions', + 'settings.meetings.desktopOnly': + 'Les paramètres de réunion sont disponibles uniquement sur ordinateur.', + 'settings.meetings.loading': 'Chargement…', + 'settings.meetings.loadError': 'Échec du chargement des paramètres de réunion.', + 'settings.meetings.saveError': 'Échec de la sauvegarde des paramètres de réunion.', + 'settings.meetings.saved': 'Enregistré', + 'settings.meetings.saving': 'Enregistrement…', + 'settings.meetings.autoJoin.title': 'Politique de connexion automatique', + 'settings.meetings.autoJoin.desc': 'Quand un événement du calendrier a un lien Google Meet', + 'settings.meetings.autoJoin.askEachTime': 'Demander à chaque fois', + 'settings.meetings.autoJoin.always': 'Toujours rejoindre', + 'settings.meetings.autoJoin.never': 'Ne jamais rejoindre', + 'settings.meetings.autoSummarize.title': 'Résumé après appel', + 'settings.meetings.autoSummarize.desc': 'Générer un résumé après la fin de la réunion', + 'settings.meetings.autoSummarize.ask': 'Demander après appel', + 'settings.meetings.autoSummarize.always': 'Toujours résumer', + 'settings.meetings.autoSummarize.never': 'Ne jamais résumer', + 'settings.meetings.listenOnly': 'Mode écoute seule', + 'settings.meetings.listenOnlyDesc': 'Rejoindre avec le micro coupé', + 'settings.meetings.ingestTranscripts': 'Intégrer les transcriptions du backend', + 'settings.meetings.ingestTranscriptsDesc': 'Stocker les transcriptions de réunion en mémoire', 'settings.sandbox.title': 'Exécution en sandbox', 'settings.sandbox.menuDesc': "Configurer les backends sandbox pour l'isolation des outils de l'agent.", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 696b0c133..989837f2e 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3138,6 +3138,11 @@ const messages: TranslationMap = { 'notifications.center.filterAll': 'सभी फिल्टर', 'notifications.center.markAllRead': 'सभी पढ़ा हुआ मार्क करें', 'notifications.center.title': 'नोटिफिकेशन', + 'notifications.meeting.joinListen': 'शामिल हों (केवल सुनें)', + 'notifications.meeting.joinActive': 'शामिल हों और जवाब दें', + 'notifications.meeting.skip': 'यह नहीं', + 'notifications.meeting.alwaysJoin': 'हमेशा शामिल हों', + 'notifications.meeting.actionError': 'यह क्रिया पूरी नहीं हो सकी। कृपया पुनः प्रयास करें।', 'oauth.button.connecting': 'कनेक्ट हो रहा है...', 'oauth.button.loopbackTimeout': 'साइन-इन का समय समाप्त हो गया — ब्राउज़र ने OAuth पुनर्निर्देशन पूरा नहीं किया। कृपया पुनः प्रयास करें।', @@ -4265,6 +4270,28 @@ const messages: TranslationMap = { 'settings.permissions.foldersDesc': 'डिफ़ॉल्ट फ़ोल्डर जिसे सहायक पढ़ता और लिखता है। आप उन्नत सेटिंग्स में और फ़ोल्डर जोड़ सकते हैं।', + 'settings.meetings.title': 'मीटिंग', + 'settings.meetings.menuDesc': 'मीटिंग असिस्टेंट ऑटो-जॉइन, सारांश और ट्रांसक्रिप्ट', + 'settings.meetings.desktopOnly': 'मीटिंग सेटिंग्स केवल डेस्कटॉप पर उपलब्ध हैं।', + 'settings.meetings.loading': 'लोड हो रहा है…', + 'settings.meetings.loadError': 'मीटिंग सेटिंग्स लोड नहीं हो सकीं।', + 'settings.meetings.saveError': 'मीटिंग सेटिंग्स सहेजी नहीं जा सकीं।', + 'settings.meetings.saved': 'सहेजा गया', + 'settings.meetings.saving': 'सहेजा जा रहा है…', + 'settings.meetings.autoJoin.title': 'ऑटो-जॉइन नीति', + 'settings.meetings.autoJoin.desc': 'जब किसी कैलेंडर इवेंट में Google Meet लिंक हो', + 'settings.meetings.autoJoin.askEachTime': 'हर बार पूछें', + 'settings.meetings.autoJoin.always': 'हमेशा शामिल हों', + 'settings.meetings.autoJoin.never': 'कभी शामिल न हों', + 'settings.meetings.autoSummarize.title': 'कॉल के बाद सारांश', + 'settings.meetings.autoSummarize.desc': 'कॉल समाप्त होने के बाद सारांश बनाएं', + 'settings.meetings.autoSummarize.ask': 'कॉल के बाद पूछें', + 'settings.meetings.autoSummarize.always': 'हमेशा सारांश बनाएं', + 'settings.meetings.autoSummarize.never': 'कभी सारांश न बनाएं', + 'settings.meetings.listenOnly': 'केवल सुनने का मोड', + 'settings.meetings.listenOnlyDesc': 'माइक्रोफ़ोन म्यूट करके शामिल हों', + 'settings.meetings.ingestTranscripts': 'बैकएंड ट्रांसक्रिप्ट इनजेस्ट करें', + 'settings.meetings.ingestTranscriptsDesc': 'मीटिंग ट्रांसक्रिप्ट मेमोरी में सहेजें', 'settings.sandbox.title': 'Sandbox निष्पादन', 'settings.sandbox.menuDesc': 'एजेंट टूल आइसोलेशन के लिए Sandbox बैकएंड कॉन्फ़िगर करें।', 'settings.sandbox.loading': 'लोड हो रहा है…', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 9ae77229c..e468bb4d1 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3141,6 +3141,11 @@ const messages: TranslationMap = { 'notifications.center.filterAll': 'Filter semua', 'notifications.center.markAllRead': 'Tandai semua sudah dibaca', 'notifications.center.title': 'Notifikasi', + 'notifications.meeting.joinListen': 'Gabung (hanya mendengarkan)', + 'notifications.meeting.joinActive': 'Gabung & balas', + 'notifications.meeting.skip': 'Bukan yang ini', + 'notifications.meeting.alwaysJoin': 'Selalu gabung', + 'notifications.meeting.actionError': 'Tindakan tidak dapat diselesaikan. Silakan coba lagi.', 'oauth.button.connecting': 'Menghubungkan...', 'oauth.button.loopbackTimeout': 'Masuk habis waktu — browser tidak menyelesaikan pengalihan OAuth. Silakan coba lagi.', @@ -4276,6 +4281,28 @@ const messages: TranslationMap = { 'settings.permissions.foldersDesc': 'Folder default yang dibaca dan ditulis asisten. Anda dapat menambahkan folder lain di pengaturan Lanjutan.', + 'settings.meetings.title': 'Rapat', + 'settings.meetings.menuDesc': 'Gabung otomatis, ringkasan, dan transkrip Asisten Rapat', + 'settings.meetings.desktopOnly': 'Pengaturan rapat hanya tersedia di desktop.', + 'settings.meetings.loading': 'Memuat…', + 'settings.meetings.loadError': 'Gagal memuat pengaturan rapat.', + 'settings.meetings.saveError': 'Gagal menyimpan pengaturan rapat.', + 'settings.meetings.saved': 'Tersimpan', + 'settings.meetings.saving': 'Menyimpan…', + 'settings.meetings.autoJoin.title': 'Kebijakan gabung otomatis', + 'settings.meetings.autoJoin.desc': 'Saat acara kalender memiliki tautan Google Meet', + 'settings.meetings.autoJoin.askEachTime': 'Tanya setiap kali', + 'settings.meetings.autoJoin.always': 'Selalu gabung', + 'settings.meetings.autoJoin.never': 'Jangan pernah gabung', + 'settings.meetings.autoSummarize.title': 'Ringkasan setelah panggilan', + 'settings.meetings.autoSummarize.desc': 'Buat ringkasan setelah panggilan berakhir', + 'settings.meetings.autoSummarize.ask': 'Tanya setelah panggilan', + 'settings.meetings.autoSummarize.always': 'Selalu ringkas', + 'settings.meetings.autoSummarize.never': 'Jangan pernah ringkas', + 'settings.meetings.listenOnly': 'Mode hanya mendengarkan', + 'settings.meetings.listenOnlyDesc': 'Gabung dengan mikrofon dimatikan', + 'settings.meetings.ingestTranscripts': 'Serap transkrip backend', + 'settings.meetings.ingestTranscriptsDesc': 'Simpan transkrip rapat di memori', 'settings.sandbox.title': 'Eksekusi sandbox', 'settings.sandbox.menuDesc': 'Konfigurasikan backend sandbox untuk isolasi alat agen.', 'settings.sandbox.loading': 'Memuat…', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 672da8cde..b8e0379ec 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3185,6 +3185,11 @@ const messages: TranslationMap = { 'notifications.center.filterAll': 'Filtra tutte', 'notifications.center.markAllRead': 'Segna tutte come lette', 'notifications.center.title': 'Notifiche', + 'notifications.meeting.joinListen': 'Partecipa (solo ascolto)', + 'notifications.meeting.joinActive': 'Partecipa e rispondi', + 'notifications.meeting.skip': 'Non questa', + 'notifications.meeting.alwaysJoin': 'Partecipa sempre', + 'notifications.meeting.actionError': 'Impossibile completare questa azione. Riprova.', 'oauth.button.connecting': 'Connessione...', 'oauth.button.loopbackTimeout': 'Accesso scaduto — il browser non ha completato il reindirizzamento OAuth. Riprova.', @@ -4331,6 +4336,30 @@ const messages: TranslationMap = { 'settings.permissions.foldersDesc': "La cartella predefinita che l'assistente legge e scrive. Puoi aggiungere altre cartelle nelle impostazioni avanzate.", + 'settings.meetings.title': 'Riunioni', + 'settings.meetings.menuDesc': + 'Accesso automatico, riepiloghi e trascrizioni di Assistente riunioni', + 'settings.meetings.desktopOnly': + 'Le impostazioni delle riunioni sono disponibili solo su desktop.', + 'settings.meetings.loading': 'Caricamento…', + 'settings.meetings.loadError': 'Impossibile caricare le impostazioni delle riunioni.', + 'settings.meetings.saveError': 'Impossibile salvare le impostazioni delle riunioni.', + 'settings.meetings.saved': 'Salvato', + 'settings.meetings.saving': 'Salvataggio…', + 'settings.meetings.autoJoin.title': 'Criterio di accesso automatico', + 'settings.meetings.autoJoin.desc': 'Quando un evento del calendario ha un link Google Meet', + 'settings.meetings.autoJoin.askEachTime': 'Chiedi ogni volta', + 'settings.meetings.autoJoin.always': 'Partecipa sempre', + 'settings.meetings.autoJoin.never': 'Non partecipare mai', + 'settings.meetings.autoSummarize.title': 'Riepilogo dopo la chiamata', + 'settings.meetings.autoSummarize.desc': 'Genera un riepilogo al termine della chiamata', + 'settings.meetings.autoSummarize.ask': 'Chiedi dopo la chiamata', + 'settings.meetings.autoSummarize.always': 'Riepiloga sempre', + 'settings.meetings.autoSummarize.never': 'Non riepilogare mai', + 'settings.meetings.listenOnly': 'Modalità solo ascolto', + 'settings.meetings.listenOnlyDesc': 'Partecipa con il microfono disattivato', + 'settings.meetings.ingestTranscripts': 'Acquisisci trascrizioni backend', + 'settings.meetings.ingestTranscriptsDesc': 'Memorizza le trascrizioni delle riunioni', 'settings.sandbox.title': 'Esecuzione in sandbox', 'settings.sandbox.menuDesc': "Configura i backend sandbox per l'isolamento degli strumenti agente.", diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index d713b07e7..1f926b337 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3108,6 +3108,11 @@ const messages: TranslationMap = { 'notifications.center.filterAll': '전체 필터', 'notifications.center.markAllRead': '모두 읽음으로 표시', 'notifications.center.title': '알림', + 'notifications.meeting.joinListen': '참여 (듣기 전용)', + 'notifications.meeting.joinActive': '참여 및 응답', + 'notifications.meeting.skip': '이건 아니에요', + 'notifications.meeting.alwaysJoin': '항상 참여', + 'notifications.meeting.actionError': '작업을 완료할 수 없습니다. 다시 시도해 주세요.', 'oauth.button.connecting': '연결 중...', 'oauth.button.loopbackTimeout': '로그인 시간 초과 — 브라우저가 OAuth 리디렉션을 완료하지 못했습니다. 다시 시도해 주세요.', @@ -4219,6 +4224,28 @@ const messages: TranslationMap = { 'settings.permissions.foldersDesc': '어시스턴트가 읽고 쓰는 기본 폴더입니다. 고급 설정에서 추가 폴더를 추가할 수 있습니다.', + 'settings.meetings.title': '회의', + 'settings.meetings.menuDesc': '회의 어시스턴트 자동 참여, 요약 및 대화록', + 'settings.meetings.desktopOnly': '회의 설정은 데스크톱에서만 사용할 수 있습니다.', + 'settings.meetings.loading': '불러오는 중…', + 'settings.meetings.loadError': '회의 설정을 불러오지 못했습니다.', + 'settings.meetings.saveError': '회의 설정을 저장하지 못했습니다.', + 'settings.meetings.saved': '저장됨', + 'settings.meetings.saving': '저장 중…', + 'settings.meetings.autoJoin.title': '자동 참여 정책', + 'settings.meetings.autoJoin.desc': '캘린더 일정에 Google Meet 링크가 있을 때', + 'settings.meetings.autoJoin.askEachTime': '매번 묻기', + 'settings.meetings.autoJoin.always': '항상 참여', + 'settings.meetings.autoJoin.never': '참여하지 않음', + 'settings.meetings.autoSummarize.title': '통화 후 요약', + 'settings.meetings.autoSummarize.desc': '통화가 끝난 후 요약 생성', + 'settings.meetings.autoSummarize.ask': '통화 후 묻기', + 'settings.meetings.autoSummarize.always': '항상 요약', + 'settings.meetings.autoSummarize.never': '요약하지 않음', + 'settings.meetings.listenOnly': '듣기 전용 모드', + 'settings.meetings.listenOnlyDesc': '마이크를 음소거하고 참여', + 'settings.meetings.ingestTranscripts': '백엔드 대화록 수집', + 'settings.meetings.ingestTranscriptsDesc': '회의 대화록을 메모리에 저장', 'settings.sandbox.title': '샌드박스 실행', 'settings.sandbox.menuDesc': '에이전트 도구 격리를 위한 샌드박스 백엔드를 구성합니다.', 'settings.sandbox.loading': '로딩 중…', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index f8de0c6bd..75f61ae77 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3176,6 +3176,11 @@ const messages: TranslationMap = { 'notifications.center.filterAll': 'Wszystkie', 'notifications.center.markAllRead': 'Oznacz wszystkie jako przeczytane', 'notifications.center.title': 'Powiadomienia', + 'notifications.meeting.joinListen': 'Dołącz (tylko słuchanie)', + 'notifications.meeting.joinActive': 'Dołącz i odpowiadaj', + 'notifications.meeting.skip': 'Nie to', + 'notifications.meeting.alwaysJoin': 'Zawsze dołączaj', + 'notifications.meeting.actionError': 'Nie udało się wykonać tej akcji. Spróbuj ponownie.', 'oauth.button.connecting': 'Łączenie...', 'oauth.button.loopbackTimeout': 'Logowanie przekroczyło limit czasu — przeglądarka nie ukończyła przekierowania OAuth. Spróbuj ponownie.', @@ -4327,6 +4332,29 @@ const messages: TranslationMap = { 'settings.permissions.foldersDesc': 'Domyślny folder, który asystent odczytuje i zapisuje. Możesz dodać więcej folderów w ustawieniach zaawansowanych.', + 'settings.meetings.title': 'Spotkania', + 'settings.meetings.menuDesc': + 'Automatyczne dołączanie, podsumowania i transkrypcje Asystenta spotkań', + 'settings.meetings.desktopOnly': 'Ustawienia spotkań są dostępne tylko na komputerze.', + 'settings.meetings.loading': 'Ładowanie…', + 'settings.meetings.loadError': 'Nie udało się załadować ustawień spotkań.', + 'settings.meetings.saveError': 'Nie udało się zapisać ustawień spotkań.', + 'settings.meetings.saved': 'Zapisano', + 'settings.meetings.saving': 'Zapisywanie…', + 'settings.meetings.autoJoin.title': 'Zasada automatycznego dołączania', + 'settings.meetings.autoJoin.desc': 'Gdy wydarzenie w kalendarzu ma link Google Meet', + 'settings.meetings.autoJoin.askEachTime': 'Pytaj za każdym razem', + 'settings.meetings.autoJoin.always': 'Zawsze dołączaj', + 'settings.meetings.autoJoin.never': 'Nigdy nie dołączaj', + 'settings.meetings.autoSummarize.title': 'Podsumowanie po rozmowie', + 'settings.meetings.autoSummarize.desc': 'Generuj podsumowanie po zakończeniu rozmowy', + 'settings.meetings.autoSummarize.ask': 'Pytaj po rozmowie', + 'settings.meetings.autoSummarize.always': 'Zawsze podsumowuj', + 'settings.meetings.autoSummarize.never': 'Nigdy nie podsumowuj', + 'settings.meetings.listenOnly': 'Tryb tylko słuchanie', + 'settings.meetings.listenOnlyDesc': 'Dołącz z wyciszonym mikrofonem', + 'settings.meetings.ingestTranscripts': 'Pobieraj transkrypcje backendu', + 'settings.meetings.ingestTranscriptsDesc': 'Przechowuj transkrypcje spotkań w pamięci', 'settings.sandbox.title': 'Wykonanie w piaskownicy', 'settings.sandbox.menuDesc': 'Konfiguruj backendy piaskownicy do izolacji narzędzi agenta.', 'settings.sandbox.loading': 'Ładowanie…', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 4f2b4a94b..45e1e8242 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3189,6 +3189,11 @@ const messages: TranslationMap = { 'notifications.center.filterAll': 'Filtrar todos', 'notifications.center.markAllRead': 'Marcar todos como lidos', 'notifications.center.title': 'Notificações', + 'notifications.meeting.joinListen': 'Entrar (apenas ouvir)', + 'notifications.meeting.joinActive': 'Entrar e responder', + 'notifications.meeting.skip': 'Ignorar', + 'notifications.meeting.alwaysJoin': 'Entrar sempre', + 'notifications.meeting.actionError': 'Não foi possível concluir essa ação. Tente novamente.', 'oauth.button.connecting': 'Conectando...', 'oauth.button.loopbackTimeout': 'Login expirou — o navegador não concluiu o redirecionamento OAuth. Por favor, tente novamente.', @@ -4332,6 +4337,30 @@ const messages: TranslationMap = { 'settings.permissions.foldersDesc': 'A pasta predefinida que o assistente lê e escreve. Pode adicionar mais pastas nas definições Avançadas.', + 'settings.meetings.title': 'Reuniões', + 'settings.meetings.menuDesc': + 'Entrada automática, resumos e transcrições do Assistente de reuniões', + 'settings.meetings.desktopOnly': + 'As configurações de reunião estão disponíveis apenas no desktop.', + 'settings.meetings.loading': 'Carregando…', + 'settings.meetings.loadError': 'Falha ao carregar as configurações de reunião.', + 'settings.meetings.saveError': 'Falha ao salvar as configurações de reunião.', + 'settings.meetings.saved': 'Salvo', + 'settings.meetings.saving': 'Salvando…', + 'settings.meetings.autoJoin.title': 'Política de entrada automática', + 'settings.meetings.autoJoin.desc': 'Quando um evento do calendário tem um link do Google Meet', + 'settings.meetings.autoJoin.askEachTime': 'Perguntar sempre', + 'settings.meetings.autoJoin.always': 'Entrar sempre', + 'settings.meetings.autoJoin.never': 'Nunca entrar', + 'settings.meetings.autoSummarize.title': 'Resumo após a chamada', + 'settings.meetings.autoSummarize.desc': 'Gerar um resumo ao fim da chamada', + 'settings.meetings.autoSummarize.ask': 'Perguntar após a chamada', + 'settings.meetings.autoSummarize.always': 'Resumir sempre', + 'settings.meetings.autoSummarize.never': 'Nunca resumir', + 'settings.meetings.listenOnly': 'Modo somente ouvir', + 'settings.meetings.listenOnlyDesc': 'Entrar com o microfone silenciado', + 'settings.meetings.ingestTranscripts': 'Ingerir transcrições do backend', + 'settings.meetings.ingestTranscriptsDesc': 'Armazenar transcrições de reunião na memória', 'settings.sandbox.title': 'Execução em sandbox', 'settings.sandbox.menuDesc': 'Configure backends de sandbox para isolamento das ferramentas do agente.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 5945dfdfb..916e0bcb3 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3163,6 +3163,11 @@ const messages: TranslationMap = { 'notifications.center.filterAll': 'Все', 'notifications.center.markAllRead': 'Отметить всё прочитанным', 'notifications.center.title': 'Уведомления', + 'notifications.meeting.joinListen': 'Присоединиться (только слушать)', + 'notifications.meeting.joinActive': 'Присоединиться и отвечать', + 'notifications.meeting.skip': 'Пропустить', + 'notifications.meeting.alwaysJoin': 'Всегда присоединяться', + 'notifications.meeting.actionError': 'Не удалось выполнить действие. Попробуйте снова.', 'oauth.button.connecting': 'Подключение...', 'oauth.button.loopbackTimeout': 'Время входа истекло — браузер не завершил перенаправление OAuth. Пожалуйста, попробуйте снова.', @@ -4302,6 +4307,28 @@ const messages: TranslationMap = { 'settings.permissions.foldersDesc': 'Папка по умолчанию, которую помощник читает и в которую пишет. В расширенных настройках можно добавить другие папки.', + 'settings.meetings.title': 'Встречи', + 'settings.meetings.menuDesc': 'Автоподключение, сводки и расшифровки Ассистента встреч', + 'settings.meetings.desktopOnly': 'Настройки встреч доступны только на компьютере.', + 'settings.meetings.loading': 'Загрузка…', + 'settings.meetings.loadError': 'Не удалось загрузить настройки встреч.', + 'settings.meetings.saveError': 'Не удалось сохранить настройки встреч.', + 'settings.meetings.saved': 'Сохранено', + 'settings.meetings.saving': 'Сохранение…', + 'settings.meetings.autoJoin.title': 'Политика автоподключения', + 'settings.meetings.autoJoin.desc': 'Когда в событии календаря есть ссылка Google Meet', + 'settings.meetings.autoJoin.askEachTime': 'Спрашивать каждый раз', + 'settings.meetings.autoJoin.always': 'Всегда подключаться', + 'settings.meetings.autoJoin.never': 'Никогда не подключаться', + 'settings.meetings.autoSummarize.title': 'Сводка после звонка', + 'settings.meetings.autoSummarize.desc': 'Создавать сводку после завершения звонка', + 'settings.meetings.autoSummarize.ask': 'Спрашивать после звонка', + 'settings.meetings.autoSummarize.always': 'Всегда создавать сводку', + 'settings.meetings.autoSummarize.never': 'Никогда не создавать сводку', + 'settings.meetings.listenOnly': 'Режим только прослушивания', + 'settings.meetings.listenOnlyDesc': 'Подключаться с выключенным микрофоном', + 'settings.meetings.ingestTranscripts': 'Импортировать расшифровки бэкенда', + 'settings.meetings.ingestTranscriptsDesc': 'Хранить расшифровки встреч в памяти', 'settings.sandbox.title': 'Выполнение в песочнице', 'settings.sandbox.menuDesc': 'Настройте бэкенды песочницы для изоляции инструментов агента.', 'settings.sandbox.loading': 'Загрузка…', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 1e32ffbbb..db0c7075d 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2981,6 +2981,11 @@ const messages: TranslationMap = { 'notifications.center.filterAll': '全部', 'notifications.center.markAllRead': '全部标记已读', 'notifications.center.title': '通知', + 'notifications.meeting.joinListen': '加入(仅收听)', + 'notifications.meeting.joinActive': '加入并回复', + 'notifications.meeting.skip': '不是这个', + 'notifications.meeting.alwaysJoin': '始终加入', + 'notifications.meeting.actionError': '无法完成该操作,请重试。', 'oauth.button.connecting': '连接中...', 'oauth.button.loopbackTimeout': '登录超时 — 浏览器未完成 OAuth 跳转。请重试。', 'oauth.login.continueWith': '继续使用', @@ -4049,6 +4054,28 @@ const messages: TranslationMap = { 'settings.permissions.folders': '它可以在哪里工作?', 'settings.permissions.foldersDesc': '助手读写的默认文件夹。您可以在高级设置中添加更多文件夹。', + 'settings.meetings.title': '会议', + 'settings.meetings.menuDesc': '会议助手自动加入、摘要和转录', + 'settings.meetings.desktopOnly': '会议设置仅在桌面端可用。', + 'settings.meetings.loading': '加载中…', + 'settings.meetings.loadError': '无法加载会议设置。', + 'settings.meetings.saveError': '无法保存会议设置。', + 'settings.meetings.saved': '已保存', + 'settings.meetings.saving': '保存中…', + 'settings.meetings.autoJoin.title': '自动加入策略', + 'settings.meetings.autoJoin.desc': '当日历事件包含 Google Meet 链接时', + 'settings.meetings.autoJoin.askEachTime': '每次询问', + 'settings.meetings.autoJoin.always': '始终加入', + 'settings.meetings.autoJoin.never': '从不加入', + 'settings.meetings.autoSummarize.title': '通话后摘要', + 'settings.meetings.autoSummarize.desc': '通话结束后生成摘要', + 'settings.meetings.autoSummarize.ask': '通话后询问', + 'settings.meetings.autoSummarize.always': '始终摘要', + 'settings.meetings.autoSummarize.never': '从不摘要', + 'settings.meetings.listenOnly': '仅旁听模式', + 'settings.meetings.listenOnlyDesc': '加入时静音麦克风', + 'settings.meetings.ingestTranscripts': '摄取后端转录', + 'settings.meetings.ingestTranscriptsDesc': '将会议转录存储到记忆中', 'settings.sandbox.title': '沙盒执行', 'settings.sandbox.menuDesc': '配置沙盒后端以隔离代理工具。', 'settings.sandbox.loading': '加载中…', diff --git a/app/src/lib/nativeNotifications/__tests__/service.test.ts b/app/src/lib/nativeNotifications/__tests__/service.test.ts index 342af8b40..86f51f979 100644 --- a/app/src/lib/nativeNotifications/__tests__/service.test.ts +++ b/app/src/lib/nativeNotifications/__tests__/service.test.ts @@ -80,6 +80,26 @@ describe('nativeNotifications service', () => { expect(items[0].deepLink).toBe('/settings/webhooks-triggers'); }); + it('passes action buttons through to the notification center (issue #3507)', () => { + store.dispatch(setPreference({ category: 'meetings', enabled: true })); + __handleCoreNotificationForTests({ + id: 'meet-auto-join:m1', + category: 'meetings', + title: 'Meeting starting: Standup', + body: 'Add Tiny to this meeting?', + timestamp_ms: 1, + actions: [ + { actionId: 'join_listen', label: 'Join (listen only)', payload: { meetingId: 'm1' } }, + { actionId: 'skip', label: 'Not this one', payload: { meetingId: 'm1' } }, + ], + }); + const items = store.getState().notifications.items; + expect(items).toHaveLength(1); + expect(items[0].actions).toHaveLength(2); + expect(items[0].actions?.[0].actionId).toBe('join_listen'); + expect(items[0].actions?.[0].payload).toEqual({ meetingId: 'm1' }); + }); + it('ignores core_notification payloads missing id/title', () => { __handleCoreNotificationForTests({ id: '', diff --git a/app/src/lib/nativeNotifications/service.ts b/app/src/lib/nativeNotifications/service.ts index 679cd7cf9..e4e5da024 100644 --- a/app/src/lib/nativeNotifications/service.ts +++ b/app/src/lib/nativeNotifications/service.ts @@ -3,6 +3,7 @@ import debug from 'debug'; import { socketService } from '../../services/socketService'; import { store } from '../../store'; import { + type NotificationAction, type NotificationCategory, type NotificationItem, notificationReceived, @@ -39,6 +40,10 @@ interface CoreNotificationPayload { body: string; deep_link?: string | null; timestamp_ms: number; + // Optional action buttons (e.g. meeting auto-join prompt, issue #3507). + // The Rust core serializes these camelCase, so the shape already matches + // the Redux `NotificationAction` type — pass through verbatim. + actions?: NotificationAction[]; } function windowIsFocused(): boolean { @@ -137,6 +142,7 @@ export function startNativeNotificationsService(): void { title: truncate(p.title, 120), body: truncate(p.body ?? '', 160), deepLink: p.deep_link ?? undefined, + actions: p.actions, }, serverTs ); @@ -206,6 +212,7 @@ export function __handleCoreNotificationForTests(payload: CoreNotificationPayloa title: truncate(payload.title, 120), body: truncate(payload.body ?? '', 160), deepLink: payload.deep_link ?? undefined, + actions: payload.actions, }, serverTs ); diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 6e52b9f6f..817c29e50 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -28,6 +28,7 @@ import EventLogPanel from '../components/settings/panels/EventLogPanel'; import IntegrationsPanel from '../components/settings/panels/IntegrationsPanel'; import LocalModelDebugPanel from '../components/settings/panels/LocalModelDebugPanel'; import McpServerPanel from '../components/settings/panels/McpServerPanel'; +import MeetingSettingsPanel from '../components/settings/panels/MeetingSettingsPanel'; import MemorySyncPanel from '../components/settings/panels/MemorySyncPanel'; import MigrationPanel from '../components/settings/panels/MigrationPanel'; import ModelHealthPanel from '../components/settings/panels/ModelHealthPanel'; @@ -144,6 +145,7 @@ const Settings = () => { )} /> )} /> )} /> + )} /> )} /> {/* ── System ──────────────────────────────────────────────── */} diff --git a/app/src/store/notificationSlice.ts b/app/src/store/notificationSlice.ts index b2525c47f..206d5f131 100644 --- a/app/src/store/notificationSlice.ts +++ b/app/src/store/notificationSlice.ts @@ -93,6 +93,15 @@ const notificationSlice = createSlice({ const item = state.items.find(i => i.id === action.payload.id); if (item) item.read = true; }, + // Drop the action buttons off a core notification once its prompt has been + // handled (e.g. a meeting auto-join join/skip succeeded). NotificationCenter + // only surfaces core items that still carry actions, so clearing them here + // removes the handled prompt from the actionable list and prevents a second + // click re-firing the same RPC (duplicate bot:join / always_join after skip). + clearNotificationActions(state, action: PayloadAction<{ id: string }>) { + const item = state.items.find(i => i.id === action.payload.id); + if (item) item.actions = undefined; + }, markAllRead(state) { for (const item of state.items) item.read = true; }, @@ -186,6 +195,7 @@ export const selectUnreadCount = (items: NotificationItem[]): number => export const { notificationReceived, markRead, + clearNotificationActions, markAllRead, clearAll, setPreference, diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts index 9a3c230c3..de766eb2b 100644 --- a/app/src/test/setup.ts +++ b/app/src/test/setup.ts @@ -165,7 +165,16 @@ vi.mock('../utils/tauriCommands', () => ({ }), openhumanGetMeetSettings: vi .fn() - .mockResolvedValue({ result: { auto_orchestrator_handoff: false }, logs: [] }), + .mockResolvedValue({ + result: { + auto_orchestrator_handoff: false, + auto_join_policy: 'ask_each_time', + auto_summarize_policy: 'ask', + listen_only_default: true, + ingest_backend_transcripts: false, + }, + logs: [], + }), exchangeToken: vi.fn(), invoke: vi.fn(), })); diff --git a/app/src/utils/tauriCommands/config.test.ts b/app/src/utils/tauriCommands/config.test.ts index 6e45aab3a..d0b6b03dc 100644 --- a/app/src/utils/tauriCommands/config.test.ts +++ b/app/src/utils/tauriCommands/config.test.ts @@ -82,6 +82,28 @@ describe('tauriCommands/config', () => { params: { auto_orchestrator_handoff: true }, }); }); + + test('forwards the Meeting Assistant fields (issue #3511)', async () => { + mockCallCoreRpc.mockResolvedValue({ + result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' }, + logs: [], + }); + await openhumanUpdateMeetSettings({ + auto_join_policy: 'always', + auto_summarize_policy: 'never', + listen_only_default: false, + ingest_backend_transcripts: true, + }); + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.config_update_meet_settings', + params: { + auto_join_policy: 'always', + auto_summarize_policy: 'never', + listen_only_default: false, + ingest_backend_transcripts: true, + }, + }); + }); }); describe('openhumanGetMeetSettings (#1299)', () => { @@ -92,12 +114,25 @@ describe('tauriCommands/config', () => { }); test('reads via openhuman.config_get_meet_settings', async () => { - mockCallCoreRpc.mockResolvedValue({ result: { auto_orchestrator_handoff: true }, logs: [] }); + mockCallCoreRpc.mockResolvedValue({ + result: { + auto_orchestrator_handoff: true, + auto_join_policy: 'always', + auto_summarize_policy: 'never', + listen_only_default: false, + ingest_backend_transcripts: true, + }, + logs: [], + }); const out = await openhumanGetMeetSettings(); expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.config_get_meet_settings', }); expect(out.result.auto_orchestrator_handoff).toBe(true); + expect(out.result.auto_join_policy).toBe('always'); + expect(out.result.auto_summarize_policy).toBe('never'); + expect(out.result.listen_only_default).toBe(false); + expect(out.result.ingest_backend_transcripts).toBe(true); }); }); diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 91e804ff6..6a03e5ec2 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -720,9 +720,32 @@ export async function openhumanGetAnalyticsSettings(): Promise< }); } -export async function openhumanUpdateMeetSettings(update: { +/** Meeting Assistant calendar auto-join policy (issue #3511). */ +export type MeetAutoJoinPolicy = 'ask_each_time' | 'always' | 'never'; +/** Meeting Assistant post-call summary policy. */ +export type MeetAutoSummarizePolicy = 'ask' | 'always' | 'never'; + +/** Full shape returned by `openhuman.config_get_meet_settings`. */ +export interface MeetSettings { + auto_orchestrator_handoff: boolean; + auto_join_policy: MeetAutoJoinPolicy; + auto_summarize_policy: MeetAutoSummarizePolicy; + listen_only_default: boolean; + ingest_backend_transcripts: boolean; +} + +/** Partial update accepted by `openhuman.config_update_meet_settings`. */ +export interface MeetSettingsUpdate { auto_orchestrator_handoff?: boolean; -}): Promise> { + auto_join_policy?: MeetAutoJoinPolicy; + auto_summarize_policy?: MeetAutoSummarizePolicy; + listen_only_default?: boolean; + ingest_backend_transcripts?: boolean; +} + +export async function openhumanUpdateMeetSettings( + update: MeetSettingsUpdate +): Promise> { if (!isTauri()) { throw new Error('Not running in Tauri'); } @@ -732,13 +755,11 @@ export async function openhumanUpdateMeetSettings(update: { }); } -export async function openhumanGetMeetSettings(): Promise< - CommandResponse<{ auto_orchestrator_handoff: boolean }> -> { +export async function openhumanGetMeetSettings(): Promise> { if (!isTauri()) { throw new Error('Not running in Tauri'); } - return await callCoreRpc>({ + return await callCoreRpc>({ method: 'openhuman.config_get_meet_settings', }); } diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs index 1f1c64fcc..c7b4a42ff 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -1049,6 +1049,23 @@ pub enum DomainEvent { meet_url: String, event_title: String, }, + /// A new meeting session was created (Pending) after a calendar Meet + /// link was detected and the auto-join prompt was surfaced (issue #3507). + MeetingSessionCreated { + meeting_id: String, + meet_url: String, + title: String, + /// Origin of the session: "calendar" | "manual" | "api". + source: String, + }, + /// Auto-join was triggered for a meeting — either policy == Always or the + /// user clicked a join action on the auto-join prompt (issue #3507). + MeetingAutoJoinTriggered { + meeting_id: String, + meet_url: String, + listen_only: bool, + correlation_id: String, + }, /// Reserved for PR-4: a post-meeting summary was generated from the /// transcript (action items, key decisions, etc.). MeetingSummaryGenerated { @@ -1202,6 +1219,8 @@ impl DomainEvent { | Self::BackendMeetSpeak { .. } | Self::InCallApprovalRequested { .. } | Self::MeetAutoJoinPrompt { .. } + | Self::MeetingSessionCreated { .. } + | Self::MeetingAutoJoinTriggered { .. } | Self::MeetingSummaryGenerated { .. } => "agent_meetings", } } @@ -1323,6 +1342,8 @@ impl DomainEvent { Self::BackendMeetSpeak { .. } => "BackendMeetSpeak", Self::InCallApprovalRequested { .. } => "InCallApprovalRequested", Self::MeetAutoJoinPrompt { .. } => "MeetAutoJoinPrompt", + Self::MeetingSessionCreated { .. } => "MeetingSessionCreated", + Self::MeetingAutoJoinTriggered { .. } => "MeetingAutoJoinTriggered", Self::MeetingSummaryGenerated { .. } => "MeetingSummaryGenerated", Self::Voice(_) => "Voice", } diff --git a/src/core/event_bus/events_tests.rs b/src/core/event_bus/events_tests.rs index 631ba8ca9..c50526c4a 100644 --- a/src/core/event_bus/events_tests.rs +++ b/src/core/event_bus/events_tests.rs @@ -495,6 +495,25 @@ fn all_variants_have_correct_domain() { }, "auth", ), + // Agent meetings (issue #3507 contract events) + ( + DomainEvent::MeetingSessionCreated { + meeting_id: "m-1".into(), + meet_url: "https://meet.google.com/abc-defg-hij".into(), + title: "Standup".into(), + source: "calendar".into(), + }, + "agent_meetings", + ), + ( + DomainEvent::MeetingAutoJoinTriggered { + meeting_id: "m-1".into(), + meet_url: "https://meet.google.com/abc-defg-hij".into(), + listen_only: true, + correlation_id: "corr-1".into(), + }, + "agent_meetings", + ), ]; for (event, expected_domain) in cases { @@ -507,6 +526,32 @@ fn all_variants_have_correct_domain() { } } +/// The two issue #3507 contract events expose stable variant names that +/// downstream audit/tracing relies on — guard them against silent renames. +#[test] +fn meeting_contract_events_have_stable_variant_names() { + assert_eq!( + DomainEvent::MeetingSessionCreated { + meeting_id: "m-1".into(), + meet_url: "https://meet.google.com/abc-defg-hij".into(), + title: "Standup".into(), + source: "calendar".into(), + } + .variant_name(), + "MeetingSessionCreated" + ); + assert_eq!( + DomainEvent::MeetingAutoJoinTriggered { + meeting_id: "m-1".into(), + meet_url: "https://meet.google.com/abc-defg-hij".into(), + listen_only: true, + correlation_id: "corr-1".into(), + } + .variant_name(), + "MeetingAutoJoinTriggered" + ); +} + /// Regression guard. An earlier revision of /// [`DomainEvent::ApprovalRequested`] published a `session_id` /// field that historically carried the verbatim JSON-RPC bearer. diff --git a/src/openhuman/agent_meetings/calendar.rs b/src/openhuman/agent_meetings/calendar.rs index b671d3aaa..2606d9d6c 100644 --- a/src/openhuman/agent_meetings/calendar.rs +++ b/src/openhuman/agent_meetings/calendar.rs @@ -25,6 +25,7 @@ use async_trait::async_trait; use crate::core::event_bus::{ publish_global, subscribe_global, DomainEvent, EventHandler, SubscriptionHandle, }; +use crate::openhuman::app_state::peek_cached_current_user_identity; use crate::openhuman::config::rpc as config_rpc; use crate::openhuman::notifications::bus::publish_core_notification; use crate::openhuman::notifications::types::{ @@ -128,123 +129,368 @@ impl EventHandler for MeetCalendarSubscriber { .unwrap_or("Untitled meeting") .to_string(); + // Resolve the meeting owner (the human the bot should reply to) so the + // auto-join can pass `respondToParticipant` to the backend bot. The + // calendar event payload carries the user as a "self" attendee whose + // displayName matches their Google Meet caption label exactly — the + // most accurate anchor. Falls back to the signed-in account identity. + let owner_display_name = + owner_name_from_event_payload(payload).or_else(fallback_owner_from_account); + tracing::info!( trigger = %trigger, meet_url = %meet_url, title = %event_title, + owner_resolved = owner_display_name.is_some(), "[meet:calendar] detected imminent Google Meet meeting" ); - // Check the auto-join policy. - let config = match config_rpc::load_config_with_timeout().await { - Ok(c) => c, - Err(e) => { - tracing::warn!( - error = %e, - "[meet:calendar] failed to load config, defaulting to ask" - ); - // Publish prompt as fallback. - publish_global(DomainEvent::MeetAutoJoinPrompt { - meet_url, - event_title, - }); - return; - } - }; + handle_calendar_meeting_candidate(meet_url, event_title, owner_display_name).await; + } +} - match config.meet.auto_join_policy { - crate::openhuman::config::schema::AutoJoinPolicy::Never => { - tracing::debug!("[meet:calendar] auto_join_policy=never, dropping"); - return; - } - crate::openhuman::config::schema::AutoJoinPolicy::Always => { - tracing::info!( - meet_url = %meet_url, - title = %event_title, - "[meet:calendar] auto_join_policy=always, joining automatically" - ); - let correlation_id = uuid::Uuid::new_v4().to_string(); - tokio::spawn(auto_join_meeting( - meet_url, - event_title, - correlation_id, - true, // calendar auto-join bots are passive listeners by default - )); - return; - } - crate::openhuman::config::schema::AutoJoinPolicy::AskEachTime => { - // Default: ask — create a Pending session and surface an - // actionable notification (issue #3507). The buttons route - // through `agent_meetings_notification_action`. - tracing::info!( - meet_url = %meet_url, - title = %event_title, - "[meet:calendar] auto_join_policy=ask_each_time, prompting user" - ); - - // Dedupe: one prompt per meeting URL while a session is - // still open (Composio can re-fire the trigger on event - // updates). - if let Ok(Some(existing)) = store::get_session_by_meet_url(&config, &meet_url) { - if existing.status != MeetingSessionStatus::Ended { - tracing::debug!( - meeting_id = %existing.id, - "[meet:calendar] open session already exists — skipping re-prompt" - ); - return; +/// Extract the meeting owner's display name from a Google Calendar event +/// payload — the participant the bot should anchor its replies to. +/// +/// Google Calendar marks the connected user's own attendee/organizer/creator +/// record with `self: true`. That record's `displayName` is the same label +/// Google Meet shows in caption regions, so it is the most reliable anchor for +/// the backend bot's `respondToParticipant` gate. Falls back to the local part +/// of the `self` email when no display name is present. +fn owner_name_from_event_payload(payload: &serde_json::Value) -> Option { + for root in [payload, payload.get("data").unwrap_or(payload)] { + // attendees[] with self == true + if let Some(attendees) = root.get("attendees").and_then(|a| a.as_array()) { + for att in attendees { + if att.get("self").and_then(|v| v.as_bool()) == Some(true) { + if let Some(name) = name_or_email_local_part(att) { + return Some(name); } } + } + } - let meeting_id = uuid::Uuid::new_v4().to_string(); - let now_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; - let session = MeetingSession { - id: meeting_id.clone(), - meet_url: meet_url.clone(), - title: Some(event_title.clone()), - calendar_event_id: None, - status: MeetingSessionStatus::Pending, - source: AutoJoinSource::Calendar, - thread_id: None, - transcript_received: false, - summary_generated: false, - created_at_ms: now_ms, - updated_at_ms: now_ms, - }; - if let Err(e) = store::create_session(&config, &session) { - tracing::warn!("[meet:calendar] session create failed (non-fatal): {e}"); + // organizer / creator with self == true + for key in ["organizer", "creator"] { + if let Some(person) = root.get(key) { + if person.get("self").and_then(|v| v.as_bool()) == Some(true) { + if let Some(name) = name_or_email_local_part(person) { + return Some(name); + } } + } + } + } + None +} - let action_payload = serde_json::json!({ - "meetingId": meeting_id, - "meetUrl": meet_url, - "title": event_title, - }); - let action = |action_id: &str, label: &str| CoreNotificationAction { - action_id: action_id.to_string(), - label: label.to_string(), - payload: Some(action_payload.clone()), - }; - publish_core_notification(CoreNotificationEvent { - id: format!("meet-auto-join:{meeting_id}"), - category: CoreNotificationCategory::Meetings, - title: format!("Meeting starting: {event_title}"), - body: "Add Tiny to this meeting?".to_string(), - deep_link: None, - timestamp_ms: now_ms, - actions: Some(vec![ - action("join_listen", "Join (listen only)"), - action("join_active", "Join & reply"), - action("skip", "Not this one"), - action("always_join", "Always join"), - ]), - }); +/// Pull a usable display name from a calendar person object: prefer +/// `displayName`, else the local part of `email` (before the `@`). +fn name_or_email_local_part(person: &serde_json::Value) -> Option { + let trimmed = |v: Option<&serde_json::Value>| -> Option { + v.and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; - // Legacy prompt event kept for existing consumers. + if let Some(name) = trimmed(person.get("displayName")) { + return Some(name); + } + let email = trimmed(person.get("email"))?; + let local = email.split('@').next().unwrap_or(&email).trim(); + if local.is_empty() { + None + } else { + Some(local.to_string()) + } +} + +/// Decide the effective `listen_only` mode for an auto-join. +/// +/// Reply mode needs a known anchor (the participant the bot replies to). When +/// no anchor resolved we force listen-only — the bot still joins, transcribes, +/// and summarizes as configured, but never speaks — instead of replying to +/// every speaker indiscriminately. +pub(crate) fn effective_listen_only(requested_listen_only: bool, has_anchor: bool) -> bool { + requested_listen_only || !has_anchor +} + +/// Fallback owner identity from the signed-in OpenHuman account when the +/// calendar payload carries no `self` attendee (e.g. heartbeat-polled events +/// that surface only a title + URL). Network-free cache peek. +fn fallback_owner_from_account() -> Option { + let identity = peek_cached_current_user_identity()?; + owner_from_identity(identity.name.as_deref(), identity.email.as_deref()) +} + +/// Pure: derive a reply anchor from an identity's `(name, email)`. Prefers a +/// non-blank name, else the local part of the email. Returns `None` when +/// neither yields a usable value. +fn owner_from_identity(name: Option<&str>, email: Option<&str>) -> Option { + let clean = |s: Option<&str>| -> Option { + s.map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; + + if let Some(name) = clean(name) { + return Some(name); + } + let email = clean(email)?; + let local = email.split('@').next().unwrap_or(&email).trim(); + if local.is_empty() { + None + } else { + Some(local.to_string()) + } +} + +/// Returns `true` when the given policy causes `handle_calendar_meeting_candidate` +/// to publish its own actionable notification — the heartbeat planner uses this +/// to skip the generic "meeting starting" plain card. +/// +/// `AskEachTime` surfaces an interactive card (join/skip buttons), so the plain +/// card is redundant. `Always` and `Never` do not surface an interactive card, +/// so the plain card is still useful. +pub(crate) fn auto_join_policy_owns_notification( + policy: &crate::openhuman::config::schema::AutoJoinPolicy, +) -> bool { + use crate::openhuman::config::schema::AutoJoinPolicy; + matches!(policy, AutoJoinPolicy::AskEachTime) +} + +/// Apply the user's meeting auto-join policy to a calendar-discovered meeting. +/// +/// Returns `true` when this function published (or will publish) its own +/// actionable notification — the heartbeat planner should skip its plain card +/// in that case. Returns `false` for `Always` and `Never` so the caller still +/// fires the generic "meeting starting" reminder. +/// +/// This is shared by live Composio calendar triggers and the heartbeat +/// calendar poller. Both sources can discover the same imminent meeting; the +/// Pending-session dedupe below keeps the ask flow to one actionable prompt. +pub async fn handle_calendar_meeting_candidate( + meet_url: String, + event_title: String, + owner_display_name: Option, +) -> bool { + // 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. + 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 { + Ok(c) => c, + Err(e) => { + tracing::warn!( + error = %e, + "[meet:calendar] failed to load config, defaulting to ask" + ); + // Keep the legacy prompt for existing consumers, but return `false` + // so the heartbeat planner still emits its plain reminder card. We + // can't build the actionable CoreNotificationEvent here (no config, + // so no persisted session for the action handler to resolve), and + // returning `true` would suppress every notification surface — + // silently "delivering" the meeting with nothing the user can act on. + publish_global(DomainEvent::MeetAutoJoinPrompt { + meet_url, + event_title, + }); + return false; + } + }; + + match config.meet.auto_join_policy { + crate::openhuman::config::schema::AutoJoinPolicy::Never => { + tracing::debug!("[meet:calendar] auto_join_policy=never, dropping"); + false + } + crate::openhuman::config::schema::AutoJoinPolicy::Always => { + // Dedup: one auto-join per meeting URL while an active session exists. + // The heartbeat planner can forward the same event from multiple + // stages (final_call + starting_now) and Composio can re-fire on + // event updates — without this guard a single meeting generates + // multiple bot:join calls with distinct correlation IDs that the + // backend cannot deduplicate. + if let Ok(Some(existing)) = store::get_session_by_meet_url(&config, &meet_url) { + if existing.status != MeetingSessionStatus::Ended { + tracing::debug!( + meeting_id = %existing.id, + "[meet:calendar] auto_join_policy=always, open session exists — skipping duplicate join" + ); + return false; + } + } + + tracing::info!( + meet_url = %meet_url, + title = %event_title, + "[meet:calendar] auto_join_policy=always, joining automatically" + ); + let correlation_id = uuid::Uuid::new_v4().to_string(); + // Honor the user's listen-only default (issue #3511 settings + // UI), but force listen-only when no reply anchor resolved so the + // bot transcribes/summarizes instead of replying to everyone. + let listen_only = effective_listen_only(config.meet.listen_only_default, has_anchor); + if listen_only && !config.meet.listen_only_default { + tracing::warn!( + meet_url = %meet_url, + "[meet:calendar] forcing listen-only auto-join (no reply anchor)" + ); + } + + // Persist a session keyed by correlation_id so future trigger + // firings find the existing entry and skip (see dedup guard above). + let now_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; + let session = MeetingSession { + id: correlation_id.clone(), + meet_url: meet_url.clone(), + title: Some(event_title.clone()), + calendar_event_id: None, + status: MeetingSessionStatus::Joined, + source: AutoJoinSource::Calendar, + thread_id: None, + transcript_received: false, + summary_generated: false, + created_at_ms: now_ms, + updated_at_ms: now_ms, + }; + if let Err(e) = store::create_session(&config, &session) { + tracing::warn!( + error = %e, + "[meet:calendar] session create failed for always-join; dedup best-effort only" + ); + } + + // Auto-join transparency: announce the triggered join so + // downstream consumers (UI banner, thread bus) can react + // (issue #3507 contract event). + publish_global(DomainEvent::MeetingAutoJoinTriggered { + meeting_id: correlation_id.clone(), + meet_url: meet_url.clone(), + listen_only, + correlation_id: correlation_id.clone(), + }); + tokio::spawn(auto_join_meeting( + meet_url, + event_title, + correlation_id, + listen_only, + owner_display_name, + )); + false + } + crate::openhuman::config::schema::AutoJoinPolicy::AskEachTime => { + // Default: ask — create a Pending session and surface an + // actionable notification (issue #3507). The buttons route + // through `agent_meetings_notification_action`. + tracing::info!( + meet_url = %meet_url, + title = %event_title, + "[meet:calendar] auto_join_policy=ask_each_time, prompting user" + ); + + // Dedupe: one prompt per meeting URL while a session is + // still open (Composio can re-fire the trigger on event + // updates; heartbeat can also poll the same event). + if let Ok(Some(existing)) = store::get_session_by_meet_url(&config, &meet_url) { + if existing.status != MeetingSessionStatus::Ended { + tracing::debug!( + meeting_id = %existing.id, + "[meet:calendar] open session already exists — skipping re-prompt" + ); + return true; + } + } + + let meeting_id = uuid::Uuid::new_v4().to_string(); + let now_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; + let session = MeetingSession { + id: meeting_id.clone(), + meet_url: meet_url.clone(), + title: Some(event_title.clone()), + calendar_event_id: None, + status: MeetingSessionStatus::Pending, + source: AutoJoinSource::Calendar, + thread_id: None, + transcript_received: false, + summary_generated: false, + created_at_ms: now_ms, + updated_at_ms: now_ms, + }; + if let Err(e) = store::create_session(&config, &session) { + // The action buttons carry this `meetingId`; the + // `agent_meetings_notification_action` handler resolves the + // session by id. If persistence failed there is no session to + // resolve, so publishing the actionable card would hand the user + // Join/Skip/Always buttons that fail against missing state. + // Fall back to the plain reminder path instead. + tracing::warn!( + error = %e, + "[meet:calendar] session create failed; falling back to plain reminder without actionable buttons" + ); publish_global(DomainEvent::MeetAutoJoinPrompt { meet_url, event_title, }); + return false; } + + // Announce the new Pending session (issue #3507 contract event). + publish_global(DomainEvent::MeetingSessionCreated { + meeting_id: meeting_id.clone(), + meet_url: meet_url.clone(), + title: event_title.clone(), + source: "calendar".to_string(), + }); + + // Carry the resolved reply anchor through the notification buttons so + // `handle_notification_action` can pass `respondToParticipant` to the + // backend bot when the user chooses "Join & reply". + let action_payload = build_action_payload( + &meeting_id, + &meet_url, + &event_title, + owner_display_name.as_deref(), + ); + let action = |action_id: &str, label: &str| CoreNotificationAction { + action_id: action_id.to_string(), + label: label.to_string(), + payload: Some(action_payload.clone()), + }; + publish_core_notification(CoreNotificationEvent { + id: format!("meet-auto-join:{meeting_id}"), + category: CoreNotificationCategory::Meetings, + title: format!("Meeting starting: {event_title}"), + body: "Add Tiny to this meeting?".to_string(), + deep_link: None, + timestamp_ms: now_ms, + actions: Some(vec![ + action("join_listen", "Join (listen only)"), + action("join_active", "Join & reply"), + action("skip", "Not this one"), + action("always_join", "Always join"), + ]), + }); + + // Legacy prompt event kept for existing consumers. + publish_global(DomainEvent::MeetAutoJoinPrompt { + meet_url, + event_title, + }); + true } } } @@ -332,6 +578,31 @@ fn is_meeting_url(s: &str) -> bool { MEETING_HOST_PATTERNS.iter().any(|pat| s.contains(pat)) } +/// Pull the first parseable meeting URL out of a free-form string. +/// +/// Calendar `location` is free-form and commonly mixes a label with a URL +/// (e.g. `"Zoom Meeting: https://zoom.us/j/123"`). Returning the raw string +/// would produce a `meeting_url` that `url::Url::parse` later rejects, leaving +/// Join/Skip buttons that silently fail. So scan whitespace-separated tokens, +/// strip surrounding punctuation (including trailing `.`), and return the first +/// token that both matches a known meeting host and parses as an http(s) URL. +fn extract_meeting_url_from_text(text: &str) -> Option { + text.split_whitespace() + .map(|tok| { + tok.trim_matches(|c: char| { + matches!( + c, + '(' | ')' | '[' | ']' | '<' | '>' | ',' | ';' | '"' | '\'' | '.' + ) + }) + }) + .filter(|tok| is_meeting_url(tok)) + .find_map(|tok| { + let parsed = url::Url::parse(tok).ok()?; + matches!(parsed.scheme(), "http" | "https").then(|| parsed.to_string()) + }) +} + /// Extract a meeting URL from a Composio Google Calendar trigger payload. /// /// Supports Google Meet, Zoom, Teams, and Webex links. Searches: @@ -363,10 +634,13 @@ fn extract_meet_url(payload: &serde_json::Value) -> Option { } } - // location field (Zoom/Teams links are often pasted here) + // location field (Zoom/Teams links are often pasted here as free-form + // text, e.g. "Zoom Meeting: https://zoom.us/j/123"). Extract only the + // parseable URL token — returning the whole string would fail later + // validation in handle_join → validate_meeting_url. if let Some(loc) = root.get("location").and_then(|v| v.as_str()) { - if is_meeting_url(loc) { - return Some(loc.to_string()); + if let Some(url) = extract_meeting_url_from_text(loc) { + return Some(url); } } } @@ -404,6 +678,7 @@ async fn auto_join_meeting( event_title: String, correlation_id: String, listen_only: bool, + owner_display_name: Option, ) { use crate::openhuman::socket::global_socket_manager; use serde_json::json; @@ -416,18 +691,19 @@ async fn auto_join_meeting( } }; - let payload = json!({ - "meetUrl": meet_url, - "displayName": "Tiny", - "correlationId": correlation_id, - "listenOnly": listen_only, - }); + let payload = build_auto_join_payload( + &meet_url, + &correlation_id, + listen_only, + owner_display_name.as_deref(), + ); tracing::info!( meet_url = %meet_url, title = %event_title, correlation_id = %correlation_id, listen_only = listen_only, + respond_to = ?owner_display_name, "[meet:calendar] emitting bot:join" ); @@ -439,6 +715,54 @@ async fn auto_join_meeting( } } +/// Build the notification action payload carried by the AskEachTime buttons. +/// +/// Pure function so the `respondToParticipant` anchor wiring is unit-testable. +/// A `None`/empty owner omits `respondToParticipant`. +fn build_action_payload( + meeting_id: &str, + meet_url: &str, + title: &str, + owner_display_name: Option<&str>, +) -> serde_json::Value { + let mut payload = serde_json::json!({ + "meetingId": meeting_id, + "meetUrl": meet_url, + "title": title, + }); + if let Some(map) = payload.as_object_mut() { + if let Some(owner) = owner_display_name.map(str::trim).filter(|s| !s.is_empty()) { + map.insert("respondToParticipant".to_string(), serde_json::json!(owner)); + } + } + payload +} + +/// Build the `bot:join` Socket.IO payload for a calendar auto-join. +/// +/// 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". +fn build_auto_join_payload( + meet_url: &str, + correlation_id: &str, + listen_only: bool, + owner_display_name: Option<&str>, +) -> serde_json::Value { + let mut payload = serde_json::json!({ + "meetUrl": meet_url, + "displayName": "Tiny", + "correlationId": correlation_id, + "listenOnly": listen_only, + }); + if let Some(map) = payload.as_object_mut() { + if let Some(owner) = owner_display_name.map(str::trim).filter(|s| !s.is_empty()) { + map.insert("respondToParticipant".to_string(), serde_json::json!(owner)); + } + } + payload +} + #[cfg(test)] mod tests { use super::*; @@ -614,4 +938,260 @@ mod tests { Some("https://meet.webex.com/meet/abc") ); } + + // ── auto_join_policy_owns_notification ────────────────────── + + #[test] + fn ask_each_time_owns_notification() { + use crate::openhuman::config::schema::AutoJoinPolicy; + assert!(auto_join_policy_owns_notification( + &AutoJoinPolicy::AskEachTime + )); + } + + #[test] + fn always_does_not_own_notification() { + use crate::openhuman::config::schema::AutoJoinPolicy; + assert!(!auto_join_policy_owns_notification(&AutoJoinPolicy::Always)); + } + + #[test] + fn never_does_not_own_notification() { + use crate::openhuman::config::schema::AutoJoinPolicy; + assert!(!auto_join_policy_owns_notification(&AutoJoinPolicy::Never)); + } + + // ── owner_name_from_event_payload ─────────────────────────── + + #[test] + fn owner_from_self_attendee_display_name() { + let payload = json!({ + "summary": "Standup", + "attendees": [ + { "email": "bob@x.com", "displayName": "Bob" }, + { "email": "me@x.com", "self": true, "displayName": "Aditya L" }, + ] + }); + assert_eq!( + owner_name_from_event_payload(&payload).as_deref(), + Some("Aditya L") + ); + } + + #[test] + fn owner_from_self_attendee_email_local_part_when_no_name() { + let payload = json!({ + "attendees": [ + { "email": "aditya@syvora.com", "self": true }, + ] + }); + assert_eq!( + owner_name_from_event_payload(&payload).as_deref(), + Some("aditya") + ); + } + + #[test] + fn owner_from_nested_data_attendees() { + let payload = json!({ + "data": { + "attendees": [ + { "email": "me@x.com", "self": true, "displayName": "Nested Me" }, + ] + } + }); + assert_eq!( + owner_name_from_event_payload(&payload).as_deref(), + Some("Nested Me") + ); + } + + #[test] + fn owner_from_organizer_self() { + let payload = json!({ + "organizer": { "email": "org@x.com", "self": true, "displayName": "Organizer" } + }); + assert_eq!( + owner_name_from_event_payload(&payload).as_deref(), + Some("Organizer") + ); + } + + #[test] + fn owner_none_when_no_self_record() { + let payload = json!({ + "attendees": [ + { "email": "bob@x.com", "displayName": "Bob" }, + ], + "organizer": { "email": "org@x.com", "displayName": "Org" } + }); + assert!(owner_name_from_event_payload(&payload).is_none()); + } + + #[test] + fn owner_ignores_blank_display_name_falls_to_email() { + let payload = json!({ + "attendees": [ + { "email": "carol@x.com", "self": true, "displayName": " " }, + ] + }); + assert_eq!( + owner_name_from_event_payload(&payload).as_deref(), + Some("carol") + ); + } + + // ── build_auto_join_payload ───────────────────────────────── + + #[test] + fn auto_join_payload_includes_respond_to_participant() { + let p = build_auto_join_payload( + "https://meet.google.com/abc", + "corr-1", + false, + Some("Aditya"), + ); + assert_eq!(p["respondToParticipant"], json!("Aditya")); + assert_eq!(p["displayName"], json!("Tiny")); + assert_eq!(p["listenOnly"], json!(false)); + assert_eq!(p["correlationId"], json!("corr-1")); + } + + #[test] + fn auto_join_payload_omits_respond_to_participant_when_absent() { + let p = build_auto_join_payload("https://meet.google.com/abc", "corr-1", true, None); + assert!(p.get("respondToParticipant").is_none()); + } + + #[test] + fn auto_join_payload_omits_respond_to_participant_when_blank() { + let p = build_auto_join_payload("https://meet.google.com/abc", "corr-1", true, Some(" ")); + assert!(p.get("respondToParticipant").is_none()); + } + + // ── effective_listen_only ─────────────────────────────────── + + #[test] + fn listen_only_forced_when_no_anchor() { + // Reply requested (listen_only=false) but no anchor → forced listen-only. + assert!(effective_listen_only(false, false)); + } + + #[test] + fn reply_mode_kept_when_anchor_present() { + assert!(!effective_listen_only(false, true)); + } + + #[test] + fn listen_only_stays_listen_only_regardless_of_anchor() { + assert!(effective_listen_only(true, true)); + assert!(effective_listen_only(true, false)); + } + + // ── owner_from_identity ───────────────────────────────────── + + #[test] + fn owner_from_identity_prefers_name() { + assert_eq!( + owner_from_identity(Some("Shanu Goyanka"), Some("shanu@x.com")).as_deref(), + Some("Shanu Goyanka") + ); + } + + #[test] + fn owner_from_identity_falls_back_to_email_local_part() { + assert_eq!( + owner_from_identity(Some(" "), Some("shanu@x.com")).as_deref(), + Some("shanu") + ); + assert_eq!( + owner_from_identity(None, Some("aditya@syvora.com")).as_deref(), + Some("aditya") + ); + } + + #[test] + fn owner_from_identity_none_when_both_blank() { + assert!(owner_from_identity(None, None).is_none()); + assert!(owner_from_identity(Some(" "), Some(" ")).is_none()); + } + + // ── build_action_payload ──────────────────────────────────── + + #[test] + fn action_payload_includes_respond_to_participant() { + let p = build_action_payload( + "m-1", + "https://meet.google.com/abc", + "Standup", + Some("Shanu Goyanka"), + ); + assert_eq!(p["meetingId"], json!("m-1")); + assert_eq!(p["meetUrl"], json!("https://meet.google.com/abc")); + assert_eq!(p["title"], json!("Standup")); + assert_eq!(p["respondToParticipant"], json!("Shanu Goyanka")); + } + + #[test] + fn action_payload_omits_respond_to_participant_when_absent_or_blank() { + let p = build_action_payload("m-1", "https://meet.google.com/abc", "Standup", None); + assert!(p.get("respondToParticipant").is_none()); + let p2 = build_action_payload("m-1", "https://meet.google.com/abc", "Standup", Some(" ")); + assert!(p2.get("respondToParticipant").is_none()); + } + + // ── extract_meeting_url_from_text ─────────────────────────── + + #[test] + fn extracts_url_from_free_form_location_with_label() { + assert_eq!( + extract_meeting_url_from_text("Zoom Meeting: https://zoom.us/j/123456789"), + Some("https://zoom.us/j/123456789".to_string()) + ); + } + + #[test] + fn strips_surrounding_parens_from_url() { + assert_eq!( + extract_meeting_url_from_text("Join here (https://zoom.us/j/999),"), + Some("https://zoom.us/j/999".to_string()) + ); + } + + #[test] + fn strips_trailing_period_from_url() { + assert_eq!( + extract_meeting_url_from_text("Link: https://zoom.us/j/123."), + Some("https://zoom.us/j/123".to_string()) + ); + } + + #[test] + fn returns_none_for_non_meeting_free_form() { + assert!(extract_meeting_url_from_text("Office kitchen, 2nd floor").is_none()); + } + + #[test] + fn extracts_zoom_from_free_form_location_field() { + let payload = json!({ + "summary": "Team sync", + "location": "Zoom Meeting: https://zoom.us/j/987654321" + }); + assert_eq!( + extract_meet_url(&payload).as_deref(), + Some("https://zoom.us/j/987654321") + ); + } + + #[test] + fn extracts_teams_from_free_form_location_field() { + let payload = json!({ + "summary": "Planning", + "location": "MS Teams: https://teams.microsoft.com/l/meetup-join/abc" + }); + assert_eq!( + extract_meet_url(&payload).as_deref(), + Some("https://teams.microsoft.com/l/meetup-join/abc") + ); + } } diff --git a/src/openhuman/agent_meetings/ops.rs b/src/openhuman/agent_meetings/ops.rs index 97c458dda..6bd1dca3a 100644 --- a/src/openhuman/agent_meetings/ops.rs +++ b/src/openhuman/agent_meetings/ops.rs @@ -283,6 +283,70 @@ fn build_join_payload( payload } +/// Pure: extract the reply anchor (`respondToParticipant`) carried by a +/// notification action payload. Returns `None` when absent or blank. +fn anchor_from_action_payload(payload: &Value) -> Option { + payload + .get("respondToParticipant") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) +} + +/// Pure: build the `agent_meetings_join` param map for an AskEachTime +/// notification action. +/// +/// Encapsulates the listen-only decision (reply mode without a known anchor is +/// downgraded to listen-only via [`super::calendar::effective_listen_only`]), +/// the wake phrase, and the `respond_to_participant` anchor wiring so they are +/// unit-testable without a live socket/config. +fn build_notification_join_map( + action_id: &str, + meet_url: &str, + correlation_id: &str, + display_name: Option<&str>, + respond_to_participant: Option<&str>, + config_listen_only_default: bool, +) -> Map { + let requested_listen_only = match action_id { + "join_listen" => true, + "join_active" => false, + _ => config_listen_only_default, + }; + // Reply mode needs a known anchor. Without one, downgrade to listen-only + // (still transcribes + summarizes) instead of replying to every speaker. + let listen_only = super::calendar::effective_listen_only( + requested_listen_only, + respond_to_participant.is_some(), + ); + if listen_only && !requested_listen_only { + tracing::warn!( + action_id = %action_id, + "[agent_meetings] no reply anchor resolved — forcing listen-only join" + ); + } + + let mut join = Map::new(); + join.insert("meet_url".to_string(), json!(meet_url)); + join.insert("correlation_id".to_string(), json!(correlation_id)); + join.insert("listen_only".to_string(), json!(listen_only)); + if let Some(name) = display_name { + join.insert("display_name".to_string(), json!(name)); + } + if !listen_only { + // Reply mode: the participant addresses the bot as "Hey Tiny"; the + // wake phrase is always required (no implicit address). + join.insert("wake_phrase".to_string(), json!("Hey Tiny")); + // Anchor replies to the meeting owner so the bot knows who it is + // answering (empty/absent = respond to everyone). + if let Some(owner) = respond_to_participant { + join.insert("respond_to_participant".to_string(), json!(owner)); + } + } + join +} + /// Handle `openhuman.agent_meetings_join`. pub async fn handle_join(params: Map) -> Result { let req: BackendMeetJoinRequest = serde_json::from_value(Value::Object(params)) @@ -493,6 +557,15 @@ pub async fn handle_notification_action(params: Map) -> Result) -> Result true, - "join_active" => false, - _ => config.meet.listen_only_default, - }; - - let mut join = Map::new(); - join.insert("meet_url".to_string(), json!(meet_url)); - join.insert( - "correlation_id".to_string(), - json!(meeting_id - .clone() - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())), + let correlation_id = meeting_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let join = build_notification_join_map( + &action_id, + &meet_url, + &correlation_id, + display_name.as_deref(), + respond_to_participant.as_deref(), + config.meet.listen_only_default, ); - join.insert("listen_only".to_string(), json!(listen_only)); - if let Some(name) = display_name { - join.insert("display_name".to_string(), json!(name)); - } - if !listen_only { - // Reply mode: the participant addresses the bot as "Hey Tiny"; - // the wake phrase is always required (no implicit address). - join.insert("wake_phrase".to_string(), json!("Hey Tiny")); - } handle_join(join).await } @@ -615,6 +676,101 @@ mod tests { assert!(err.contains("unknown action_id")); } + // ── anchor_from_action_payload ────────────────────────────── + + #[test] + fn anchor_extracted_from_payload() { + let payload = json!({ "respondToParticipant": "Shanu Goyanka" }); + assert_eq!( + anchor_from_action_payload(&payload).as_deref(), + Some("Shanu Goyanka") + ); + } + + #[test] + fn anchor_none_when_absent_or_blank() { + assert!(anchor_from_action_payload(&json!({})).is_none()); + assert!(anchor_from_action_payload(&json!({ "respondToParticipant": " " })).is_none()); + } + + // ── build_notification_join_map ───────────────────────────── + + #[test] + fn join_map_listen_only_action_has_no_anchor_or_wake() { + let join = build_notification_join_map( + "join_listen", + "https://meet.google.com/abc", + "corr-1", + Some("Tiny"), + Some("Shanu"), + false, + ); + assert_eq!(join["listen_only"], json!(true)); + assert_eq!(join["display_name"], json!("Tiny")); + // listen-only never carries wake/anchor + assert!(!join.contains_key("wake_phrase")); + assert!(!join.contains_key("respond_to_participant")); + } + + #[test] + fn join_map_active_with_anchor_carries_wake_and_anchor() { + let join = build_notification_join_map( + "join_active", + "https://meet.google.com/abc", + "corr-1", + None, + Some("Shanu"), + false, + ); + assert_eq!(join["listen_only"], json!(false)); + assert_eq!(join["wake_phrase"], json!("Hey Tiny")); + assert_eq!(join["respond_to_participant"], json!("Shanu")); + assert!(!join.contains_key("display_name")); + } + + #[test] + fn join_map_active_without_anchor_downgrades_to_listen_only() { + let join = build_notification_join_map( + "join_active", + "https://meet.google.com/abc", + "corr-1", + None, + None, + false, + ); + // No anchor → forced listen-only, no wake/anchor emitted. + assert_eq!(join["listen_only"], json!(true)); + assert!(!join.contains_key("wake_phrase")); + assert!(!join.contains_key("respond_to_participant")); + } + + #[test] + fn join_map_always_join_uses_config_default() { + // always_join + config default reply (false) + anchor → reply mode. + let reply = build_notification_join_map( + "always_join", + "https://meet.google.com/abc", + "corr-1", + None, + Some("Shanu"), + false, + ); + assert_eq!(reply["listen_only"], json!(false)); + assert_eq!(reply["respond_to_participant"], json!("Shanu")); + + // always_join + config default listen-only (true) → listen-only. + let passive = build_notification_join_map( + "always_join", + "https://meet.google.com/abc", + "corr-1", + None, + Some("Shanu"), + true, + ); + assert_eq!(passive["listen_only"], json!(true)); + assert!(!passive.contains_key("respond_to_participant")); + } + #[tokio::test] async fn notification_action_join_requires_meet_url() { let mut params = Map::new(); diff --git a/src/openhuman/config/ops/ui.rs b/src/openhuman/config/ops/ui.rs index 52898c3d4..a1081ed5f 100644 --- a/src/openhuman/config/ops/ui.rs +++ b/src/openhuman/config/ops/ui.rs @@ -3,7 +3,7 @@ use serde_json::json; -use crate::openhuman::config::Config; +use crate::openhuman::config::{AutoJoinPolicy, AutoSummarizePolicy, Config}; use crate::openhuman::screen_intelligence; use crate::rpc::RpcOutcome; @@ -36,6 +36,14 @@ pub struct AnalyticsSettingsPatch { #[derive(Debug, Clone, Default)] pub struct MeetSettingsPatch { pub auto_orchestrator_handoff: Option, + /// Calendar auto-join policy (issue #3511 settings UI). + pub auto_join_policy: Option, + /// Post-call auto-summarize policy. + pub auto_summarize_policy: Option, + /// When `true`, the bot joins in listen-only mode (mic muted). + pub listen_only_default: Option, + /// When `true`, backend-bot transcripts are ingested into memory. + pub ingest_backend_transcripts: Option, } #[derive(Debug, Clone, Default)] @@ -210,6 +218,18 @@ pub async fn apply_meet_settings( if let Some(enabled) = update.auto_orchestrator_handoff { config.meet.auto_orchestrator_handoff = enabled; } + if let Some(policy) = update.auto_join_policy { + config.meet.auto_join_policy = policy; + } + if let Some(policy) = update.auto_summarize_policy { + config.meet.auto_summarize_policy = policy; + } + if let Some(listen_only) = update.listen_only_default { + config.meet.listen_only_default = listen_only; + } + if let Some(ingest) = update.ingest_backend_transcripts { + config.meet.ingest_backend_transcripts = ingest; + } 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 b35b47278..95616391a 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -1039,6 +1039,7 @@ async fn apply_meet_settings_updates_handoff_flag() { &mut cfg, MeetSettingsPatch { auto_orchestrator_handoff: Some(true), + ..Default::default() }, ) .await @@ -1049,6 +1050,7 @@ async fn apply_meet_settings_updates_handoff_flag() { &mut cfg, MeetSettingsPatch { auto_orchestrator_handoff: Some(false), + ..Default::default() }, ) .await @@ -1060,6 +1062,7 @@ async fn apply_meet_settings_updates_handoff_flag() { &mut cfg, MeetSettingsPatch { auto_orchestrator_handoff: None, + ..Default::default() }, ) .await @@ -1067,6 +1070,44 @@ async fn apply_meet_settings_updates_handoff_flag() { assert_eq!(prior, cfg.meet.auto_orchestrator_handoff); } +#[tokio::test] +async fn apply_meet_settings_updates_all_meeting_assistant_fields() { + use crate::openhuman::config::{AutoJoinPolicy, AutoSummarizePolicy}; + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + // Defaults (issue #3511). + assert_eq!(cfg.meet.auto_join_policy, AutoJoinPolicy::AskEachTime); + assert_eq!(cfg.meet.auto_summarize_policy, AutoSummarizePolicy::Ask); + assert!(cfg.meet.listen_only_default); + assert!(!cfg.meet.ingest_backend_transcripts); + + let _ = apply_meet_settings( + &mut cfg, + MeetSettingsPatch { + auto_join_policy: Some(AutoJoinPolicy::Always), + auto_summarize_policy: Some(AutoSummarizePolicy::Never), + listen_only_default: Some(false), + ingest_backend_transcripts: Some(true), + ..Default::default() + }, + ) + .await + .expect("apply all fields"); + assert_eq!(cfg.meet.auto_join_policy, AutoJoinPolicy::Always); + assert_eq!(cfg.meet.auto_summarize_policy, AutoSummarizePolicy::Never); + assert!(!cfg.meet.listen_only_default); + assert!(cfg.meet.ingest_backend_transcripts); + + // No-op patch must leave the prior values untouched. + let _ = apply_meet_settings(&mut cfg, MeetSettingsPatch::default()) + .await + .expect("apply noop"); + assert_eq!(cfg.meet.auto_join_policy, AutoJoinPolicy::Always); + assert_eq!(cfg.meet.auto_summarize_policy, AutoSummarizePolicy::Never); + assert!(!cfg.meet.listen_only_default); + assert!(cfg.meet.ingest_backend_transcripts); +} + #[tokio::test] async fn get_config_snapshot_wraps_snapshot_in_rpc_outcome() { let tmp = tempdir().unwrap(); diff --git a/src/openhuman/config/schemas/controllers.rs b/src/openhuman/config/schemas/controllers.rs index 97f32ed7a..abde9f96a 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::{AutoJoinPolicy, AutoSummarizePolicy}; use super::helpers::{ deserialize_params, to_json, ActivityLevelSettingsUpdate, AgentPathsUpdate, @@ -606,12 +607,46 @@ fn handle_update_meet_settings(params: Map) -> ControllerFuture { return Err(err); } }; + let auto_join_policy = match update.auto_join_policy.as_deref() { + Some("ask_each_time") => Some(AutoJoinPolicy::AskEachTime), + Some("always") => Some(AutoJoinPolicy::Always), + Some("never") => Some(AutoJoinPolicy::Never), + None => None, + Some(other) => { + log::warn!("[config][rpc] update_meet_settings invalid auto_join_policy: {other}"); + return Err(format!( + "invalid auto_join_policy: {other} (valid: ask_each_time, always, never)" + )); + } + }; + let auto_summarize_policy = match update.auto_summarize_policy.as_deref() { + Some("ask") => Some(AutoSummarizePolicy::Ask), + Some("always") => Some(AutoSummarizePolicy::Always), + Some("never") => Some(AutoSummarizePolicy::Never), + None => None, + Some(other) => { + log::warn!( + "[config][rpc] update_meet_settings invalid auto_summarize_policy: {other}" + ); + return Err(format!( + "invalid auto_summarize_policy: {other} (valid: ask, always, never)" + )); + } + }; log::debug!( - "[config][rpc] update_meet_settings patch auto_orchestrator_handoff={:?}", - update.auto_orchestrator_handoff + "[config][rpc] update_meet_settings patch auto_orchestrator_handoff={:?} auto_join_policy={:?} auto_summarize_policy={:?} listen_only_default={:?} ingest_backend_transcripts={:?}", + update.auto_orchestrator_handoff, + auto_join_policy, + auto_summarize_policy, + update.listen_only_default, + update.ingest_backend_transcripts ); let patch = config_rpc::MeetSettingsPatch { auto_orchestrator_handoff: update.auto_orchestrator_handoff, + auto_join_policy, + auto_summarize_policy, + listen_only_default: update.listen_only_default, + ingest_backend_transcripts: update.ingest_backend_transcripts, }; match config_rpc::load_and_apply_meet_settings(patch).await { Ok(outcome) => { @@ -639,10 +674,20 @@ 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}" + "[config][rpc] get_meet_settings ok auto_orchestrator_handoff={auto_orchestrator_handoff} auto_join_policy={:?} auto_summarize_policy={:?} listen_only_default={} ingest_backend_transcripts={}", + config.meet.auto_join_policy, + config.meet.auto_summarize_policy, + config.meet.listen_only_default, + config.meet.ingest_backend_transcripts ); + // Enums serialize via `#[serde(rename_all = "snake_case")]` → + // "ask_each_time"/"always"/"never" and "ask"/"always"/"never". let result = serde_json::json!({ "auto_orchestrator_handoff": auto_orchestrator_handoff, + "auto_join_policy": config.meet.auto_join_policy, + "auto_summarize_policy": config.meet.auto_summarize_policy, + "listen_only_default": config.meet.listen_only_default, + "ingest_backend_transcripts": config.meet.ingest_backend_transcripts, }); to_json(RpcOutcome::new( result, diff --git a/src/openhuman/config/schemas/helpers.rs b/src/openhuman/config/schemas/helpers.rs index 5d5689683..016243d01 100644 --- a/src/openhuman/config/schemas/helpers.rs +++ b/src/openhuman/config/schemas/helpers.rs @@ -118,6 +118,12 @@ pub(super) struct AnalyticsSettingsUpdate { #[derive(Debug, Deserialize)] pub(super) struct MeetSettingsUpdate { pub(super) auto_orchestrator_handoff: Option, + /// Calendar auto-join policy as a string: `ask_each_time` | `always` | `never`. + pub(super) auto_join_policy: Option, + /// Post-call summary policy as a string: `ask` | `always` | `never`. + pub(super) auto_summarize_policy: Option, + pub(super) listen_only_default: Option, + pub(super) ingest_backend_transcripts: Option, } #[derive(Debug, Deserialize)] diff --git a/src/openhuman/config/schemas/schema_defs.rs b/src/openhuman/config/schemas/schema_defs.rs index d90668eda..fb5ef2fed 100644 --- a/src/openhuman/config/schemas/schema_defs.rs +++ b/src/openhuman/config/schemas/schema_defs.rs @@ -422,24 +422,68 @@ pub fn schemas(function: &str) -> ControllerSchema { namespace: "config", function: "update_meet_settings", description: - "Update Google Meet integration settings (currently the auto-orchestrator-handoff privacy gate).", - inputs: vec![optional_bool( - "auto_orchestrator_handoff", - "When true, ending a Meet call hands the transcript to the orchestrator for proactive follow-up actions.", - )], + "Update Meeting Assistant settings: auto-join, post-call summary, listen-only, transcript ingestion, and the orchestrator-handoff privacy gate.", + inputs: vec![ + optional_bool( + "auto_orchestrator_handoff", + "When true, ending a Meet call hands the transcript to the orchestrator for proactive follow-up actions.", + ), + optional_string( + "auto_join_policy", + "Calendar auto-join policy: ask_each_time | always | never.", + ), + optional_string( + "auto_summarize_policy", + "Post-call summary policy: ask | always | never.", + ), + optional_bool( + "listen_only_default", + "When true, the bot joins in listen-only mode (mic muted).", + ), + optional_bool( + "ingest_backend_transcripts", + "When true, backend-bot meeting transcripts are ingested into memory.", + ), + ], outputs: vec![json_output("snapshot", "Updated config snapshot.")], }, "get_meet_settings" => ControllerSchema { namespace: "config", function: "get_meet_settings", - description: "Read current Google Meet integration settings.", + description: "Read current Meeting Assistant settings.", inputs: vec![], - outputs: vec![FieldSchema { - name: "auto_orchestrator_handoff", - ty: TypeSchema::Bool, - comment: "Whether the orchestrator handoff fires on Meet call end.", - required: true, - }], + outputs: vec![ + FieldSchema { + name: "auto_orchestrator_handoff", + ty: TypeSchema::Bool, + comment: "Whether the orchestrator handoff fires on Meet call end.", + required: true, + }, + FieldSchema { + name: "auto_join_policy", + ty: TypeSchema::String, + comment: "Calendar auto-join policy: ask_each_time | always | never.", + required: true, + }, + FieldSchema { + name: "auto_summarize_policy", + ty: TypeSchema::String, + comment: "Post-call summary policy: ask | always | never.", + required: true, + }, + FieldSchema { + name: "listen_only_default", + ty: TypeSchema::Bool, + comment: "Whether the bot joins mic-muted (listen-only).", + required: true, + }, + FieldSchema { + name: "ingest_backend_transcripts", + ty: TypeSchema::Bool, + comment: "Whether backend-bot transcripts are ingested into memory.", + required: true, + }, + ], }, "update_search_settings" => ControllerSchema { namespace: "config", diff --git a/src/openhuman/subconscious/heartbeat/planner/collectors.rs b/src/openhuman/subconscious/heartbeat/planner/collectors.rs index 44ab48b82..a1e5d4657 100644 --- a/src/openhuman/subconscious/heartbeat/planner/collectors.rs +++ b/src/openhuman/subconscious/heartbeat/planner/collectors.rs @@ -58,6 +58,7 @@ pub(crate) fn collect_cron_reminders(config: &Config, now: DateTime) -> Vec title, body, deep_link: Some("/settings/cron-jobs".to_string()), + meeting_url: None, anchor_at: job.next_run, } }) @@ -355,6 +356,7 @@ fn collect_calendar_events_recursive( .and_then(serde_json::Value::as_str) .or_else(|| map.get("hangoutLink").and_then(serde_json::Value::as_str)) .map(ToString::to_string); + let meeting_url = extract_meeting_url_from_map(map); let fingerprint = stable_key(&format!( "{}:{}:{}:{}", @@ -377,6 +379,7 @@ fn collect_calendar_events_recursive( title: title.clone(), body: format!("{} starts at {}.", title, starts_at.format("%H:%M")), deep_link, + meeting_url, anchor_at: starts_at, }); } @@ -430,6 +433,72 @@ fn extract_title_from_map(map: &serde_json::Map) -> S .unwrap_or_else(|| "Upcoming meeting".to_string()) } +const MEETING_HOST_PATTERNS: &[&str] = &[ + "meet.google.com", + "zoom.us", + "teams.microsoft.com", + "webex.com", +]; + +fn is_meeting_url(raw: &str) -> bool { + MEETING_HOST_PATTERNS.iter().any(|pat| raw.contains(pat)) +} + +/// Pull the first parseable meeting URL out of a free-form string. +/// +/// Calendar `location` is free-form and commonly mixes a label with a URL +/// (e.g. `Zoom Meeting: https://zoom.us/j/123`). Returning the whole string +/// would produce a `meeting_url` that the join handler's `url::Url::parse` +/// later rejects, leaving AskEachTime prompts with buttons that always fail +/// while the generic reminder stays suppressed. So scan tokens for one that +/// both matches a known meeting host and parses as an http(s) URL. +fn extract_meeting_url_from_text(text: &str) -> Option { + text.split_whitespace() + // Strip surrounding punctuation that often hugs a URL in prose: + // "(https://zoom.us/j/123)," -> "https://zoom.us/j/123". + .map(|tok| { + tok.trim_matches(|c: char| { + matches!( + c, + '(' | ')' | '[' | ']' | '<' | '>' | ',' | ';' | '"' | '\'' | '.' + ) + }) + }) + .filter(|tok| is_meeting_url(tok)) + .find_map(|tok| { + let parsed = url::Url::parse(tok).ok()?; + matches!(parsed.scheme(), "http" | "https").then(|| parsed.to_string()) + }) +} + +fn extract_meeting_url_from_map( + map: &serde_json::Map, +) -> Option { + map.get("hangoutLink") + .and_then(serde_json::Value::as_str) + .filter(|url| is_meeting_url(url)) + .map(ToString::to_string) + .or_else(|| { + map.get("conferenceData") + .and_then(|cd| cd.get("entryPoints")) + .and_then(serde_json::Value::as_array) + .and_then(|entries| { + entries.iter().find_map(|entry| { + entry + .get("uri") + .and_then(serde_json::Value::as_str) + .filter(|url| is_meeting_url(url)) + .map(ToString::to_string) + }) + }) + }) + .or_else(|| { + map.get("location") + .and_then(serde_json::Value::as_str) + .and_then(extract_meeting_url_from_text) + }) +} + fn parse_datetime(raw: &str) -> Option> { chrono::DateTime::parse_from_rfc3339(raw) .map(|dt| dt.with_timezone(&Utc)) @@ -489,6 +558,7 @@ pub(crate) fn collect_relevant_notifications( title, body, deep_link: Some("/notifications".to_string()), + meeting_url: None, anchor_at: item.received_at, } }) @@ -576,4 +646,105 @@ mod tests { assert_eq!(selected, vec!["active-cal"]); } + + // ── extract_meeting_url_from_map ───────────────────────────── + + fn map_from_value(v: serde_json::Value) -> serde_json::Map { + match v { + serde_json::Value::Object(m) => m, + _ => panic!("expected object"), + } + } + + #[test] + fn extract_meeting_url_picks_hangout_link() { + let map = map_from_value(serde_json::json!({ + "hangoutLink": "https://meet.google.com/abc-defg-hij", + "summary": "Standup" + })); + assert_eq!( + extract_meeting_url_from_map(&map).as_deref(), + Some("https://meet.google.com/abc-defg-hij") + ); + } + + #[test] + fn extract_meeting_url_picks_conference_data_entry_point() { + let map = map_from_value(serde_json::json!({ + "conferenceData": { + "entryPoints": [ + { "entryPointType": "phone", "uri": "tel:+1234567890" }, + { "entryPointType": "video", "uri": "https://meet.google.com/xyz-uvwx-yz1" } + ] + } + })); + assert_eq!( + extract_meeting_url_from_map(&map).as_deref(), + Some("https://meet.google.com/xyz-uvwx-yz1") + ); + } + + #[test] + fn extract_meeting_url_picks_zoom_from_location() { + let map = map_from_value(serde_json::json!({ + "location": "https://zoom.us/j/123456789" + })); + assert_eq!( + extract_meeting_url_from_map(&map).as_deref(), + Some("https://zoom.us/j/123456789") + ); + } + + #[test] + fn extract_meeting_url_picks_url_out_of_free_form_location() { + // A label + URL is the common calendar shape; we must return only the + // parseable URL, not the whole string (which url::Url::parse rejects). + let map = map_from_value(serde_json::json!({ + "location": "Zoom Meeting: (https://zoom.us/j/123456789), dial-in optional" + })); + assert_eq!( + extract_meeting_url_from_map(&map).as_deref(), + Some("https://zoom.us/j/123456789") + ); + } + + #[test] + fn extract_meeting_url_rejects_unparseable_location() { + // Mentions a host substring but has no real URL — must not leak a value + // the join handler would reject. + let map = map_from_value(serde_json::json!({ + "location": "Conference Room — ask host for the zoom.us link" + })); + assert_eq!(extract_meeting_url_from_map(&map), None); + } + + #[test] + fn extract_meeting_url_rejects_non_meeting_hangout_link() { + let map = map_from_value(serde_json::json!({ + "hangoutLink": "https://not-a-meeting-host.example.com/room/abc" + })); + assert_eq!(extract_meeting_url_from_map(&map), None); + } + + #[test] + fn extract_meeting_url_returns_none_for_plain_event() { + let map = map_from_value(serde_json::json!({ + "summary": "Lunch", + "location": "Office kitchen" + })); + assert_eq!(extract_meeting_url_from_map(&map), None); + } + + #[test] + fn extract_meeting_url_strips_trailing_period() { + // url::Url::parse accepts a trailing period as a path segment, but it + // produces a subtly different URL. Strip it at the token-trim level. + let map = map_from_value(serde_json::json!({ + "location": "Join the call: https://zoom.us/j/999888777." + })); + assert_eq!( + extract_meeting_url_from_map(&map).as_deref(), + Some("https://zoom.us/j/999888777") + ); + } } diff --git a/src/openhuman/subconscious/heartbeat/planner/mod.rs b/src/openhuman/subconscious/heartbeat/planner/mod.rs index 302d51e34..89c49b9d4 100644 --- a/src/openhuman/subconscious/heartbeat/planner/mod.rs +++ b/src/openhuman/subconscious/heartbeat/planner/mod.rs @@ -145,6 +145,46 @@ pub async fn evaluate_and_dispatch(config: &Config, now: DateTime) -> Plann continue; } + if event.category == types::HeartbeatCategory::Meetings && plan.allow_external { + if let Some(meeting_url) = event.meeting_url.clone() { + tracing::info!( + source = %event.source, + source_event_id = %event.source_event_id, + stage = plan.stage, + "[heartbeat:planner] forwarding imminent meeting to auto-join policy" + ); + let owns_notification = + crate::openhuman::agent_meetings::calendar::handle_calendar_meeting_candidate( + meeting_url, + event.title.clone(), + // Heartbeat-polled events carry only a title + URL; the + // candidate handler resolves the reply anchor from the + // signed-in account identity. + None, + ) + .await; + if owns_notification { + // The auto-join handler published its own actionable card + // (AskEachTime prompt). Skip the generic plain card so the + // user doesn't see two notifications for the same meeting. + tracing::debug!( + source = %event.source, + source_event_id = %event.source_event_id, + "[heartbeat:planner] auto-join handler owns notification; skipping plain card" + ); + summary.deliveries_sent += 1; + continue; + } + } else { + tracing::debug!( + source = %event.source, + source_event_id = %event.source_event_id, + stage = plan.stage, + "[heartbeat:planner] imminent meeting has no join URL; auto-join skipped" + ); + } + } + publish_core_notification(CoreNotificationEvent { id, category: event.category.notification_category(), @@ -206,7 +246,8 @@ mod tests { "id": "evt-1", "summary": "Team sync", "start": { "dateTime": "2026-05-08T10:20:00Z" }, - "htmlLink": "https://calendar.google.com/event?evt=1" + "htmlLink": "https://calendar.google.com/event?evt=1", + "hangoutLink": "https://meet.google.com/abc-defg-hij" } ] }); @@ -227,6 +268,10 @@ mod tests { events[0].deep_link.as_deref(), Some("https://calendar.google.com/event?evt=1") ); + assert_eq!( + events[0].meeting_url.as_deref(), + Some("https://meet.google.com/abc-defg-hij") + ); } /// Regression for issue #1714 — an event stored against a @@ -306,6 +351,7 @@ mod tests { title: "Pay rent".to_string(), body: String::new(), deep_link: None, + meeting_url: None, anchor_at: now, }; @@ -332,6 +378,7 @@ mod tests { title: "Planning".to_string(), body: String::new(), deep_link: None, + meeting_url: None, anchor_at: now + Duration::minutes(45), }; @@ -476,4 +523,69 @@ mod tests { "different categories must produce different overlap keys" ); } + + /// An imminent calendar meeting with a join URL must be extracted with its + /// `meeting_url` populated and planned with `allow_external = true`, so the + /// planner forwards it to the auto-join policy (`handle_calendar_meeting_candidate`). + /// + /// Note: the end-to-end suppression path (handler "owns" the notification → + /// plain card skipped) is intentionally NOT asserted via `evaluate_and_dispatch` + /// here. That path depends on `collect_calendar_meetings` reaching a live + /// Composio connection and on `load_config_with_timeout` (which reads the + /// process-global `OPENHUMAN_WORKSPACE`, not this test's `TempDir`) — neither + /// is deterministic in a unit test. The policy-ownership decision itself is + /// covered by `auto_join_policy_owns_notification` tests in `agent_meetings::calendar`. + #[test] + fn imminent_meeting_with_url_is_extracted_and_marked_allow_external() { + let tmp = TempDir::new().unwrap(); + let mut config = test_config(&tmp); + config.heartbeat.notify_meetings = true; + config.heartbeat.notify_reminders = false; + config.heartbeat.notify_relevant_events = false; + // meeting_lookahead_minutes=120 → allow_external=false for heads_up stage. + // We need allow_external=true (final_call / due window). + config.heartbeat.meeting_lookahead_minutes = 15; + + let now = Utc::now(); + // Anchor the meeting to start in ~5 min → within the 15-min lookahead + // AND within the 7-min final_call window → allow_external=true. + let anchor = now + Duration::minutes(5); + + // Build a minimal Google Calendar payload that contains a hangoutLink so + // `meeting_url` is populated. + let payload = serde_json::json!({ + "items": [{ + "id": "suppress-test-evt", + "summary": "Suppression Test Meeting", + "start": { "dateTime": anchor.to_rfc3339() }, + "end": { "dateTime": (anchor + Duration::minutes(30)).to_rfc3339() }, + "hangoutLink": "https://meet.google.com/sup-pres-sion" + }] + }); + + // The collector picks up the meeting and populates meeting_url. + let events = collectors::extract_calendar_events( + &payload, + "googlecalendar", + "conn-suppress", + now, + now + Duration::minutes(60), + ); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].meeting_url.as_deref(), + Some("https://meet.google.com/sup-pres-sion"), + "meeting_url must be populated from hangoutLink" + ); + assert_eq!(events[0].category, types::HeartbeatCategory::Meetings); + + // The plan marks it allow_external so the planner forwards it to the + // auto-join policy instead of only emitting a plain heads-up card. + let plan = + plan::plan_delivery_for_event(&events[0], &config, now).expect("must produce a plan"); + assert!( + plan.allow_external, + "imminent meeting must be allow_external so the auto-join path fires" + ); + } } diff --git a/src/openhuman/subconscious/heartbeat/planner/persistence.rs b/src/openhuman/subconscious/heartbeat/planner/persistence.rs index 62acffc8c..3e00763ec 100644 --- a/src/openhuman/subconscious/heartbeat/planner/persistence.rs +++ b/src/openhuman/subconscious/heartbeat/planner/persistence.rs @@ -35,6 +35,7 @@ pub(crate) fn persist_heartbeat_alert( "stage": plan.stage, "anchor_at": event.anchor_at.to_rfc3339(), "deep_link": event.deep_link.clone(), + "meeting_url": event.meeting_url.clone(), }), importance_score: Some(match event.category { HeartbeatCategory::Meetings => 0.8, diff --git a/src/openhuman/subconscious/heartbeat/planner/types.rs b/src/openhuman/subconscious/heartbeat/planner/types.rs index aa8c8d402..50688fb79 100644 --- a/src/openhuman/subconscious/heartbeat/planner/types.rs +++ b/src/openhuman/subconscious/heartbeat/planner/types.rs @@ -43,6 +43,9 @@ pub(crate) struct PendingEvent { pub title: String, pub body: String, pub deep_link: Option, + /// Join URL for calendar-backed meeting events. Kept separate from + /// `deep_link`, which may point at a calendar details page. + pub meeting_url: Option, pub anchor_at: DateTime, }