mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(meetings): calendar-triggered auto-join prompt + Meeting Assistant settings UI (#3721)
This commit is contained in:
@@ -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> = {}): 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(
|
||||
<Provider store={store}>
|
||||
<CoreNotificationCard notification={item} />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div
|
||||
className={`w-full p-3 border-b border-stone-100 dark:border-neutral-800 transition-colors duration-150 ${
|
||||
n.read ? 'bg-white dark:bg-neutral-900' : 'bg-primary-50/30'
|
||||
}`}
|
||||
data-testid="core-notification-card">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Unread dot — reserve space so text stays aligned whether read or unread */}
|
||||
<div className="mt-1.5 flex-shrink-0 w-2">
|
||||
{!n.read && (
|
||||
<span className="block w-2 h-2 rounded-full bg-primary-500" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
{/* Header row: category badge + timestamp */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 border-stone-200 dark:border-neutral-800">
|
||||
{t(`notifications.category.${n.category}`)}
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] text-stone-400 dark:text-neutral-500 flex-shrink-0">
|
||||
{relativeTime(n.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">{n.title}</p>
|
||||
|
||||
{/* Body */}
|
||||
{n.body && (
|
||||
<p
|
||||
data-testid="core-notification-body"
|
||||
className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5">
|
||||
<NotificationBody body={n.body} />
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{n.actions && n.actions.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
{n.actions.map(action => {
|
||||
const labelKey = ACTION_LABEL_KEYS[action.actionId];
|
||||
const label = labelKey ? t(labelKey) : action.label;
|
||||
const primary = isPrimaryAction(action.actionId);
|
||||
return (
|
||||
<button
|
||||
key={action.actionId}
|
||||
type="button"
|
||||
disabled={pendingActionId !== null}
|
||||
onClick={() => {
|
||||
void handleAction(action.actionId, action.payload);
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
primary
|
||||
? 'bg-primary-500 text-white hover:bg-primary-600'
|
||||
: 'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 hover:bg-stone-200 dark:hover:bg-neutral-800/60'
|
||||
}`}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-600 mt-1.5">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoreNotificationCard;
|
||||
@@ -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<string | undefined>(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 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Actionable core notifications (e.g. meeting auto-join prompt) —
|
||||
always shown first, independent of integration load state. */}
|
||||
{coreActionItems.length > 0 && (
|
||||
<div className="divide-y-0">
|
||||
{coreActionItems.map(item => (
|
||||
<CoreNotificationCard key={item.id} notification={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12 text-stone-400 dark:text-neutral-500 text-sm">
|
||||
{t('common.loading')}
|
||||
@@ -184,7 +200,7 @@ const NotificationCenter = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && visibleItems.length === 0 && (
|
||||
{!loading && !error && visibleItems.length === 0 && coreActionItems.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-stone-400 dark:text-neutral-500">
|
||||
<svg
|
||||
className="w-10 h-10 mb-3 opacity-40"
|
||||
|
||||
@@ -61,6 +61,7 @@ export type SettingsRoute =
|
||||
| 'security'
|
||||
| 'migration'
|
||||
| 'companion'
|
||||
| 'meetings'
|
||||
| 'embeddings'
|
||||
| 'search'
|
||||
| 'skills-runner'
|
||||
|
||||
@@ -76,6 +76,11 @@ export const SETTINGS_NAV_ICONS: Record<string, ReactNode> = {
|
||||
'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(
|
||||
|
||||
@@ -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<MeetAutoJoinPolicy, string> = {
|
||||
ask_each_time: 'settings.meetings.autoJoin.askEachTime',
|
||||
always: 'settings.meetings.autoJoin.always',
|
||||
never: 'settings.meetings.autoJoin.never',
|
||||
};
|
||||
|
||||
const AUTO_SUMMARIZE_LABEL_KEY: Record<MeetAutoSummarizePolicy, string> = {
|
||||
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<string | null>(null);
|
||||
const [savedNote, setSavedNote] = useState<string | null>(null);
|
||||
|
||||
const [autoJoin, setAutoJoin] = useState<MeetAutoJoinPolicy>('ask_each_time');
|
||||
const [autoSummarize, setAutoSummarize] = useState<MeetAutoSummarizePolicy>('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<typeof openhumanUpdateMeetSettings>[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 (
|
||||
<PanelPage
|
||||
className="z-10"
|
||||
contentClassName=""
|
||||
description={t('settings.meetings.menuDesc')}
|
||||
leading={<SettingsBackButton onBack={navigateBack} />}>
|
||||
<div className="p-4 pt-2">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.meetings.desktopOnly')}
|
||||
</p>
|
||||
</div>
|
||||
</PanelPage>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PanelPage
|
||||
className="z-10"
|
||||
contentClassName=""
|
||||
description={t('settings.meetings.menuDesc')}
|
||||
leading={<SettingsBackButton onBack={navigateBack} />}>
|
||||
<div className="p-4 pt-2">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.meetings.loading')}
|
||||
</p>
|
||||
</div>
|
||||
</PanelPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelPage
|
||||
className="z-10"
|
||||
contentClassName=""
|
||||
description={t('settings.meetings.menuDesc')}
|
||||
leading={<SettingsBackButton onBack={navigateBack} />}>
|
||||
<div className="p-4 pt-2 space-y-5" data-testid="meeting-settings-panel">
|
||||
{/* Auto-join policy */}
|
||||
<SettingsSection
|
||||
title={t('settings.meetings.autoJoin.title')}
|
||||
description={t('settings.meetings.autoJoin.desc')}>
|
||||
<SettingsRow
|
||||
stacked
|
||||
control={
|
||||
<SettingsSelect
|
||||
value={autoJoin}
|
||||
onChange={e => handleAutoJoinChange(e.target.value as MeetAutoJoinPolicy)}
|
||||
aria-label={t('settings.meetings.autoJoin.title')}>
|
||||
{AUTO_JOIN_OPTIONS.map(opt => (
|
||||
<option key={opt} value={opt}>
|
||||
{t(AUTO_JOIN_LABEL_KEY[opt])}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Auto-summarize policy */}
|
||||
<SettingsSection
|
||||
title={t('settings.meetings.autoSummarize.title')}
|
||||
description={t('settings.meetings.autoSummarize.desc')}>
|
||||
<SettingsRow
|
||||
stacked
|
||||
control={
|
||||
<SettingsSelect
|
||||
value={autoSummarize}
|
||||
onChange={e => handleAutoSummarizeChange(e.target.value as MeetAutoSummarizePolicy)}
|
||||
aria-label={t('settings.meetings.autoSummarize.title')}>
|
||||
{AUTO_SUMMARIZE_OPTIONS.map(opt => (
|
||||
<option key={opt} value={opt}>
|
||||
{t(AUTO_SUMMARIZE_LABEL_KEY[opt])}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Toggles */}
|
||||
<SettingsSection>
|
||||
<SettingsRow
|
||||
htmlFor="switch-meet-listen-only"
|
||||
label={t('settings.meetings.listenOnly')}
|
||||
description={t('settings.meetings.listenOnlyDesc')}
|
||||
control={
|
||||
<SettingsSwitch
|
||||
id="switch-meet-listen-only"
|
||||
checked={listenOnly}
|
||||
onCheckedChange={handleListenOnlyChange}
|
||||
aria-label={t('settings.meetings.listenOnly')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
htmlFor="switch-meet-ingest-transcripts"
|
||||
label={t('settings.meetings.ingestTranscripts')}
|
||||
description={t('settings.meetings.ingestTranscriptsDesc')}
|
||||
control={
|
||||
<SettingsSwitch
|
||||
id="switch-meet-ingest-transcripts"
|
||||
checked={ingestTranscripts}
|
||||
onCheckedChange={handleIngestChange}
|
||||
aria-label={t('settings.meetings.ingestTranscripts')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Status line */}
|
||||
<SettingsStatusLine
|
||||
saving={isSaving}
|
||||
savedNote={savedNote}
|
||||
error={error}
|
||||
savingLabel={t('settings.meetings.saving')}
|
||||
/>
|
||||
</div>
|
||||
</PanelPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeetingSettingsPanel;
|
||||
@@ -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> = {}): 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<typeof import('../../../../utils/tauriCommands')>(
|
||||
'../../../../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(<MeetingSettingsPanel />);
|
||||
await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('renders the auto-join select with the current value', async () => {
|
||||
renderWithProviders(<MeetingSettingsPanel />);
|
||||
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(<MeetingSettingsPanel />);
|
||||
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(<MeetingSettingsPanel />);
|
||||
const select = await screen.findByRole('combobox', { name: /post-call summary/i });
|
||||
expect(select).toHaveValue('ask');
|
||||
});
|
||||
|
||||
it('changing auto-summarize persists the selection', async () => {
|
||||
renderWithProviders(<MeetingSettingsPanel />);
|
||||
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(<MeetingSettingsPanel />);
|
||||
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(<MeetingSettingsPanel />);
|
||||
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(<MeetingSettingsPanel />);
|
||||
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(<MeetingSettingsPanel />);
|
||||
expect(await screen.findByText('RPC timeout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error note when persist fails', async () => {
|
||||
mockUpdate.mockRejectedValue(new Error('Save failed'));
|
||||
renderWithProviders(<MeetingSettingsPanel />);
|
||||
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(<MeetingSettingsPanel />);
|
||||
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(<MeetingSettingsPanel />);
|
||||
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(<MeetingSettingsPanel />);
|
||||
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(<MeetingSettingsPanel />);
|
||||
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(<MeetingSettingsPanel />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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': 'جارٍ التحميل…',
|
||||
|
||||
@@ -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': 'লোড হচ্ছে…',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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': 'लोड हो रहा है…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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': '로딩 중…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Загрузка…',
|
||||
|
||||
@@ -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': '加载中…',
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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 = () => {
|
||||
<Route path="desktop-agent" element={wrapSettingsPage(<DesktopAgentPanel />)} />
|
||||
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
|
||||
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
|
||||
<Route path="meetings" element={wrapSettingsPage(<MeetingSettingsPanel />)} />
|
||||
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
|
||||
|
||||
{/* ── System ──────────────────────────────────────────────── */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+10
-1
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<CommandResponse<ConfigSnapshot>> {
|
||||
auto_join_policy?: MeetAutoJoinPolicy;
|
||||
auto_summarize_policy?: MeetAutoSummarizePolicy;
|
||||
listen_only_default?: boolean;
|
||||
ingest_backend_transcripts?: boolean;
|
||||
}
|
||||
|
||||
export async function openhumanUpdateMeetSettings(
|
||||
update: MeetSettingsUpdate
|
||||
): Promise<CommandResponse<ConfigSnapshot>> {
|
||||
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<CommandResponse<MeetSettings>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<{ auto_orchestrator_handoff: boolean }>>({
|
||||
return await callCoreRpc<CommandResponse<MeetSettings>>({
|
||||
method: 'openhuman.config_get_meet_settings',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
let trimmed = |v: Option<&serde_json::Value>| -> Option<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
let clean = |s: Option<&str>| -> Option<String> {
|
||||
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<String>,
|
||||
) -> 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<String> {
|
||||
text.split_whitespace()
|
||||
.map(|tok| {
|
||||
tok.trim_matches(|c: char| {
|
||||
matches!(
|
||||
c,
|
||||
'(' | ')' | '[' | ']' | '<' | '>' | ',' | ';' | '"' | '\'' | '.'
|
||||
)
|
||||
})
|
||||
})
|
||||
.filter(|tok| is_meeting_url(tok))
|
||||
.find_map(|tok| {
|
||||
let parsed = url::Url::parse(tok).ok()?;
|
||||
matches!(parsed.scheme(), "http" | "https").then(|| parsed.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<String>,
|
||||
) {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<String, Value> {
|
||||
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<String, Value>) -> Result<Value, String> {
|
||||
let req: BackendMeetJoinRequest = serde_json::from_value(Value::Object(params))
|
||||
@@ -493,6 +557,15 @@ pub async fn handle_notification_action(params: Map<String, Value>) -> Result<Va
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from);
|
||||
// Reply anchor carried from the calendar notification (issue: gmeet
|
||||
// auto-join anchor). Falls back to the signed-in account identity so a
|
||||
// notification raised before the anchor wiring still knows who to reply to.
|
||||
let respond_to_participant = anchor_from_action_payload(&payload).or_else(|| {
|
||||
crate::openhuman::app_state::peek_cached_current_user_identity()
|
||||
.and_then(|i| i.name)
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
action_id = %action_id,
|
||||
@@ -540,29 +613,17 @@ pub async fn handle_notification_action(params: Map<String, Value>) -> Result<Va
|
||||
}
|
||||
}
|
||||
|
||||
let listen_only = match action_id.as_str() {
|
||||
"join_listen" => 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();
|
||||
|
||||
@@ -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<bool>,
|
||||
/// Calendar auto-join policy (issue #3511 settings UI).
|
||||
pub auto_join_policy: Option<AutoJoinPolicy>,
|
||||
/// Post-call auto-summarize policy.
|
||||
pub auto_summarize_policy: Option<AutoSummarizePolicy>,
|
||||
/// When `true`, the bot joins in listen-only mode (mic muted).
|
||||
pub listen_only_default: Option<bool>,
|
||||
/// When `true`, backend-bot transcripts are ingested into memory.
|
||||
pub ingest_backend_transcripts: Option<bool>,
|
||||
}
|
||||
|
||||
#[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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String, Value>) -> 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<String, Value>) -> ControllerFuture {
|
||||
};
|
||||
let auto_orchestrator_handoff = config.meet.auto_orchestrator_handoff;
|
||||
log::debug!(
|
||||
"[config][rpc] get_meet_settings ok auto_orchestrator_handoff={auto_orchestrator_handoff}"
|
||||
"[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,
|
||||
|
||||
@@ -118,6 +118,12 @@ pub(super) struct AnalyticsSettingsUpdate {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct MeetSettingsUpdate {
|
||||
pub(super) auto_orchestrator_handoff: Option<bool>,
|
||||
/// Calendar auto-join policy as a string: `ask_each_time` | `always` | `never`.
|
||||
pub(super) auto_join_policy: Option<String>,
|
||||
/// Post-call summary policy as a string: `ask` | `always` | `never`.
|
||||
pub(super) auto_summarize_policy: Option<String>,
|
||||
pub(super) listen_only_default: Option<bool>,
|
||||
pub(super) ingest_backend_transcripts: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -58,6 +58,7 @@ pub(crate) fn collect_cron_reminders(config: &Config, now: DateTime<Utc>) -> 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<String, serde_json::Value>) -> 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<String> {
|
||||
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<String, serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
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<DateTime<Utc>> {
|
||||
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<String, serde_json::Value> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,46 @@ pub async fn evaluate_and_dispatch(config: &Config, now: DateTime<Utc>) -> 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,6 +43,9 @@ pub(crate) struct PendingEvent {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub deep_link: Option<String>,
|
||||
/// Join URL for calendar-backed meeting events. Kept separate from
|
||||
/// `deep_link`, which may point at a calendar details page.
|
||||
pub meeting_url: Option<String>,
|
||||
pub anchor_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user