feat(meetings): respondTo required + wake-phrase filtering UI (#3555)

This commit is contained in:
YellowSnnowmann
2026-06-09 13:31:37 -07:00
committed by GitHub
parent 2974605a46
commit 3f0d9f6ef8
34 changed files with 1316 additions and 82 deletions
+38 -6
View File
@@ -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();
});
});
+3 -1
View File
@@ -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': 'يجب أن يقول المشارك هذا قبل أن يرد البوت.',
+3 -1
View File
@@ -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': 'বট সাড়া দেওয়ার আগে অংশগ্রহণকারীকে এটি বলতে হবে।',
+3 -1
View File
@@ -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.',
+3 -1
View File
@@ -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.',
+3 -1
View File
@@ -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':
+3 -1
View File
@@ -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.',
+3 -1
View File
@@ -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': 'बोट के जवाब देने से पहले प्रतिभागी को यह कहना होगा।',
+3 -1
View File
@@ -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.',
+3 -1
View File
@@ -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 risponde 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.',
+3 -1
View File
@@ -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': '참가자가 봇이 응답하기 전에 이것을 말해야 합니다.',
+3 -1
View File
@@ -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.',
+3 -1
View File
@@ -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.',
+3 -1
View File
@@ -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': 'Участник должен произнести это, прежде чем бот ответит.',
+4 -1
View File
@@ -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': '参与者必须先说出此词,机器人才会回复。',
+7 -3
View File
@@ -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));
}
+6
View File
@@ -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;
+25 -9
View File
@@ -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();
+26 -4
View File
@@ -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);
+43 -4
View File
@@ -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",
}
}
+2
View File
@@ -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
View File
@@ -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);
}
+139
View File
@@ -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"][..]));
}
}
+549
View File
@@ -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")
);
}
}
+2
View File
@@ -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;
+200 -2
View File
@@ -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);
+54 -1
View File
@@ -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]
+10 -2
View File
@@ -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();
+78 -27
View File
@@ -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