mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(meetings): respondTo required + wake-phrase filtering UI (#3555)
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
resetBackendMeet,
|
||||
selectBackendMeetLastHarness,
|
||||
selectBackendMeetLastReply,
|
||||
selectBackendMeetListenOnly,
|
||||
selectBackendMeetStatus,
|
||||
selectBackendMeetUrl,
|
||||
setBackendMeetJoining,
|
||||
@@ -83,6 +84,7 @@ function ActiveMeetingView({ onToast }: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const status = useAppSelector(selectBackendMeetStatus);
|
||||
const meetUrl = useAppSelector(selectBackendMeetUrl);
|
||||
const listenOnly = useAppSelector(selectBackendMeetListenOnly);
|
||||
const lastReply = useAppSelector(selectBackendMeetLastReply);
|
||||
const lastHarness = useAppSelector(selectBackendMeetLastHarness);
|
||||
const face = faceFromMeetState(status, lastReply, lastHarness);
|
||||
@@ -114,14 +116,18 @@ function ActiveMeetingView({ onToast }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const statusText =
|
||||
{
|
||||
const statusText = (() => {
|
||||
const base: Record<string, string> = {
|
||||
joining: t('skills.meetingBots.liveStatusJoining'),
|
||||
active: t('skills.meetingBots.liveStatusActive'),
|
||||
active: listenOnly
|
||||
? t('skills.meetingBots.liveStatusListening')
|
||||
: t('skills.meetingBots.liveStatusActive'),
|
||||
ended: t('skills.meetingBots.liveStatusEnded'),
|
||||
error: t('skills.meetingBots.liveStatusError'),
|
||||
idle: '',
|
||||
}[status] ?? '';
|
||||
};
|
||||
return base[status] ?? '';
|
||||
})();
|
||||
|
||||
const canLeave = status === 'active' || status === 'joining';
|
||||
const isDone = status === 'ended' || status === 'error';
|
||||
@@ -242,6 +248,7 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const [meetUrl, setMeetUrl] = useState('');
|
||||
const [respondTo, setRespondTo] = useState('');
|
||||
const personaDisplayName = useAppSelector(selectPersonaDisplayName);
|
||||
const personaDescription = useAppSelector(selectPersonaDescription);
|
||||
const selectedMascotId = useAppSelector(selectSelectedMascotId);
|
||||
@@ -299,9 +306,12 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Generate a correlation ID so every backend event for this session
|
||||
// can be tied back to this meeting.
|
||||
const meetingId = crypto.randomUUID();
|
||||
// Optimistically update Redux state so the banner transitions to
|
||||
// the ActiveMeetingView immediately, before the backend responds.
|
||||
dispatch(setBackendMeetJoining({ meetUrl: meetUrl.trim() }));
|
||||
dispatch(setBackendMeetJoining({ meetUrl: meetUrl.trim(), meetingId }));
|
||||
// Backend Recall.ai bot: sends the mascot into the meeting via
|
||||
// the backend's Recall.ai integration. The backend joins as a
|
||||
// participant, renders the mascot as the bot's camera feed, and
|
||||
@@ -314,6 +324,8 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
|
||||
systemPrompt,
|
||||
mascotId,
|
||||
riveColors,
|
||||
correlationId: meetingId,
|
||||
respondToParticipant: respondTo.trim() || undefined,
|
||||
});
|
||||
onToast?.({
|
||||
type: 'success',
|
||||
@@ -380,6 +392,26 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('skills.meetingBots.respondToParticipant')}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={respondTo}
|
||||
onChange={e => setRespondTo(e.target.value)}
|
||||
placeholder={t('skills.meetingBots.respondToParticipantHint')}
|
||||
disabled={submitting}
|
||||
required
|
||||
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-stone-50 dark:disabled:bg-neutral-800/60"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.meetingBots.respondToParticipantDesc')}
|
||||
</p>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -397,7 +429,7 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !meetUrl.trim()}
|
||||
disabled={submitting || !meetUrl.trim() || !respondTo.trim()}
|
||||
className="rounded-xl bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 disabled:cursor-not-allowed disabled:bg-stone-200 dark:disabled:bg-neutral-700 disabled:text-stone-400 dark:disabled:text-neutral-500">
|
||||
{submitting
|
||||
? t('skills.meetingBots.starting')
|
||||
|
||||
@@ -68,6 +68,9 @@ describe('MeetingBotsCard', () => {
|
||||
fireEvent.change(screen.getByLabelText(/meeting link/i), {
|
||||
target: { value: 'https://meet.google.com/abc-defg-hij' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/your name in this meeting/i), {
|
||||
target: { value: 'Alice' },
|
||||
});
|
||||
const form = screen.getByRole('dialog').querySelector('form')!;
|
||||
fireEvent.submit(form);
|
||||
|
||||
@@ -78,6 +81,7 @@ describe('MeetingBotsCard', () => {
|
||||
displayName: 'OpenHuman',
|
||||
platform: 'gmeet',
|
||||
agentName: 'OpenHuman',
|
||||
respondToParticipant: 'Alice',
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -118,6 +122,9 @@ describe('MeetingBotsCard', () => {
|
||||
fireEvent.change(screen.getByLabelText(/meeting link/i), {
|
||||
target: { value: 'https://meet.google.com/abc-defg-hij' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/your name in this meeting/i), {
|
||||
target: { value: 'Alice' },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -143,6 +150,9 @@ describe('MeetingBotsCard', () => {
|
||||
fireEvent.change(screen.getByLabelText(/meeting link/i), {
|
||||
target: { value: 'https://meet.google.com/x' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/your name in this meeting/i), {
|
||||
target: { value: 'Alice' },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -165,9 +175,12 @@ describe('MeetingBotsCard', () => {
|
||||
renderWithProviders(<MeetingBotsCard />);
|
||||
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
|
||||
expect(screen.getByLabelText(/meeting link/i)).toBeInTheDocument();
|
||||
// respondTo field is present (participant name the bot should respond to)
|
||||
expect(screen.getByLabelText(/your name in this meeting/i)).toBeInTheDocument();
|
||||
// Old bot-tuning fields should be absent
|
||||
expect(screen.queryByLabelText(/^display name$/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/your name in the call/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/wake phrase/i)).not.toBeInTheDocument();
|
||||
// No standalone "Wake Phrase" label — the phrase appears only in the respondTo hint text
|
||||
expect(screen.queryByText(/^wake phrase$/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1781,6 +1781,7 @@ const messages: TranslationMap = {
|
||||
'chat.safetyTimeout': 'لا استجابة من الوكيل بعد دقيقتين. حاول مرة أخرى أو تحقق من اتصالك.',
|
||||
'chat.filter.general': 'عام',
|
||||
'chat.filter.subconscious': 'اللاوعي',
|
||||
'chat.filter.meetings': 'الاجتماعات',
|
||||
'chat.filter.tasks': 'المهام',
|
||||
'chat.selectThread': 'اختر محادثة',
|
||||
'chat.threads': 'المحادثات',
|
||||
@@ -4211,13 +4212,14 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': 'في اجتماع',
|
||||
'skills.meetingBots.liveStatusJoining': 'جارٍ الانضمام\u2026',
|
||||
'skills.meetingBots.liveStatusActive': 'مباشر في الاجتماع',
|
||||
'skills.meetingBots.liveStatusListening': 'الاستماع (صامت)',
|
||||
'skills.meetingBots.liveStatusEnded': 'انتهى الاجتماع',
|
||||
'skills.meetingBots.liveStatusError': 'فشل الانضمام',
|
||||
'skills.meetingBots.leaveButton': 'مغادرة',
|
||||
'skills.meetingBots.respondToParticipant': 'اسمك في هذا الاجتماع',
|
||||
'skills.meetingBots.respondToParticipantHint': 'مثال: أحمد (اسمك في المكالمة)',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'لن يستجيب البوت إلا لك. اتركه فارغاً للسماح لأي شخص بتفعيله.',
|
||||
'أدخل اسمك الظاهر بالضبط في الاجتماع. لن يستجيب البوت إلا عندما تنطق باسمه (عبارة التنشيط).',
|
||||
'skills.meetingBots.wakePhrase': 'عبارة التنشيط',
|
||||
'skills.meetingBots.wakePhraseHint': 'مرحباً OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc': 'يجب أن يقول المشارك هذا قبل أن يرد البوت.',
|
||||
|
||||
@@ -1822,6 +1822,7 @@ const messages: TranslationMap = {
|
||||
'২ মিনিট পরেও এজেন্টের কোনো সাড়া নেই। আবার চেষ্টা করুন বা সংযোগ পরীক্ষা করুন।',
|
||||
'chat.filter.general': 'সাধারণ',
|
||||
'chat.filter.subconscious': 'সাবকনশাস',
|
||||
'chat.filter.meetings': 'মিটিং',
|
||||
'chat.filter.tasks': 'টাস্ক',
|
||||
'chat.selectThread': 'একটি থ্রেড বেছে নিন',
|
||||
'chat.threads': 'থ্রেড',
|
||||
@@ -4295,13 +4296,14 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': 'মিটিংয়ে',
|
||||
'skills.meetingBots.liveStatusJoining': 'যোগ দিচ্ছে\u2026',
|
||||
'skills.meetingBots.liveStatusActive': 'মিটিংয়ে সরাসরি',
|
||||
'skills.meetingBots.liveStatusListening': 'শুনছে (নিঃশব্দ)',
|
||||
'skills.meetingBots.liveStatusEnded': 'মিটিং শেষ',
|
||||
'skills.meetingBots.liveStatusError': 'যোগ দিতে ব্যর্থ',
|
||||
'skills.meetingBots.leaveButton': 'ছেড়ে দিন',
|
||||
'skills.meetingBots.respondToParticipant': 'এই মিটিংয়ে আপনার নাম',
|
||||
'skills.meetingBots.respondToParticipantHint': 'যেমন: রিয়া (কলে আপনার প্রদর্শনী নাম)',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'বট শুধুমাত্র আপনাকে জবাব দেবে। যে কেউ সক্রিয় করতে পারে সে জন্য খালি রাখুন।',
|
||||
'মিটিং থেকে আপনার সঠিক প্রদর্শন নাম লিখুন। বট কেবল তখনই সাড়া দেয় যখন আপনি তার নাম বলেন (ওয়েক ফ্রেজ)।',
|
||||
'skills.meetingBots.wakePhrase': 'ওয়েক ফ্রেজ',
|
||||
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc': 'বট সাড়া দেওয়ার আগে অংশগ্রহণকারীকে এটি বলতে হবে।',
|
||||
|
||||
@@ -1869,6 +1869,7 @@ const messages: TranslationMap = {
|
||||
'Keine Antwort vom Agenten nach 2 Minuten. Versuche es erneut oder prüfe deine Verbindung.',
|
||||
'chat.filter.general': 'Allgemein',
|
||||
'chat.filter.subconscious': 'Unterbewusstsein',
|
||||
'chat.filter.meetings': 'Meetings',
|
||||
'chat.filter.tasks': 'Aufgaben',
|
||||
'chat.selectThread': 'Wähle einen Thread aus',
|
||||
'chat.threads': 'Themen',
|
||||
@@ -4407,13 +4408,14 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': 'Im Meeting',
|
||||
'skills.meetingBots.liveStatusJoining': 'Beitritt\u2026',
|
||||
'skills.meetingBots.liveStatusActive': 'Live im Meeting',
|
||||
'skills.meetingBots.liveStatusListening': 'Zuhören (stumm)',
|
||||
'skills.meetingBots.liveStatusEnded': 'Meeting beendet',
|
||||
'skills.meetingBots.liveStatusError': 'Beitritt fehlgeschlagen',
|
||||
'skills.meetingBots.leaveButton': 'Verlassen',
|
||||
'skills.meetingBots.respondToParticipant': 'Ihr Name in diesem Meeting',
|
||||
'skills.meetingBots.respondToParticipantHint': 'z. B. Max (Ihr Anzeigename im Anruf)',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Der Bot antwortet nur Ihnen. Leer lassen, damit jeder ihn aktivieren kann.',
|
||||
'Geben Sie Ihren genauen Anzeigenamen aus dem Meeting ein. Der Bot antwortet nur, wenn Sie seinen Namen sagen (Wake-Phrase).',
|
||||
'skills.meetingBots.wakePhrase': 'Wake-Phrase',
|
||||
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc': 'Der Teilnehmer muss dies sagen, bevor der Bot antwortet.',
|
||||
|
||||
@@ -2197,6 +2197,7 @@ const en: TranslationMap = {
|
||||
'No response from the agent after 2 minutes. Try again or check your connection.',
|
||||
'chat.filter.general': 'General',
|
||||
'chat.filter.subconscious': 'Subconscious',
|
||||
'chat.filter.meetings': 'Meetings',
|
||||
'chat.filter.tasks': 'Tasks',
|
||||
'chat.selectThread': 'Select a thread',
|
||||
'chat.threads': 'Threads',
|
||||
@@ -4828,13 +4829,14 @@ const en: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': 'In Meeting',
|
||||
'skills.meetingBots.liveStatusJoining': 'Joining\u2026',
|
||||
'skills.meetingBots.liveStatusActive': 'Live in meeting',
|
||||
'skills.meetingBots.liveStatusListening': 'Listening (muted)',
|
||||
'skills.meetingBots.liveStatusEnded': 'Meeting ended',
|
||||
'skills.meetingBots.liveStatusError': 'Failed to join',
|
||||
'skills.meetingBots.leaveButton': 'Leave',
|
||||
'skills.meetingBots.respondToParticipant': 'Your Name in This Meeting',
|
||||
'skills.meetingBots.respondToParticipantHint': 'e.g. Alice (your display name in the call)',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'The bot will only respond to you. Leave blank to let anyone activate it.',
|
||||
'Enter your exact display name from the meeting. The bot only responds when you say its name (wake phrase).',
|
||||
'skills.meetingBots.wakePhrase': 'Wake Phrase',
|
||||
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc': 'Participant must say this before the bot responds.',
|
||||
|
||||
@@ -1861,6 +1861,7 @@ const messages: TranslationMap = {
|
||||
'Sin respuesta del agente después de 2 minutos. Intenta de nuevo o verifica tu conexión.',
|
||||
'chat.filter.general': 'General',
|
||||
'chat.filter.subconscious': 'Subconsciente',
|
||||
'chat.filter.meetings': 'Reuniones',
|
||||
'chat.filter.tasks': 'Tareas',
|
||||
'chat.selectThread': 'Selecciona un hilo',
|
||||
'chat.threads': 'Hilos',
|
||||
@@ -4379,13 +4380,14 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': 'En reunión',
|
||||
'skills.meetingBots.liveStatusJoining': 'Uniéndose\u2026',
|
||||
'skills.meetingBots.liveStatusActive': 'En vivo en la reunión',
|
||||
'skills.meetingBots.liveStatusListening': 'Escuchando (silenciado)',
|
||||
'skills.meetingBots.liveStatusEnded': 'Reunión finalizada',
|
||||
'skills.meetingBots.liveStatusError': 'Error al unirse',
|
||||
'skills.meetingBots.leaveButton': 'Salir',
|
||||
'skills.meetingBots.respondToParticipant': 'Tu nombre en esta reunión',
|
||||
'skills.meetingBots.respondToParticipantHint': 'p. ej. Ana (tu nombre visible en la llamada)',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'El bot solo te responderá a ti. Deja en blanco para que cualquiera pueda activarlo.',
|
||||
'Introduce tu nombre de visualización exacto de la reunión. El bot solo responde cuando dices su nombre (frase de activación).',
|
||||
'skills.meetingBots.wakePhrase': 'Frase de activación',
|
||||
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc':
|
||||
|
||||
@@ -1869,6 +1869,7 @@ const messages: TranslationMap = {
|
||||
"Aucune réponse de l'agent après 2 minutes. Réessaie ou vérifie ta connexion.",
|
||||
'chat.filter.general': 'Général',
|
||||
'chat.filter.subconscious': 'Subconscient',
|
||||
'chat.filter.meetings': 'Réunions',
|
||||
'chat.filter.tasks': 'Tâches',
|
||||
'chat.selectThread': 'Sélectionne un fil',
|
||||
'chat.threads': 'Fils',
|
||||
@@ -4396,13 +4397,14 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': 'En réunion',
|
||||
'skills.meetingBots.liveStatusJoining': 'Connexion\u2026',
|
||||
'skills.meetingBots.liveStatusActive': 'En direct dans la réunion',
|
||||
'skills.meetingBots.liveStatusListening': 'Écoute (muet)',
|
||||
'skills.meetingBots.liveStatusEnded': 'Réunion terminée',
|
||||
'skills.meetingBots.liveStatusError': 'Échec de connexion',
|
||||
'skills.meetingBots.leaveButton': 'Quitter',
|
||||
'skills.meetingBots.respondToParticipant': 'Votre nom dans cette réunion',
|
||||
'skills.meetingBots.respondToParticipantHint': 'ex. Alice (votre nom affiché dans l\u2019appel)',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Le bot ne répondra qu\u2019à vous. Laissez vide pour que n\u2019importe qui puisse l\u2019activer.',
|
||||
'Saisissez votre nom d\u2019affichage exact dans la réunion. Le bot ne répond que lorsque vous prononcez son nom (phrase de réveil).',
|
||||
'skills.meetingBots.wakePhrase': 'Phrase de réveil',
|
||||
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc': 'Le participant doit dire ceci avant que le bot réponde.',
|
||||
|
||||
@@ -1822,6 +1822,7 @@ const messages: TranslationMap = {
|
||||
'2 मिनट बाद भी एजेंट से कोई जवाब नहीं मिला। दोबारा कोशिश करें या अपना कनेक्शन चेक करें।',
|
||||
'chat.filter.general': 'सामान्य',
|
||||
'chat.filter.subconscious': 'सबकॉन्शस',
|
||||
'chat.filter.meetings': 'मीटिंग',
|
||||
'chat.filter.tasks': 'टास्क',
|
||||
'chat.selectThread': 'एक थ्रेड चुनें',
|
||||
'chat.threads': 'थ्रेड्स',
|
||||
@@ -4303,13 +4304,14 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': 'मीटिंग में',
|
||||
'skills.meetingBots.liveStatusJoining': 'शामिल हो रहा है\u2026',
|
||||
'skills.meetingBots.liveStatusActive': 'मीटिंग में लाइव',
|
||||
'skills.meetingBots.liveStatusListening': 'सुन रहा है (म्यूट)',
|
||||
'skills.meetingBots.liveStatusEnded': 'मीटिंग समाप्त',
|
||||
'skills.meetingBots.liveStatusError': 'शामिल होने में विफल',
|
||||
'skills.meetingBots.leaveButton': 'छोड़ें',
|
||||
'skills.meetingBots.respondToParticipant': 'इस मीटिंग में आपका नाम',
|
||||
'skills.meetingBots.respondToParticipantHint': 'जैसे: अनीता (कॉल में आपका प्रदर्शन नाम)',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'बॉट केवल आपको जवाब देगा। किसी को भी सक्रिय करने देने के लिए खाली छोड़ें।',
|
||||
'मीटिंग में अपना सटीक डिस्प्ले नाम दर्ज करें। बॉट केवल तभी जवाब देता है जब आप उसका नाम बोलते हैं (वेक फ्रेज़)।',
|
||||
'skills.meetingBots.wakePhrase': 'वेक फ्रेज़',
|
||||
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc': 'बोट के जवाब देने से पहले प्रतिभागी को यह कहना होगा।',
|
||||
|
||||
@@ -1825,6 +1825,7 @@ const messages: TranslationMap = {
|
||||
'chat.safetyTimeout': 'Tidak ada respons dari agen setelah 2 menit. Coba lagi atau cek koneksi.',
|
||||
'chat.filter.general': 'Umum',
|
||||
'chat.filter.subconscious': 'Bawah sadar',
|
||||
'chat.filter.meetings': 'Rapat',
|
||||
'chat.filter.tasks': 'Tugas',
|
||||
'chat.selectThread': 'Pilih thread',
|
||||
'chat.threads': 'Thread',
|
||||
@@ -4313,13 +4314,14 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': 'Dalam Rapat',
|
||||
'skills.meetingBots.liveStatusJoining': 'Bergabung\u2026',
|
||||
'skills.meetingBots.liveStatusActive': 'Langsung dalam rapat',
|
||||
'skills.meetingBots.liveStatusListening': 'Mendengarkan (dibisukan)',
|
||||
'skills.meetingBots.liveStatusEnded': 'Rapat selesai',
|
||||
'skills.meetingBots.liveStatusError': 'Gagal bergabung',
|
||||
'skills.meetingBots.leaveButton': 'Keluar',
|
||||
'skills.meetingBots.respondToParticipant': 'Nama Anda di Rapat Ini',
|
||||
'skills.meetingBots.respondToParticipantHint': 'mis. Budi (nama tampilan Anda di panggilan)',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Bot hanya akan membalas Anda. Kosongkan agar siapa saja dapat mengaktifkannya.',
|
||||
'Masukkan nama tampilan Anda yang tepat dari rapat. Bot hanya merespons ketika Anda menyebut namanya (frasa bangun).',
|
||||
'skills.meetingBots.wakePhrase': 'Frasa Bangun',
|
||||
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc': 'Peserta harus mengucapkan ini sebelum bot merespons.',
|
||||
|
||||
@@ -1853,6 +1853,7 @@ const messages: TranslationMap = {
|
||||
"Nessuna risposta dall'agente dopo 2 minuti. Riprova o controlla la connessione.",
|
||||
'chat.filter.general': 'Generale',
|
||||
'chat.filter.subconscious': 'Subconscio',
|
||||
'chat.filter.meetings': 'Riunioni',
|
||||
'chat.filter.tasks': 'Attività',
|
||||
'chat.selectThread': 'Seleziona un thread',
|
||||
'chat.threads': 'Thread',
|
||||
@@ -4371,6 +4372,7 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': 'In Riunione',
|
||||
'skills.meetingBots.liveStatusJoining': 'Partecipando\u2026',
|
||||
'skills.meetingBots.liveStatusActive': 'In diretta nella riunione',
|
||||
'skills.meetingBots.liveStatusListening': 'In ascolto (muto)',
|
||||
'skills.meetingBots.liveStatusEnded': 'Riunione terminata',
|
||||
'skills.meetingBots.liveStatusError': 'Partecipazione fallita',
|
||||
'skills.meetingBots.leaveButton': 'Esci',
|
||||
@@ -4378,7 +4380,7 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.respondToParticipantHint':
|
||||
'es. Mario (il tuo nome visualizzato nella chiamata)',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Il bot risponderà solo a te. Lascia vuoto per permettere a chiunque di attivarlo.',
|
||||
'Inserisci il tuo nome visualizzato esatto nella riunione. Il bot risponde solo quando pronunci il suo nome (frase di attivazione).',
|
||||
'skills.meetingBots.wakePhrase': 'Frase di attivazione',
|
||||
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc': 'Il partecipante deve dirlo prima che il bot risponda.',
|
||||
|
||||
@@ -1801,6 +1801,7 @@ const messages: TranslationMap = {
|
||||
'chat.safetyTimeout': '2분 후에도 에이전트의 응답이 없습니다. 다시 시도하거나 연결을 확인하세요.',
|
||||
'chat.filter.general': '일반',
|
||||
'chat.filter.subconscious': '잠재의식',
|
||||
'chat.filter.meetings': '회의',
|
||||
'chat.filter.tasks': '작업',
|
||||
'chat.selectThread': '스레드 선택',
|
||||
'chat.threads': '스레드',
|
||||
@@ -4254,13 +4255,14 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': '회의 중',
|
||||
'skills.meetingBots.liveStatusJoining': '참가 중\u2026',
|
||||
'skills.meetingBots.liveStatusActive': '회의에서 라이브',
|
||||
'skills.meetingBots.liveStatusListening': '듣는 중 (음소거)',
|
||||
'skills.meetingBots.liveStatusEnded': '회의 종료',
|
||||
'skills.meetingBots.liveStatusError': '참가 실패',
|
||||
'skills.meetingBots.leaveButton': '나가기',
|
||||
'skills.meetingBots.respondToParticipant': '이 회의에서 내 이름',
|
||||
'skills.meetingBots.respondToParticipantHint': '예: 김철수 (통화에서 표시되는 이름)',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'봇은 나에게만 응답합니다. 누구나 활성화할 수 있도록 비워 두세요.',
|
||||
'회의에서 사용하는 정확한 표시 이름을 입력하세요. 봇은 이름(웨이크 구문)을 말할 때만 응답합니다.',
|
||||
'skills.meetingBots.wakePhrase': '웨이크 구문',
|
||||
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc': '참가자가 봇이 응답하기 전에 이것을 말해야 합니다.',
|
||||
|
||||
@@ -1843,6 +1843,7 @@ const messages: TranslationMap = {
|
||||
'Brak odpowiedzi agenta po 2 minutach. Spróbuj ponownie lub sprawdź połączenie.',
|
||||
'chat.filter.general': 'Ogólne',
|
||||
'chat.filter.subconscious': 'Podświadomość',
|
||||
'chat.filter.meetings': 'Spotkania',
|
||||
'chat.filter.tasks': 'Zadania',
|
||||
'chat.selectThread': 'Wybierz wątek',
|
||||
'chat.threads': 'Wątki',
|
||||
@@ -4367,6 +4368,7 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': 'Na spotkaniu',
|
||||
'skills.meetingBots.liveStatusJoining': 'Dołączanie\u2026',
|
||||
'skills.meetingBots.liveStatusActive': 'Na żywo na spotkaniu',
|
||||
'skills.meetingBots.liveStatusListening': 'Słuchanie (wyciszony)',
|
||||
'skills.meetingBots.liveStatusEnded': 'Spotkanie zakończone',
|
||||
'skills.meetingBots.liveStatusError': 'Nie można dołączyć',
|
||||
'skills.meetingBots.leaveButton': 'Wyjdź',
|
||||
@@ -4374,7 +4376,7 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.respondToParticipantHint':
|
||||
'np. Anna (Twoja nazwa wyświetlana podczas rozmowy)',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Bot będzie odpowiadał tylko Tobie. Pozostaw puste, aby każdy mógł go aktywować.',
|
||||
'Wprowadź swój dokładny wyświetlany nick ze spotkania. Bot reaguje tylko wtedy, gdy wypowiesz jego nazwę (fraza aktywacji).',
|
||||
'skills.meetingBots.wakePhrase': 'Fraza aktywacji',
|
||||
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc': 'Uczestnik musi to powiedzieć, zanim bot odpowie.',
|
||||
|
||||
@@ -1860,6 +1860,7 @@ const messages: TranslationMap = {
|
||||
'Nenhuma resposta do agente após 2 minutos. Tente novamente ou verifique sua conexão.',
|
||||
'chat.filter.general': 'Geral',
|
||||
'chat.filter.subconscious': 'Subconsciente',
|
||||
'chat.filter.meetings': 'Reuniões',
|
||||
'chat.filter.tasks': 'Tarefas',
|
||||
'chat.selectThread': 'Selecione uma conversa',
|
||||
'chat.threads': 'Conversas',
|
||||
@@ -4370,13 +4371,14 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': 'Em reunião',
|
||||
'skills.meetingBots.liveStatusJoining': 'Entrando\u2026',
|
||||
'skills.meetingBots.liveStatusActive': 'Ao vivo na reunião',
|
||||
'skills.meetingBots.liveStatusListening': 'Ouvindo (mudo)',
|
||||
'skills.meetingBots.liveStatusEnded': 'Reunião encerrada',
|
||||
'skills.meetingBots.liveStatusError': 'Falha ao entrar',
|
||||
'skills.meetingBots.leaveButton': 'Sair',
|
||||
'skills.meetingBots.respondToParticipant': 'Seu nome nesta reunião',
|
||||
'skills.meetingBots.respondToParticipantHint': 'ex. João (seu nome exibido na chamada)',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'O bot só responderá a você. Deixe em branco para que qualquer pessoa possa ativá-lo.',
|
||||
'Insira o seu nome de exibição exato da reunião. O bot só responde quando você diz o nome dele (frase de ativação).',
|
||||
'skills.meetingBots.wakePhrase': 'Frase de ativação',
|
||||
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc': 'O participante deve dizer isso antes de o bot responder.',
|
||||
|
||||
@@ -1836,6 +1836,7 @@ const messages: TranslationMap = {
|
||||
'Агент не ответил в течение 2 минут. Попробуй снова или проверь соединение.',
|
||||
'chat.filter.general': 'Общее',
|
||||
'chat.filter.subconscious': 'Подсознание',
|
||||
'chat.filter.meetings': 'Встречи',
|
||||
'chat.filter.tasks': 'Задачи',
|
||||
'chat.selectThread': 'Выбери чат',
|
||||
'chat.threads': 'Чаты',
|
||||
@@ -4333,13 +4334,14 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': 'На встрече',
|
||||
'skills.meetingBots.liveStatusJoining': 'Подключение\u2026',
|
||||
'skills.meetingBots.liveStatusActive': 'В прямом эфире на встрече',
|
||||
'skills.meetingBots.liveStatusListening': 'Прослушивание (без звука)',
|
||||
'skills.meetingBots.liveStatusEnded': 'Встреча завершена',
|
||||
'skills.meetingBots.liveStatusError': 'Ошибка подключения',
|
||||
'skills.meetingBots.leaveButton': 'Выйти',
|
||||
'skills.meetingBots.respondToParticipant': 'Ваше имя на этой встрече',
|
||||
'skills.meetingBots.respondToParticipantHint': 'напр. Иван (ваше отображаемое имя в звонке)',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'Бот будет отвечать только вам. Оставьте пустым, чтобы любой мог его активировать.',
|
||||
'Введите своё точное отображаемое имя из встречи. Бот реагирует только когда вы произносите его имя (фраза активации).',
|
||||
'skills.meetingBots.wakePhrase': 'Фраза активации',
|
||||
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc': 'Участник должен произнести это, прежде чем бот ответит.',
|
||||
|
||||
@@ -1719,6 +1719,7 @@ const messages: TranslationMap = {
|
||||
'chat.safetyTimeout': '助手 2 分钟内未响应。请重试或检查你的连接。',
|
||||
'chat.filter.general': '常规',
|
||||
'chat.filter.subconscious': '潜意识',
|
||||
'chat.filter.meetings': '会议',
|
||||
'chat.filter.tasks': '任务',
|
||||
'chat.selectThread': '选择一个对话',
|
||||
'chat.threads': '对话列表',
|
||||
@@ -4086,12 +4087,14 @@ const messages: TranslationMap = {
|
||||
'skills.meetingBots.liveTitle': '会议中',
|
||||
'skills.meetingBots.liveStatusJoining': '正在加入\u2026',
|
||||
'skills.meetingBots.liveStatusActive': '会议直播中',
|
||||
'skills.meetingBots.liveStatusListening': '正在收听(已静音)',
|
||||
'skills.meetingBots.liveStatusEnded': '会议已结束',
|
||||
'skills.meetingBots.liveStatusError': '加入失败',
|
||||
'skills.meetingBots.leaveButton': '离开',
|
||||
'skills.meetingBots.respondToParticipant': '您在此会议中的姓名',
|
||||
'skills.meetingBots.respondToParticipantHint': '例如:小明(通话中的显示名称)',
|
||||
'skills.meetingBots.respondToParticipantDesc': '机器人只会回应您。留空以允许任何人激活它。',
|
||||
'skills.meetingBots.respondToParticipantDesc':
|
||||
'输入您在会议中的确切显示名称。机器人仅在您说出其名称(唤醒词)时才会响应。',
|
||||
'skills.meetingBots.wakePhrase': '唤醒词',
|
||||
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
|
||||
'skills.meetingBots.wakePhraseDesc': '参与者必须先说出此词,机器人才会回复。',
|
||||
|
||||
@@ -102,6 +102,7 @@ import {
|
||||
import {
|
||||
GENERAL_TAB_VALUE,
|
||||
isThreadVisibleInTab,
|
||||
MEETINGS_TAB_VALUE,
|
||||
SUBCONSCIOUS_TAB_VALUE,
|
||||
TASKS_TAB_VALUE,
|
||||
} from './conversations/utils/threadFilter';
|
||||
@@ -411,9 +412,11 @@ const Conversations = ({
|
||||
setSelectedLabel(
|
||||
isThreadVisibleInTab(openThread, TASKS_TAB_VALUE)
|
||||
? TASKS_TAB_VALUE
|
||||
: isThreadVisibleInTab(openThread, SUBCONSCIOUS_TAB_VALUE)
|
||||
? SUBCONSCIOUS_TAB_VALUE
|
||||
: GENERAL_TAB_VALUE
|
||||
: isThreadVisibleInTab(openThread, MEETINGS_TAB_VALUE)
|
||||
? MEETINGS_TAB_VALUE
|
||||
: isThreadVisibleInTab(openThread, SUBCONSCIOUS_TAB_VALUE)
|
||||
? SUBCONSCIOUS_TAB_VALUE
|
||||
: GENERAL_TAB_VALUE
|
||||
);
|
||||
dispatch(setSelectedThread(openThread.id));
|
||||
void dispatch(loadThreadMessages(openThread.id));
|
||||
@@ -1263,6 +1266,7 @@ const Conversations = ({
|
||||
// filter state remains unambiguous regardless of what threads exist.
|
||||
const labelTabs = [
|
||||
{ label: t('chat.filter.general'), value: GENERAL_TAB_VALUE },
|
||||
{ label: t('chat.filter.meetings'), value: MEETINGS_TAB_VALUE },
|
||||
{ label: t('chat.filter.subconscious'), value: SUBCONSCIOUS_TAB_VALUE },
|
||||
{ label: t('chat.filter.tasks'), value: TASKS_TAB_VALUE },
|
||||
];
|
||||
|
||||
@@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { Thread } from '../../../types/thread';
|
||||
import {
|
||||
GENERAL_TAB_VALUE,
|
||||
isMeetingThread,
|
||||
isThreadVisibleInTab,
|
||||
MEETINGS_TAB_VALUE,
|
||||
SUBCONSCIOUS_TAB_VALUE,
|
||||
TASKS_TAB_VALUE,
|
||||
} from './threadFilter';
|
||||
@@ -53,6 +55,13 @@ describe('isThreadVisibleInTab', () => {
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes meeting threads from the General bucket', () => {
|
||||
expect(
|
||||
isThreadVisibleInTab(thread({ labels: [MEETINGS_TAB_VALUE] }), GENERAL_TAB_VALUE)
|
||||
).toBe(false);
|
||||
expect(isThreadVisibleInTab(thread({ labels: ['Meetings'] }), GENERAL_TAB_VALUE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subconscious bucket', () => {
|
||||
@@ -105,4 +114,38 @@ describe('isThreadVisibleInTab', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meetings bucket', () => {
|
||||
it('keeps threads with canonical "meetings" label', () => {
|
||||
expect(
|
||||
isThreadVisibleInTab(thread({ labels: [MEETINGS_TAB_VALUE] }), MEETINGS_TAB_VALUE)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps threads with Rust-generated "Meetings" (capitalized) label', () => {
|
||||
expect(isThreadVisibleInTab(thread({ labels: ['Meetings'] }), MEETINGS_TAB_VALUE)).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes ordinary and task threads', () => {
|
||||
expect(
|
||||
isThreadVisibleInTab(thread({ labels: [GENERAL_TAB_VALUE] }), MEETINGS_TAB_VALUE)
|
||||
).toBe(false);
|
||||
expect(isThreadVisibleInTab(thread({ labels: [TASKS_TAB_VALUE] }), MEETINGS_TAB_VALUE)).toBe(
|
||||
false
|
||||
);
|
||||
expect(isThreadVisibleInTab(thread({ labels: [] }), MEETINGS_TAB_VALUE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMeetingThread helper', () => {
|
||||
it('recognizes canonical and capitalized meeting labels', () => {
|
||||
expect(isMeetingThread(thread({ labels: [MEETINGS_TAB_VALUE] }))).toBe(true);
|
||||
expect(isMeetingThread(thread({ labels: ['Meetings'] }))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-meeting threads', () => {
|
||||
expect(isMeetingThread(thread({ labels: [] }))).toBe(false);
|
||||
expect(isMeetingThread(thread({ labels: [GENERAL_TAB_VALUE] }))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,12 @@ import type { Thread } from '../../../types/thread';
|
||||
export const GENERAL_TAB_VALUE = 'general';
|
||||
export const SUBCONSCIOUS_TAB_VALUE = 'subconscious';
|
||||
export const TASKS_TAB_VALUE = 'tasks';
|
||||
export const MEETINGS_TAB_VALUE = 'meetings';
|
||||
export const LEGACY_GENERAL_LABEL = 'work';
|
||||
export const LEGACY_SUBCONSCIOUS_LABELS = ['from_reflection', 'subconscious_tick'];
|
||||
export const LEGACY_TASK_LABELS = ['agent-task', 'worker'];
|
||||
/** Canonical label applied to meeting transcript threads by the Rust core. */
|
||||
const MEETINGS_LABEL = 'Meetings';
|
||||
|
||||
function hasAnyLabel(thread: Thread, labels: readonly string[]): boolean {
|
||||
return Boolean(thread.labels?.some(label => labels.includes(label)));
|
||||
@@ -21,6 +24,10 @@ export function isTaskThread(thread: Thread): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function isMeetingThread(thread: Thread): boolean {
|
||||
return hasAnyLabel(thread, [MEETINGS_TAB_VALUE, MEETINGS_LABEL]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure, side-effect-free thread filter shared between
|
||||
* `Conversations.tsx` (which renders the sidebar list) and the test
|
||||
@@ -37,10 +44,12 @@ export function isTaskThread(thread: Thread): boolean {
|
||||
export function isThreadVisibleInTab(thread: Thread, selectedLabel: string): boolean {
|
||||
const isSubconscious = isSubconsciousThread(thread);
|
||||
const isTask = isTaskThread(thread);
|
||||
const isMeeting = isMeetingThread(thread);
|
||||
if (selectedLabel === SUBCONSCIOUS_TAB_VALUE) return isSubconscious;
|
||||
if (selectedLabel === TASKS_TAB_VALUE) return isTask;
|
||||
if (selectedLabel === MEETINGS_TAB_VALUE) return isMeeting;
|
||||
if (selectedLabel === GENERAL_TAB_VALUE) {
|
||||
return !isSubconscious && !isTask;
|
||||
return !isSubconscious && !isTask && !isMeeting;
|
||||
}
|
||||
return Boolean(thread.labels?.includes(selectedLabel));
|
||||
}
|
||||
|
||||
@@ -172,6 +172,10 @@ export type BackendMeetJoinInput = {
|
||||
respondToParticipant?: string;
|
||||
/** Wake phrase the participant must say before the bot responds (empty = no wake phrase). */
|
||||
wakePhrase?: string;
|
||||
/** Opaque correlation id echoed on all `bot:*` events for this session. */
|
||||
correlationId?: string;
|
||||
/** When true, the bot joins in listen-only mode (no microphone, no replies). */
|
||||
listenOnly?: boolean;
|
||||
};
|
||||
|
||||
type CoreBackendMeetJoinResponse = { ok: boolean; meet_url: string; platform: string };
|
||||
@@ -202,6 +206,8 @@ export async function joinMeetViaBackendBot(
|
||||
mascot_id: input.mascotId?.trim() || undefined,
|
||||
respond_to_participant: input.respondToParticipant?.trim() || undefined,
|
||||
wake_phrase: input.wakePhrase?.trim() || undefined,
|
||||
correlation_id: input.correlationId?.trim() || undefined,
|
||||
listen_only: input.listenOnly ?? undefined,
|
||||
rive_colors: (() => {
|
||||
if (!input.riveColors) return undefined;
|
||||
const primary = input.riveColors.primaryColor?.trim() || undefined;
|
||||
|
||||
@@ -428,55 +428,71 @@ class SocketService {
|
||||
this.socket.on('agent_meetings:joined', (data: unknown) => {
|
||||
const obj = data as Record<string, unknown> | null;
|
||||
const meetUrl = typeof obj?.meet_url === 'string' ? obj.meet_url : '';
|
||||
socketLog('agent_meetings:joined meet_url_len=%d', meetUrl.length);
|
||||
store.dispatch(setBackendMeetJoined({ meetUrl }));
|
||||
const correlationId =
|
||||
typeof obj?.correlation_id === 'string' ? obj.correlation_id : undefined;
|
||||
socketLog(
|
||||
'agent_meetings:joined meet_url_len=%d correlation_id=%s',
|
||||
meetUrl.length,
|
||||
correlationId ?? 'none'
|
||||
);
|
||||
store.dispatch(setBackendMeetJoined({ meetUrl, meetingId: correlationId }));
|
||||
});
|
||||
this.socket.on('agent_meetings:left', (data: unknown) => {
|
||||
const obj = data as Record<string, unknown> | null;
|
||||
const reason = typeof obj?.reason === 'string' ? obj.reason : 'unknown';
|
||||
socketLog('agent_meetings:left reason=%s', reason);
|
||||
store.dispatch(setBackendMeetLeft({ reason }));
|
||||
const correlationId =
|
||||
typeof obj?.correlation_id === 'string' ? obj.correlation_id : undefined;
|
||||
socketLog('agent_meetings:left reason=%s correlation_id=%s', reason, correlationId ?? 'none');
|
||||
store.dispatch(setBackendMeetLeft({ reason, correlationId }));
|
||||
});
|
||||
this.socket.on('agent_meetings:reply', (data: unknown) => {
|
||||
const obj = data as Record<string, unknown> | null;
|
||||
if (!obj) return;
|
||||
socketLog('agent_meetings:reply');
|
||||
const correlationId = typeof obj.correlation_id === 'string' ? obj.correlation_id : undefined;
|
||||
socketLog('agent_meetings:reply correlation_id=%s', correlationId ?? 'none');
|
||||
store.dispatch(
|
||||
setBackendMeetReply({
|
||||
transcript: typeof obj.transcript === 'string' ? obj.transcript : '',
|
||||
reply: typeof obj.reply === 'string' ? obj.reply : '',
|
||||
emotion: typeof obj.emotion === 'string' ? obj.emotion : 'neutral',
|
||||
correlationId,
|
||||
})
|
||||
);
|
||||
});
|
||||
this.socket.on('agent_meetings:harness', (data: unknown) => {
|
||||
const obj = data as Record<string, unknown> | null;
|
||||
if (!obj) return;
|
||||
socketLog('agent_meetings:harness');
|
||||
const correlationId = typeof obj.correlation_id === 'string' ? obj.correlation_id : undefined;
|
||||
socketLog('agent_meetings:harness correlation_id=%s', correlationId ?? 'none');
|
||||
store.dispatch(
|
||||
setBackendMeetHarness({
|
||||
transcript: typeof obj.transcript === 'string' ? obj.transcript : '',
|
||||
instruction: typeof obj.instruction === 'string' ? obj.instruction : '',
|
||||
emotion: typeof obj.emotion === 'string' ? obj.emotion : 'neutral',
|
||||
correlationId,
|
||||
})
|
||||
);
|
||||
});
|
||||
this.socket.on('agent_meetings:transcript', (data: unknown) => {
|
||||
const obj = data as Record<string, unknown> | null;
|
||||
if (!obj) return;
|
||||
socketLog('agent_meetings:transcript');
|
||||
const correlationId = typeof obj.correlation_id === 'string' ? obj.correlation_id : undefined;
|
||||
socketLog('agent_meetings:transcript correlation_id=%s', correlationId ?? 'none');
|
||||
store.dispatch(
|
||||
setBackendMeetTranscript({
|
||||
turns: Array.isArray(obj.turns) ? obj.turns : [],
|
||||
duration_ms: typeof obj.duration_ms === 'number' ? obj.duration_ms : 0,
|
||||
correlationId,
|
||||
})
|
||||
);
|
||||
});
|
||||
this.socket.on('agent_meetings:error', (data: unknown) => {
|
||||
const obj = data as Record<string, unknown> | null;
|
||||
const error = typeof obj?.error === 'string' ? obj.error : 'Unknown error';
|
||||
socketError('agent_meetings:error %s', error);
|
||||
store.dispatch(setBackendMeetError({ error }));
|
||||
const correlationId =
|
||||
typeof obj?.correlation_id === 'string' ? obj.correlation_id : undefined;
|
||||
socketError('agent_meetings:error %s correlation_id=%s', error, correlationId ?? 'none');
|
||||
store.dispatch(setBackendMeetError({ error, correlationId }));
|
||||
});
|
||||
|
||||
this.socket.connect();
|
||||
|
||||
@@ -13,22 +13,27 @@ export interface BackendMeetReplyEvent {
|
||||
transcript: string;
|
||||
reply: string;
|
||||
emotion: string;
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
export interface BackendMeetHarnessEvent {
|
||||
transcript: string;
|
||||
instruction: string;
|
||||
emotion: string;
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
export interface BackendMeetTranscriptEvent {
|
||||
turns: BackendMeetTurn[];
|
||||
duration_ms: number;
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
export interface BackendMeetState {
|
||||
status: BackendMeetStatus;
|
||||
meetUrl: string | null;
|
||||
meetingId: string | null;
|
||||
listenOnly: boolean;
|
||||
lastReply: BackendMeetReplyEvent | null;
|
||||
lastHarness: BackendMeetHarnessEvent | null;
|
||||
transcript: BackendMeetTranscriptEvent | null;
|
||||
@@ -38,6 +43,8 @@ export interface BackendMeetState {
|
||||
const initialState: BackendMeetState = {
|
||||
status: 'idle',
|
||||
meetUrl: null,
|
||||
meetingId: null,
|
||||
listenOnly: false,
|
||||
lastReply: null,
|
||||
lastHarness: null,
|
||||
transcript: null,
|
||||
@@ -48,19 +55,29 @@ const backendMeetSlice = createSlice({
|
||||
name: 'backendMeet',
|
||||
initialState,
|
||||
reducers: {
|
||||
setBackendMeetJoining(state, action: PayloadAction<{ meetUrl: string }>) {
|
||||
setBackendMeetJoining(
|
||||
state,
|
||||
action: PayloadAction<{ meetUrl: string; meetingId?: string | null; listenOnly?: boolean }>
|
||||
) {
|
||||
state.status = 'joining';
|
||||
state.meetUrl = action.payload.meetUrl;
|
||||
state.meetingId = action.payload.meetingId ?? null;
|
||||
state.listenOnly = action.payload.listenOnly ?? false;
|
||||
state.error = null;
|
||||
state.lastReply = null;
|
||||
state.lastHarness = null;
|
||||
state.transcript = null;
|
||||
},
|
||||
setBackendMeetJoined(state, action: PayloadAction<{ meetUrl: string }>) {
|
||||
setBackendMeetJoined(state, action: PayloadAction<{ meetUrl: string; meetingId?: string }>) {
|
||||
state.status = 'active';
|
||||
state.meetUrl = action.payload.meetUrl;
|
||||
// Backfill meetingId from the backend's correlation_id echo if the
|
||||
// optimistic setBackendMeetJoining didn't set one.
|
||||
if (action.payload.meetingId) {
|
||||
state.meetingId = action.payload.meetingId;
|
||||
}
|
||||
},
|
||||
setBackendMeetLeft(state, _action: PayloadAction<{ reason: string }>) {
|
||||
setBackendMeetLeft(state, _action: PayloadAction<{ reason: string; correlationId?: string }>) {
|
||||
state.status = 'ended';
|
||||
},
|
||||
setBackendMeetReply(state, action: PayloadAction<BackendMeetReplyEvent>) {
|
||||
@@ -72,7 +89,7 @@ const backendMeetSlice = createSlice({
|
||||
setBackendMeetTranscript(state, action: PayloadAction<BackendMeetTranscriptEvent>) {
|
||||
state.transcript = action.payload;
|
||||
},
|
||||
setBackendMeetError(state, action: PayloadAction<{ error: string }>) {
|
||||
setBackendMeetError(state, action: PayloadAction<{ error: string; correlationId?: string }>) {
|
||||
state.status = 'error';
|
||||
state.error = action.payload.error;
|
||||
},
|
||||
@@ -105,5 +122,10 @@ export const selectBackendMeetLastReply = (state: { backendMeet: BackendMeetStat
|
||||
state.backendMeet.lastReply;
|
||||
export const selectBackendMeetLastHarness = (state: { backendMeet: BackendMeetState }) =>
|
||||
state.backendMeet.lastHarness;
|
||||
export const selectBackendMeetMeetingId = (state: {
|
||||
backendMeet: BackendMeetState;
|
||||
}): string | null => state.backendMeet.meetingId;
|
||||
export const selectBackendMeetListenOnly = (state: { backendMeet: BackendMeetState }): boolean =>
|
||||
state.backendMeet.listenOnly;
|
||||
|
||||
export default backendMeetSlice.reducer;
|
||||
|
||||
@@ -29,7 +29,7 @@ test.describe('Google Meet Connections tab', () => {
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByLabel('Meeting link')).toBeVisible();
|
||||
await expect(dialog.locator('input[type="url"]')).toHaveCount(1);
|
||||
await expect(dialog.locator('input[type="text"]')).toHaveCount(0);
|
||||
await expect(dialog.locator('input[type="text"]')).toHaveCount(1);
|
||||
await expect(dialog.getByText('Wake Phrase')).toHaveCount(0);
|
||||
await expect(dialog.getByText('Display name')).toHaveCount(0);
|
||||
await expect(dialog.getByText('Zoom')).toHaveCount(0);
|
||||
|
||||
@@ -980,28 +980,61 @@ pub enum DomainEvent {
|
||||
|
||||
// ── Backend Meet Bot ──────────────────────────────────────────────
|
||||
/// Backend gmeet bot successfully joined the meeting.
|
||||
BackendMeetJoined { meet_url: String },
|
||||
BackendMeetJoined {
|
||||
meet_url: String,
|
||||
correlation_id: Option<String>,
|
||||
},
|
||||
/// Backend gmeet bot left the meeting.
|
||||
BackendMeetLeft { reason: String },
|
||||
BackendMeetLeft {
|
||||
reason: String,
|
||||
correlation_id: Option<String>,
|
||||
},
|
||||
/// Backend gmeet bot produced a spoken reply.
|
||||
BackendMeetReply {
|
||||
transcript: String,
|
||||
reply: String,
|
||||
emotion: String,
|
||||
correlation_id: Option<String>,
|
||||
},
|
||||
/// Backend gmeet bot needs the harness to execute a tool instruction.
|
||||
BackendMeetHarness {
|
||||
transcript: String,
|
||||
instruction: String,
|
||||
emotion: String,
|
||||
correlation_id: Option<String>,
|
||||
},
|
||||
/// Backend gmeet bot sent the full meeting transcript on close.
|
||||
BackendMeetTranscript {
|
||||
turns: Vec<BackendMeetTurn>,
|
||||
duration_ms: u64,
|
||||
correlation_id: Option<String>,
|
||||
},
|
||||
/// Backend gmeet bot emitted an error.
|
||||
BackendMeetError { error: String },
|
||||
BackendMeetError {
|
||||
error: String,
|
||||
correlation_id: Option<String>,
|
||||
},
|
||||
/// Backend gmeet bot detected a wake-phrase command from a participant.
|
||||
BackendMeetInCallRequest {
|
||||
correlation_id: Option<String>,
|
||||
speaker: String,
|
||||
command_text: String,
|
||||
recent_transcript: Vec<BackendMeetTurn>,
|
||||
timestamp_ms: u64,
|
||||
},
|
||||
/// A Google Calendar event with a Meet link was detected and the
|
||||
/// auto-join policy is "ask" — the UI should prompt the user.
|
||||
MeetAutoJoinPrompt {
|
||||
meet_url: String,
|
||||
event_title: String,
|
||||
},
|
||||
/// Reserved for PR-4: a post-meeting summary was generated from the
|
||||
/// transcript (action items, key decisions, etc.).
|
||||
MeetingSummaryGenerated {
|
||||
thread_id: String,
|
||||
correlation_id: Option<String>,
|
||||
summary: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -1142,7 +1175,10 @@ impl DomainEvent {
|
||||
| Self::BackendMeetReply { .. }
|
||||
| Self::BackendMeetHarness { .. }
|
||||
| Self::BackendMeetTranscript { .. }
|
||||
| Self::BackendMeetError { .. } => "agent_meetings",
|
||||
| Self::BackendMeetError { .. }
|
||||
| Self::BackendMeetInCallRequest { .. }
|
||||
| Self::MeetAutoJoinPrompt { .. }
|
||||
| Self::MeetingSummaryGenerated { .. } => "agent_meetings",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1258,6 +1294,9 @@ impl DomainEvent {
|
||||
Self::BackendMeetHarness { .. } => "BackendMeetHarness",
|
||||
Self::BackendMeetTranscript { .. } => "BackendMeetTranscript",
|
||||
Self::BackendMeetError { .. } => "BackendMeetError",
|
||||
Self::BackendMeetInCallRequest { .. } => "BackendMeetInCallRequest",
|
||||
Self::MeetAutoJoinPrompt { .. } => "MeetAutoJoinPrompt",
|
||||
Self::MeetingSummaryGenerated { .. } => "MeetingSummaryGenerated",
|
||||
Self::Voice(_) => "Voice",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1899,6 +1899,8 @@ fn register_domain_subscribers(
|
||||
log::warn!("[composio][history] failed to initialize trigger archive: {error}");
|
||||
}
|
||||
crate::openhuman::composio::register_composio_trigger_subscriber();
|
||||
crate::openhuman::agent_meetings::calendar::register_meet_calendar_subscriber();
|
||||
crate::openhuman::agent_meetings::bus::register_meeting_event_subscriber();
|
||||
crate::openhuman::composio::start_periodic_sync();
|
||||
// Task-sources proactive ingestion: connection-created hook + poll.
|
||||
crate::openhuman::task_sources::bus::register_task_sources_subscriber();
|
||||
|
||||
+23
-6
@@ -968,13 +968,20 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
};
|
||||
match event {
|
||||
crate::core::event_bus::DomainEvent::BackendMeetJoined { meet_url } => {
|
||||
let payload = serde_json::json!({ "meet_url": meet_url });
|
||||
crate::core::event_bus::DomainEvent::BackendMeetJoined {
|
||||
meet_url,
|
||||
correlation_id,
|
||||
} => {
|
||||
let payload = serde_json::json!({ "meet_url": meet_url, "correlation_id": correlation_id });
|
||||
log::debug!("[socketio] broadcast agent_meetings:joined");
|
||||
let _ = io_agent_meetings.emit("agent_meetings:joined", &payload);
|
||||
}
|
||||
crate::core::event_bus::DomainEvent::BackendMeetLeft { reason } => {
|
||||
let payload = serde_json::json!({ "reason": reason });
|
||||
crate::core::event_bus::DomainEvent::BackendMeetLeft {
|
||||
reason,
|
||||
correlation_id,
|
||||
} => {
|
||||
let payload =
|
||||
serde_json::json!({ "reason": reason, "correlation_id": correlation_id });
|
||||
log::debug!("[socketio] broadcast agent_meetings:left reason={}", reason);
|
||||
let _ = io_agent_meetings.emit("agent_meetings:left", &payload);
|
||||
}
|
||||
@@ -982,11 +989,13 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
|
||||
transcript,
|
||||
reply,
|
||||
emotion,
|
||||
correlation_id,
|
||||
} => {
|
||||
let payload = serde_json::json!({
|
||||
"transcript": transcript,
|
||||
"reply": reply,
|
||||
"emotion": emotion,
|
||||
"correlation_id": correlation_id,
|
||||
});
|
||||
log::debug!(
|
||||
"[socketio] broadcast agent_meetings:reply reply_len={}",
|
||||
@@ -998,11 +1007,13 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
|
||||
transcript,
|
||||
instruction,
|
||||
emotion,
|
||||
correlation_id,
|
||||
} => {
|
||||
let payload = serde_json::json!({
|
||||
"transcript": transcript,
|
||||
"instruction": instruction,
|
||||
"emotion": emotion,
|
||||
"correlation_id": correlation_id,
|
||||
});
|
||||
log::debug!(
|
||||
"[socketio] broadcast agent_meetings:harness instruction_len={}",
|
||||
@@ -1013,10 +1024,12 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
|
||||
crate::core::event_bus::DomainEvent::BackendMeetTranscript {
|
||||
turns,
|
||||
duration_ms,
|
||||
correlation_id,
|
||||
} => {
|
||||
let payload = serde_json::json!({
|
||||
"turns": turns,
|
||||
"duration_ms": duration_ms,
|
||||
"correlation_id": correlation_id,
|
||||
});
|
||||
log::debug!(
|
||||
"[socketio] broadcast agent_meetings:transcript turns={} duration_ms={}",
|
||||
@@ -1025,8 +1038,12 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
|
||||
);
|
||||
let _ = io_agent_meetings.emit("agent_meetings:transcript", &payload);
|
||||
}
|
||||
crate::core::event_bus::DomainEvent::BackendMeetError { error } => {
|
||||
let payload = serde_json::json!({ "error": error });
|
||||
crate::core::event_bus::DomainEvent::BackendMeetError {
|
||||
error,
|
||||
correlation_id,
|
||||
} => {
|
||||
let payload =
|
||||
serde_json::json!({ "error": error, "correlation_id": correlation_id });
|
||||
log::debug!("[socketio] broadcast agent_meetings:error");
|
||||
let _ = io_agent_meetings.emit("agent_meetings:error", &payload);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Event-bus subscriber that reacts to backend meeting events.
|
||||
//!
|
||||
//! - `BackendMeetTranscript` → creates a dedicated "Meetings"-labelled
|
||||
//! conversation thread and appends the transcript.
|
||||
//! - `BackendMeetJoined` / `BackendMeetLeft` → logged for audit trail;
|
||||
//! session status tracking is handled by the frontend Redux slice.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::core::event_bus::{DomainEvent, EventHandler, SubscriptionHandle};
|
||||
|
||||
use super::ops::{create_meeting_thread_with_transcript, ingest_backend_meeting_transcript};
|
||||
|
||||
static MEETING_EVENT_HANDLE: OnceLock<SubscriptionHandle> = OnceLock::new();
|
||||
|
||||
const LOG_PREFIX: &str = "[agent_meetings::bus]";
|
||||
|
||||
/// Register the meeting event subscriber. Idempotent — second+ calls are
|
||||
/// no-ops.
|
||||
pub fn register_meeting_event_subscriber() {
|
||||
if MEETING_EVENT_HANDLE.get().is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
match crate::core::event_bus::subscribe_global(std::sync::Arc::new(MeetingEventSubscriber)) {
|
||||
Some(handle) => {
|
||||
let _ = MEETING_EVENT_HANDLE.set(handle);
|
||||
tracing::info!("{LOG_PREFIX} registered");
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("{LOG_PREFIX} failed to register — event bus not initialized");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MeetingEventSubscriber;
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for MeetingEventSubscriber {
|
||||
fn name(&self) -> &str {
|
||||
"agent_meetings::events"
|
||||
}
|
||||
|
||||
fn domains(&self) -> Option<&[&str]> {
|
||||
Some(&["agent_meetings"])
|
||||
}
|
||||
|
||||
async fn handle(&self, event: &DomainEvent) {
|
||||
match event {
|
||||
DomainEvent::BackendMeetTranscript {
|
||||
turns,
|
||||
duration_ms,
|
||||
correlation_id,
|
||||
} => {
|
||||
tracing::info!(
|
||||
turn_count = turns.len(),
|
||||
duration_ms = duration_ms,
|
||||
correlation_id = ?correlation_id,
|
||||
"{LOG_PREFIX} transcript received — creating meeting thread"
|
||||
);
|
||||
|
||||
// Create the meeting thread with transcript.
|
||||
if let Err(e) = create_meeting_thread_with_transcript(
|
||||
turns,
|
||||
*duration_ms,
|
||||
correlation_id.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("{LOG_PREFIX} meeting thread creation failed: {e}");
|
||||
}
|
||||
|
||||
// Also ingest into memory tree (existing pipeline).
|
||||
let enabled = crate::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.map(|c| c.meet.ingest_backend_transcripts)
|
||||
.unwrap_or(false);
|
||||
if enabled {
|
||||
if let Err(e) = ingest_backend_meeting_transcript(
|
||||
turns.clone(),
|
||||
*duration_ms,
|
||||
correlation_id.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("{LOG_PREFIX} memory ingest failed: {e}");
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"{LOG_PREFIX} memory ingest skipped (config.meet.ingest_backend_transcripts = false)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DomainEvent::BackendMeetJoined {
|
||||
meet_url,
|
||||
correlation_id,
|
||||
} => {
|
||||
tracing::info!(
|
||||
meet_url_len = meet_url.len(),
|
||||
correlation_id = ?correlation_id,
|
||||
"{LOG_PREFIX} bot joined meeting"
|
||||
);
|
||||
}
|
||||
|
||||
DomainEvent::BackendMeetLeft {
|
||||
reason,
|
||||
correlation_id,
|
||||
} => {
|
||||
tracing::info!(
|
||||
reason = %reason,
|
||||
correlation_id = ?correlation_id,
|
||||
"{LOG_PREFIX} bot left meeting"
|
||||
);
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn subscriber_name_is_correct() {
|
||||
let subscriber = MeetingEventSubscriber;
|
||||
assert_eq!(subscriber.name(), "agent_meetings::events");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscriber_domains_filter_to_agent_meetings() {
|
||||
let subscriber = MeetingEventSubscriber;
|
||||
assert_eq!(subscriber.domains(), Some(&["agent_meetings"][..]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
//! Calendar-triggered meeting auto-join subscriber.
|
||||
//!
|
||||
//! Listens for [`DomainEvent::ComposioTriggerReceived`] events from the
|
||||
//! `googlecalendar` toolkit and, when the payload contains a Google Meet
|
||||
//! link, either auto-joins or notifies the user based on
|
||||
//! `config.meet.auto_join_policy`.
|
||||
//!
|
||||
//! ## Trigger flow
|
||||
//!
|
||||
//! ```text
|
||||
//! Google Calendar event created/updated
|
||||
//! └─► Composio fires webhook
|
||||
//! └─► backend verifies + emits `composio:trigger` over Socket.IO
|
||||
//! └─► core publishes `ComposioTriggerReceived`
|
||||
//! └─► `MeetCalendarSubscriber` (this module)
|
||||
//! ├─► policy = "always" → emit `bot:join`
|
||||
//! ├─► policy = "ask" → publish `MeetAutoJoinPrompt`
|
||||
//! └─► policy = "never" → drop
|
||||
//! ```
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::core::event_bus::{
|
||||
publish_global, subscribe_global, DomainEvent, EventHandler, SubscriptionHandle,
|
||||
};
|
||||
use crate::openhuman::config::rpc as config_rpc;
|
||||
|
||||
static MEET_CALENDAR_HANDLE: OnceLock<SubscriptionHandle> = OnceLock::new();
|
||||
|
||||
/// Register the calendar-triggered meeting subscriber. Idempotent.
|
||||
pub fn register_meet_calendar_subscriber() {
|
||||
if MEET_CALENDAR_HANDLE.get().is_some() {
|
||||
return;
|
||||
}
|
||||
match subscribe_global(std::sync::Arc::new(MeetCalendarSubscriber)) {
|
||||
Some(handle) => {
|
||||
let _ = MEET_CALENDAR_HANDLE.set(handle);
|
||||
tracing::debug!("[event_bus] meet calendar subscriber registered");
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"[event_bus] failed to register meet calendar subscriber — bus not initialized"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscriber that reacts to Google Calendar Composio triggers.
|
||||
struct MeetCalendarSubscriber;
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for MeetCalendarSubscriber {
|
||||
fn name(&self) -> &str {
|
||||
"agent_meetings::calendar"
|
||||
}
|
||||
|
||||
fn domains(&self) -> Option<&[&str]> {
|
||||
// Listen on the composio domain since that's where
|
||||
// `ComposioTriggerReceived` events are published.
|
||||
Some(&["composio"])
|
||||
}
|
||||
|
||||
async fn handle(&self, event: &DomainEvent) {
|
||||
let DomainEvent::ComposioTriggerReceived {
|
||||
toolkit,
|
||||
trigger,
|
||||
payload,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Only care about Google Calendar triggers.
|
||||
if !toolkit.eq_ignore_ascii_case("googlecalendar") {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
trigger = %trigger,
|
||||
"[meet:calendar] received googlecalendar trigger"
|
||||
);
|
||||
|
||||
// Extract a Google Meet URL from the calendar event payload.
|
||||
// Composio sends different shapes depending on the trigger, but
|
||||
// the Meet link typically lives in one of these locations:
|
||||
// - payload.hangoutLink (direct field on calendar event)
|
||||
// - payload.conferenceData.entryPoints[].uri
|
||||
// - deeply nested inside payload.data.* variants
|
||||
let meet_url = extract_meet_url(payload);
|
||||
let Some(meet_url) = meet_url else {
|
||||
tracing::debug!(
|
||||
trigger = %trigger,
|
||||
"[meet:calendar] no Google Meet URL found in payload, skipping"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// Only act on meetings that are starting soon (within 10 minutes)
|
||||
// or already in progress. Skip events that are far in the future
|
||||
// or already ended.
|
||||
if !is_meeting_imminent(payload) {
|
||||
tracing::debug!(
|
||||
trigger = %trigger,
|
||||
"[meet:calendar] meeting is not imminent, skipping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let event_title = payload
|
||||
.get("summary")
|
||||
.or_else(|| payload.get("title"))
|
||||
.or_else(|| {
|
||||
payload
|
||||
.get("data")
|
||||
.and_then(|d| d.get("summary").or_else(|| d.get("title")))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Untitled meeting")
|
||||
.to_string();
|
||||
|
||||
tracing::info!(
|
||||
trigger = %trigger,
|
||||
meet_url = %meet_url,
|
||||
title = %event_title,
|
||||
"[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;
|
||||
}
|
||||
};
|
||||
|
||||
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 — publish a prompt for the UI.
|
||||
tracing::info!(
|
||||
meet_url = %meet_url,
|
||||
title = %event_title,
|
||||
"[meet:calendar] auto_join_policy=ask_each_time, prompting user"
|
||||
);
|
||||
publish_global(DomainEvent::MeetAutoJoinPrompt {
|
||||
meet_url,
|
||||
event_title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum number of minutes before a meeting starts to consider it "imminent".
|
||||
const IMMINENT_WINDOW_MINUTES: i64 = 10;
|
||||
|
||||
/// Check whether a calendar event is starting soon or already in progress.
|
||||
///
|
||||
/// Returns `true` when:
|
||||
/// - The event's start time is within [`IMMINENT_WINDOW_MINUTES`] from now, or
|
||||
/// - The event has already started but hasn't ended yet, or
|
||||
/// - No start time can be parsed (fail-open to avoid silently dropping events).
|
||||
fn is_meeting_imminent(payload: &serde_json::Value) -> bool {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
// Try to find start/end times. Google Calendar API uses:
|
||||
// start.dateTime (RFC3339) or start.date (all-day)
|
||||
// end.dateTime or end.date
|
||||
// Composio may nest under `data`.
|
||||
let roots = [payload, payload.get("data").unwrap_or(payload)];
|
||||
|
||||
for root in &roots {
|
||||
let start_str = root
|
||||
.get("start")
|
||||
.and_then(|s| s.get("dateTime").or_else(|| s.get("date_time")))
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| root.get("startTime").and_then(|v| v.as_str()))
|
||||
.or_else(|| root.get("start_time").and_then(|v| v.as_str()));
|
||||
|
||||
let end_str = root
|
||||
.get("end")
|
||||
.and_then(|e| e.get("dateTime").or_else(|| e.get("date_time")))
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| root.get("endTime").and_then(|v| v.as_str()))
|
||||
.or_else(|| root.get("end_time").and_then(|v| v.as_str()));
|
||||
|
||||
if let Some(start_str) = start_str {
|
||||
if let Ok(start) = chrono::DateTime::parse_from_rfc3339(start_str) {
|
||||
let start_utc = start.with_timezone(&chrono::Utc);
|
||||
let minutes_until_start = (start_utc - now).num_minutes();
|
||||
|
||||
// Already ended?
|
||||
if let Some(end_str) = end_str {
|
||||
if let Ok(end) = chrono::DateTime::parse_from_rfc3339(end_str) {
|
||||
if end.with_timezone(&chrono::Utc) < now {
|
||||
tracing::debug!(
|
||||
start = %start_str,
|
||||
end = %end_str,
|
||||
"[meet:calendar] meeting already ended"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Starting within the window or already started?
|
||||
let imminent = minutes_until_start <= IMMINENT_WINDOW_MINUTES;
|
||||
tracing::debug!(
|
||||
start = %start_str,
|
||||
minutes_until_start = minutes_until_start,
|
||||
imminent = imminent,
|
||||
"[meet:calendar] meeting start check"
|
||||
);
|
||||
return imminent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No parseable start time — fail-open so we don't silently drop.
|
||||
tracing::debug!("[meet:calendar] no start time found in payload, treating as imminent");
|
||||
true
|
||||
}
|
||||
|
||||
/// Supported meeting URL host patterns. A string is considered a meeting
|
||||
/// link when it contains any of these substrings.
|
||||
const MEETING_HOST_PATTERNS: &[&str] = &[
|
||||
"meet.google.com",
|
||||
"zoom.us",
|
||||
"teams.microsoft.com",
|
||||
"webex.com",
|
||||
];
|
||||
|
||||
fn is_meeting_url(s: &str) -> bool {
|
||||
MEETING_HOST_PATTERNS.iter().any(|pat| s.contains(pat))
|
||||
}
|
||||
|
||||
/// Extract a meeting URL from a Composio Google Calendar trigger payload.
|
||||
///
|
||||
/// Supports Google Meet, Zoom, Teams, and Webex links. Searches:
|
||||
/// - `hangoutLink` (top level or inside `data`)
|
||||
/// - `conferenceData.entryPoints[].uri`
|
||||
/// - `location` field (Zoom/Teams links are often placed here)
|
||||
/// - recursive fallback across all string values
|
||||
fn extract_meet_url(payload: &serde_json::Value) -> Option<String> {
|
||||
for root in [payload, payload.get("data").unwrap_or(payload)] {
|
||||
// hangoutLink (Google Meet)
|
||||
if let Some(link) = root.get("hangoutLink").and_then(|v| v.as_str()) {
|
||||
if is_meeting_url(link) {
|
||||
return Some(link.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// conferenceData.entryPoints[].uri
|
||||
if let Some(entries) = root
|
||||
.get("conferenceData")
|
||||
.and_then(|cd| cd.get("entryPoints"))
|
||||
.and_then(|ep| ep.as_array())
|
||||
{
|
||||
for entry in entries {
|
||||
if let Some(uri) = entry.get("uri").and_then(|v| v.as_str()) {
|
||||
if is_meeting_url(uri) {
|
||||
return Some(uri.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// location field (Zoom/Teams links are often pasted here)
|
||||
if let Some(loc) = root.get("location").and_then(|v| v.as_str()) {
|
||||
if is_meeting_url(loc) {
|
||||
return Some(loc.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan all string values for any meeting URL.
|
||||
find_meet_url_recursive(payload)
|
||||
}
|
||||
|
||||
fn find_meet_url_recursive(val: &serde_json::Value) -> Option<String> {
|
||||
match val {
|
||||
serde_json::Value::String(s) if is_meeting_url(s) => Some(s.clone()),
|
||||
serde_json::Value::Object(map) => {
|
||||
for v in map.values() {
|
||||
if let Some(url) = find_meet_url_recursive(v) {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
for v in arr {
|
||||
if let Some(url) = find_meet_url_recursive(v) {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-join a meeting via the backend Socket.IO connection.
|
||||
async fn auto_join_meeting(
|
||||
meet_url: String,
|
||||
event_title: String,
|
||||
correlation_id: String,
|
||||
listen_only: bool,
|
||||
) {
|
||||
use crate::openhuman::socket::global_socket_manager;
|
||||
use serde_json::json;
|
||||
|
||||
let mgr = match global_socket_manager() {
|
||||
Some(mgr) if mgr.is_connected() => mgr,
|
||||
_ => {
|
||||
tracing::warn!("[meet:calendar] cannot auto-join: socket not connected to backend");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let payload = json!({
|
||||
"meetUrl": meet_url,
|
||||
"displayName": "OpenHuman",
|
||||
"correlationId": correlation_id,
|
||||
"listenOnly": listen_only,
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
meet_url = %meet_url,
|
||||
title = %event_title,
|
||||
correlation_id = %correlation_id,
|
||||
listen_only = listen_only,
|
||||
"[meet:calendar] emitting bot:join"
|
||||
);
|
||||
|
||||
if let Err(e) = mgr.emit("bot:join", payload).await {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"[meet:calendar] failed to emit bot:join for auto-join"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn extracts_hangout_link() {
|
||||
let payload = json!({
|
||||
"summary": "Standup",
|
||||
"hangoutLink": "https://meet.google.com/abc-defg-hij"
|
||||
});
|
||||
assert_eq!(
|
||||
extract_meet_url(&payload).as_deref(),
|
||||
Some("https://meet.google.com/abc-defg-hij")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_nested_hangout_link() {
|
||||
let payload = json!({
|
||||
"data": {
|
||||
"summary": "Standup",
|
||||
"hangoutLink": "https://meet.google.com/xyz-abcd-efg"
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
extract_meet_url(&payload).as_deref(),
|
||||
Some("https://meet.google.com/xyz-abcd-efg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_from_conference_data() {
|
||||
let payload = json!({
|
||||
"conferenceData": {
|
||||
"entryPoints": [
|
||||
{ "entryPointType": "video", "uri": "https://meet.google.com/abc-defg-hij" },
|
||||
{ "entryPointType": "phone", "uri": "tel:+1234567890" }
|
||||
]
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
extract_meet_url(&payload).as_deref(),
|
||||
Some("https://meet.google.com/abc-defg-hij")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_meet_link() {
|
||||
let payload = json!({
|
||||
"summary": "Lunch",
|
||||
"location": "Office kitchen"
|
||||
});
|
||||
assert!(extract_meet_url(&payload).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imminent_meeting_starting_in_5_minutes() {
|
||||
let start = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
|
||||
let end = (chrono::Utc::now() + chrono::Duration::minutes(35)).to_rfc3339();
|
||||
let payload = json!({
|
||||
"start": { "dateTime": start },
|
||||
"end": { "dateTime": end },
|
||||
});
|
||||
assert!(is_meeting_imminent(&payload));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_imminent_meeting_starting_in_2_hours() {
|
||||
let start = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339();
|
||||
let end = (chrono::Utc::now() + chrono::Duration::hours(3)).to_rfc3339();
|
||||
let payload = json!({
|
||||
"start": { "dateTime": start },
|
||||
"end": { "dateTime": end },
|
||||
});
|
||||
assert!(!is_meeting_imminent(&payload));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imminent_meeting_already_started() {
|
||||
let start = (chrono::Utc::now() - chrono::Duration::minutes(5)).to_rfc3339();
|
||||
let end = (chrono::Utc::now() + chrono::Duration::minutes(25)).to_rfc3339();
|
||||
let payload = json!({
|
||||
"start": { "dateTime": start },
|
||||
"end": { "dateTime": end },
|
||||
});
|
||||
assert!(is_meeting_imminent(&payload));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_imminent_meeting_already_ended() {
|
||||
let start = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339();
|
||||
let end = (chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
|
||||
let payload = json!({
|
||||
"start": { "dateTime": start },
|
||||
"end": { "dateTime": end },
|
||||
});
|
||||
assert!(!is_meeting_imminent(&payload));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imminent_when_no_start_time_fail_open() {
|
||||
let payload = json!({ "summary": "Meeting" });
|
||||
assert!(is_meeting_imminent(&payload));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imminent_nested_data_start_time() {
|
||||
let start = (chrono::Utc::now() + chrono::Duration::minutes(3)).to_rfc3339();
|
||||
let payload = json!({
|
||||
"data": {
|
||||
"start": { "dateTime": start },
|
||||
}
|
||||
});
|
||||
assert!(is_meeting_imminent(&payload));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_deeply_nested_meet_url() {
|
||||
let payload = json!({
|
||||
"data": {
|
||||
"nested": {
|
||||
"deep": {
|
||||
"url": "https://meet.google.com/deep-nest-url"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
extract_meet_url(&payload).as_deref(),
|
||||
Some("https://meet.google.com/deep-nest-url")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_zoom_from_location() {
|
||||
let payload = json!({
|
||||
"summary": "Team sync",
|
||||
"location": "https://zoom.us/j/123456789"
|
||||
});
|
||||
assert_eq!(
|
||||
extract_meet_url(&payload).as_deref(),
|
||||
Some("https://zoom.us/j/123456789")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_teams_from_conference_data() {
|
||||
let payload = json!({
|
||||
"conferenceData": {
|
||||
"entryPoints": [
|
||||
{ "entryPointType": "video", "uri": "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")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_webex_recursively() {
|
||||
let payload = json!({
|
||||
"data": {
|
||||
"info": {
|
||||
"link": "https://meet.webex.com/meet/abc"
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
extract_meet_url(&payload).as_deref(),
|
||||
Some("https://meet.webex.com/meet/abc")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
//! - [`schemas`] — controller schema + registered handler wrappers
|
||||
//! - [`store`] — SQLite persistence for meeting sessions
|
||||
|
||||
pub mod bus;
|
||||
pub mod calendar;
|
||||
pub mod ops;
|
||||
pub mod schemas;
|
||||
pub mod store;
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::types::{
|
||||
BackendMeetHarnessResponseRequest, BackendMeetJoinRequest, BackendMeetJoinResponse,
|
||||
BackendMeetLeaveRequest,
|
||||
BackendMeetLeaveRequest, BackendMeetSpeakRequest,
|
||||
};
|
||||
|
||||
const ALLOWED_HOSTS: &[(&str, &str)] = &[
|
||||
@@ -75,6 +75,7 @@ fn transcript_turns_to_chat_batch(
|
||||
pub async fn ingest_backend_meeting_transcript(
|
||||
turns: Vec<BackendMeetTurn>,
|
||||
duration_ms: u64,
|
||||
correlation_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let Some(batch) = transcript_turns_to_chat_batch(&turns, duration_ms) else {
|
||||
tracing::debug!("[agent_meetings] transcript had no ingestible turns");
|
||||
@@ -84,7 +85,12 @@ pub async fn ingest_backend_meeting_transcript(
|
||||
let config = crate::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.map_err(|e| format!("[agent_meetings] config load failed: {e}"))?;
|
||||
let source_id = format!("meet:recall:{}", chrono::Utc::now().timestamp_millis());
|
||||
let cid_suffix = correlation_id.as_deref().unwrap_or("none");
|
||||
let source_id = format!(
|
||||
"meet:recall:{}:{}",
|
||||
chrono::Utc::now().timestamp_millis(),
|
||||
cid_suffix
|
||||
);
|
||||
let tags = vec!["meeting".to_string(), "recall_ai".to_string()];
|
||||
let result = ingest_pipeline::ingest_chat(&config, &source_id, "user", tags, batch)
|
||||
.await
|
||||
@@ -93,8 +99,100 @@ pub async fn ingest_backend_meeting_transcript(
|
||||
tracing::info!(
|
||||
source_id = %source_id,
|
||||
chunks_written = result.chunks_written,
|
||||
correlation_id = ?correlation_id,
|
||||
"[agent_meetings] transcript ingested into memory tree"
|
||||
);
|
||||
|
||||
// Create a meeting thread with the transcript for the thread system.
|
||||
if let Err(e) = create_meeting_thread_with_transcript(&turns, duration_ms, correlation_id).await
|
||||
{
|
||||
tracing::warn!("[agent_meetings] meeting thread creation failed: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a conversation thread labelled "Meetings" containing the transcript.
|
||||
///
|
||||
/// The correlation_id (when present) is embedded in the transcript body as an
|
||||
/// external reference for tracing — it does not deduplicate; each call creates
|
||||
/// a new thread.
|
||||
pub async fn create_meeting_thread_with_transcript(
|
||||
turns: &[BackendMeetTurn],
|
||||
duration_ms: u64,
|
||||
correlation_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
use crate::openhuman::memory::{
|
||||
AppendConversationMessageRequest, ConversationMessageRecord,
|
||||
CreateConversationThreadRequest,
|
||||
};
|
||||
use crate::openhuman::threads::ops;
|
||||
|
||||
if turns.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Format transcript body.
|
||||
let mut body = String::new();
|
||||
let duration_min = duration_ms / 60_000;
|
||||
body.push_str(&format!("Duration: {duration_min} min\n\n"));
|
||||
if let Some(cid) = &correlation_id {
|
||||
body.push_str(&format!("Correlation ID: {cid}\n\n"));
|
||||
}
|
||||
for turn in turns {
|
||||
let text = turn.content.trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let role_label = if turn.role.eq_ignore_ascii_case("assistant") {
|
||||
"Assistant"
|
||||
} else {
|
||||
"Participant"
|
||||
};
|
||||
body.push_str(&format!("**{role_label}**: {text}\n\n"));
|
||||
}
|
||||
|
||||
// 1. Create thread with labels: ["Meetings"]
|
||||
let create_req = CreateConversationThreadRequest {
|
||||
labels: Some(vec!["Meetings".to_string()]),
|
||||
personality_id: None,
|
||||
};
|
||||
let outcome = ops::thread_create_new(create_req)
|
||||
.await
|
||||
.map_err(|e| format!("[agent_meetings] thread creation failed: {e}"))?;
|
||||
let thread_id = outcome
|
||||
.value
|
||||
.data
|
||||
.as_ref()
|
||||
.ok_or_else(|| "[agent_meetings] thread creation returned no data".to_string())?
|
||||
.id
|
||||
.clone();
|
||||
|
||||
// 2. Append the transcript as a message.
|
||||
let msg = ConversationMessageRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
content: body,
|
||||
message_type: "system".to_string(),
|
||||
extra_metadata: serde_json::Value::Null,
|
||||
sender: "system".to_string(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let append_req = AppendConversationMessageRequest {
|
||||
thread_id: thread_id.clone(),
|
||||
message: msg,
|
||||
};
|
||||
if let Err(e) = ops::message_append(append_req).await {
|
||||
tracing::warn!(
|
||||
thread_id = %thread_id,
|
||||
"[agent_meetings] failed to append transcript message: {e}"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
thread_id = %thread_id,
|
||||
turn_count = turns.len(),
|
||||
"[agent_meetings] meeting thread created"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -175,6 +273,12 @@ fn build_join_payload(
|
||||
if let Some(phrase) = &req.wake_phrase {
|
||||
map.insert("wakePhrase".to_string(), json!(phrase));
|
||||
}
|
||||
if let Some(cid) = &req.correlation_id {
|
||||
map.insert("correlationId".to_string(), json!(cid));
|
||||
}
|
||||
if let Some(lo) = req.listen_only {
|
||||
map.insert("listenOnly".to_string(), json!(lo));
|
||||
}
|
||||
}
|
||||
payload
|
||||
}
|
||||
@@ -288,6 +392,43 @@ pub async fn handle_harness_response(params: Map<String, Value>) -> Result<Value
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
/// Handle `openhuman.agent_meetings_speak`.
|
||||
pub async fn handle_speak(params: Map<String, Value>) -> Result<Value, String> {
|
||||
let req: BackendMeetSpeakRequest = serde_json::from_value(Value::Object(params))
|
||||
.map_err(|e| format!("[agent_meetings] invalid speak request: {e}"))?;
|
||||
|
||||
if req.text.trim().is_empty() {
|
||||
return Err("[agent_meetings] text must not be empty".to_string());
|
||||
}
|
||||
|
||||
let mgr = global_socket_manager()
|
||||
.ok_or_else(|| "[agent_meetings] socket not connected to backend".to_string())?;
|
||||
|
||||
if !mgr.is_connected() {
|
||||
return Err("[agent_meetings] socket not connected to backend".to_string());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
text_len = req.text.len(),
|
||||
correlation_id = ?req.correlation_id,
|
||||
"[agent_meetings] emitting bot:speak"
|
||||
);
|
||||
|
||||
let mut speak_payload = json!({ "text": req.text });
|
||||
if let Some(map) = speak_payload.as_object_mut() {
|
||||
if let Some(cid) = &req.correlation_id {
|
||||
map.insert("correlationId".to_string(), json!(cid));
|
||||
}
|
||||
}
|
||||
|
||||
mgr.emit("bot:speak", speak_payload)
|
||||
.await
|
||||
.map_err(|e| format!("[agent_meetings] emit failed: {e}"))?;
|
||||
|
||||
let outcome = RpcOutcome::new(json!({ "ok": true }), vec![]);
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -484,6 +625,63 @@ mod tests {
|
||||
assert!(req.rive_colors.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_join_payload_with_correlation_id() {
|
||||
let req: BackendMeetJoinRequest = serde_json::from_value(json!({
|
||||
"meet_url": "https://meet.google.com/abc-defg-hij",
|
||||
"correlation_id": "meeting-123"
|
||||
}))
|
||||
.unwrap();
|
||||
let payload = build_join_payload(
|
||||
"https://meet.google.com/abc-defg-hij",
|
||||
"OpenHuman",
|
||||
"gmeet",
|
||||
&req,
|
||||
);
|
||||
assert_eq!(payload["correlationId"], "meeting-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_join_payload_with_listen_only() {
|
||||
let req: BackendMeetJoinRequest = serde_json::from_value(json!({
|
||||
"meet_url": "https://meet.google.com/abc-defg-hij",
|
||||
"listen_only": true
|
||||
}))
|
||||
.unwrap();
|
||||
let payload = build_join_payload(
|
||||
"https://meet.google.com/abc-defg-hij",
|
||||
"OpenHuman",
|
||||
"gmeet",
|
||||
&req,
|
||||
);
|
||||
assert_eq!(payload["listenOnly"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_join_payload_correlation_and_listen_only_absent_by_default() {
|
||||
let req = minimal_req("https://meet.google.com/abc-defg-hij");
|
||||
let payload = build_join_payload(
|
||||
"https://meet.google.com/abc-defg-hij",
|
||||
"OpenHuman",
|
||||
"gmeet",
|
||||
&req,
|
||||
);
|
||||
assert!(payload.get("correlationId").is_none());
|
||||
assert!(payload.get("listenOnly").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_request_correlation_and_listen_only_deserialize() {
|
||||
let req: BackendMeetJoinRequest = serde_json::from_value(json!({
|
||||
"meet_url": "https://meet.google.com/abc-defg-hij",
|
||||
"correlation_id": "sess-456",
|
||||
"listen_only": true
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(req.correlation_id.as_deref(), Some("sess-456"));
|
||||
assert_eq!(req.listen_only, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_turns_empty_returns_none() {
|
||||
let result = transcript_turns_to_chat_batch(&[], 1_000);
|
||||
|
||||
@@ -31,6 +31,11 @@ const DEFS: &[BackendMeetControllerDef] = &[
|
||||
schema: schema_harness_response,
|
||||
handler: handle_harness_response_wrap,
|
||||
},
|
||||
BackendMeetControllerDef {
|
||||
function: "speak",
|
||||
schema: schema_speak,
|
||||
handler: handle_speak_wrap,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
@@ -111,6 +116,18 @@ fn schema_join() -> ControllerSchema {
|
||||
The phrase is stripped before the text reaches the LLM.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "correlation_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Opaque correlation id echoed on all bot:* events for this session.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "listen_only",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "When true, the bot joins in listen-only mode (no microphone, no replies).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
@@ -188,6 +205,39 @@ fn handle_harness_response_wrap(params: Map<String, Value>) -> ControllerFuture
|
||||
Box::pin(async move { super::ops::handle_harness_response(params).await })
|
||||
}
|
||||
|
||||
fn schema_speak() -> ControllerSchema {
|
||||
ControllerSchema {
|
||||
namespace: "agent_meetings",
|
||||
function: "speak",
|
||||
description: "Send text to the meeting bot for TTS playback. The backend converts \
|
||||
the text to speech and plays it into the meeting audio.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "text",
|
||||
ty: TypeSchema::String,
|
||||
comment: "The text to speak in the meeting.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "correlation_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Optional correlation id to associate with this speak request.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "ok",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when the speak request was emitted.",
|
||||
required: true,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_speak_wrap(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { super::ops::handle_speak(params).await })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -203,7 +253,10 @@ mod tests {
|
||||
.map(|c| c.schema.function)
|
||||
.collect();
|
||||
assert_eq!(schema_fns, handler_fns);
|
||||
assert_eq!(schema_fns, vec!["join", "leave", "harness_response"]);
|
||||
assert_eq!(
|
||||
schema_fns,
|
||||
vec!["join", "leave", "harness_response", "speak"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -116,10 +116,10 @@ pub struct BackendMeetJoinRequest {
|
||||
/// Wake phrase the participant must say before the bot responds.
|
||||
#[serde(default)]
|
||||
pub wake_phrase: Option<String>,
|
||||
/// Correlation ID linking this join to a `MeetingSession`.
|
||||
/// Opaque correlation id echoed on all `bot:*` events for this session.
|
||||
#[serde(default)]
|
||||
pub correlation_id: Option<String>,
|
||||
/// Join in listen-only mode (mic muted, no bot replies).
|
||||
/// When `true`, the bot joins in listen-only mode (no microphone, no replies).
|
||||
#[serde(default)]
|
||||
pub listen_only: Option<bool>,
|
||||
}
|
||||
@@ -145,6 +145,14 @@ pub struct BackendMeetHarnessResponseRequest {
|
||||
pub result: String,
|
||||
}
|
||||
|
||||
/// Inputs to `openhuman.agent_meetings_speak`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BackendMeetSpeakRequest {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub correlation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -89,6 +89,8 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
|
||||
);
|
||||
crate::openhuman::memory::sync::register_sync_stage_bridge(&config);
|
||||
crate::openhuman::composio::register_composio_trigger_subscriber();
|
||||
crate::openhuman::agent_meetings::calendar::register_meet_calendar_subscriber();
|
||||
crate::openhuman::agent_meetings::bus::register_meeting_event_subscriber();
|
||||
// Surface parked ApprovalGate requests as chat messages so the user can
|
||||
// answer yes/no in the thread (chat-native approval, issue #1339).
|
||||
crate::openhuman::channels::providers::web::register_approval_surface_subscriber();
|
||||
|
||||
@@ -255,8 +255,15 @@ pub(super) fn handle_sio_event(
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let correlation_id = data
|
||||
.get("correlationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
log::info!("[socket] bot:joined meet_url_len={}", meet_url.len());
|
||||
publish_global(DomainEvent::BackendMeetJoined { meet_url });
|
||||
publish_global(DomainEvent::BackendMeetJoined {
|
||||
meet_url,
|
||||
correlation_id,
|
||||
});
|
||||
}
|
||||
"bot:left" => {
|
||||
let reason = data
|
||||
@@ -264,8 +271,15 @@ pub(super) fn handle_sio_event(
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let correlation_id = data
|
||||
.get("correlationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
log::info!("[socket] bot:left reason={}", reason);
|
||||
publish_global(DomainEvent::BackendMeetLeft { reason });
|
||||
publish_global(DomainEvent::BackendMeetLeft {
|
||||
reason,
|
||||
correlation_id,
|
||||
});
|
||||
}
|
||||
"bot:reply" => {
|
||||
let transcript = data
|
||||
@@ -283,6 +297,10 @@ pub(super) fn handle_sio_event(
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("neutral")
|
||||
.to_string();
|
||||
let correlation_id = data
|
||||
.get("correlationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
log::info!(
|
||||
"[socket] bot:reply reply_len={} emotion={}",
|
||||
reply.len(),
|
||||
@@ -292,6 +310,7 @@ pub(super) fn handle_sio_event(
|
||||
transcript,
|
||||
reply,
|
||||
emotion,
|
||||
correlation_id,
|
||||
});
|
||||
}
|
||||
"bot:harness" => {
|
||||
@@ -310,6 +329,10 @@ pub(super) fn handle_sio_event(
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("neutral")
|
||||
.to_string();
|
||||
let correlation_id = data
|
||||
.get("correlationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
log::info!(
|
||||
"[socket] bot:harness instruction_len={} emotion={}",
|
||||
instruction.len(),
|
||||
@@ -319,6 +342,7 @@ pub(super) fn handle_sio_event(
|
||||
transcript,
|
||||
instruction,
|
||||
emotion,
|
||||
correlation_id,
|
||||
});
|
||||
}
|
||||
"bot:transcript" => {
|
||||
@@ -327,38 +351,58 @@ pub(super) fn handle_sio_event(
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let duration_ms = data.get("durationMs").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let correlation_id = data
|
||||
.get("correlationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
log::info!(
|
||||
"[socket] bot:transcript turns={} duration_ms={}",
|
||||
turns.len(),
|
||||
duration_ms
|
||||
);
|
||||
// Thread creation + memory ingest are handled by the
|
||||
// MeetingEventSubscriber (agent_meetings/bus.rs) reacting to
|
||||
// this event — no inline spawn needed.
|
||||
publish_global(DomainEvent::BackendMeetTranscript {
|
||||
turns: turns.clone(),
|
||||
turns,
|
||||
duration_ms,
|
||||
correlation_id,
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
// Only ingest into memory when the user has opted in via
|
||||
// config.meet.ingest_backend_transcripts (default: false).
|
||||
let enabled = crate::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.map(|c| c.meet.ingest_backend_transcripts)
|
||||
.unwrap_or(false);
|
||||
if !enabled {
|
||||
tracing::debug!(
|
||||
"[socket] bot:transcript memory ingest skipped \
|
||||
(config.meet.ingest_backend_transcripts = false)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::openhuman::agent_meetings::ops::ingest_backend_meeting_transcript(
|
||||
turns,
|
||||
duration_ms,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("[socket] bot:transcript memory ingest failed: {e}");
|
||||
}
|
||||
}
|
||||
"bot:in_call_request" => {
|
||||
let correlation_id = data
|
||||
.get("correlationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let speaker = data
|
||||
.get("speaker")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let command_text = data
|
||||
.get("commandText")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let recent_transcript: Vec<BackendMeetTurn> = data
|
||||
.get("recentTranscript")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let timestamp_ms = data
|
||||
.get("timestampMs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
log::info!(
|
||||
"[socket] bot:in_call_request speaker={} cmd_len={}",
|
||||
speaker,
|
||||
command_text.len()
|
||||
);
|
||||
publish_global(DomainEvent::BackendMeetInCallRequest {
|
||||
correlation_id,
|
||||
speaker,
|
||||
command_text,
|
||||
recent_transcript,
|
||||
timestamp_ms,
|
||||
});
|
||||
}
|
||||
"bot:error" => {
|
||||
@@ -367,8 +411,15 @@ pub(super) fn handle_sio_event(
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown error")
|
||||
.to_string();
|
||||
let correlation_id = data
|
||||
.get("correlationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
log::error!("[socket] bot:error: {}", error);
|
||||
publish_global(DomainEvent::BackendMeetError { error });
|
||||
publish_global(DomainEvent::BackendMeetError {
|
||||
error,
|
||||
correlation_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Channel inbound message — publish to event bus for ChannelInboundSubscriber
|
||||
|
||||
Reference in New Issue
Block a user