diff --git a/app/src/components/skills/MeetingBotsCard.tsx b/app/src/components/skills/MeetingBotsCard.tsx index 9f20fcbf05..c69ce0dd5d 100644 --- a/app/src/components/skills/MeetingBotsCard.tsx +++ b/app/src/components/skills/MeetingBotsCard.tsx @@ -3,6 +3,7 @@ import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } fro import { type MascotFace, RiveMascot } from '../../features/human/Mascot'; import { useT } from '../../lib/i18n/I18nContext'; import { + isCapacityGateMessage, joinMeetViaBackendBot, leaveBackendMeetBot, listMeetCalls, @@ -260,7 +261,12 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps) if (!hasSubmittedRef.current) return; if (meetStatus === 'error') { hasSubmittedRef.current = false; - const message = meetError?.trim() || t('skills.meetingBots.failedToStart'); + const raw = meetError?.trim() || t('skills.meetingBots.failedToStart'); + // A capacity-gate error carries the backend's terse "…try again later." + // wording; show the tailored, actionable (and localized) copy instead (#4151). + const message = isCapacityGateMessage(raw) + ? t('skills.meetingBots.serverOverloaded') + : raw; setError(message); setSubmitting(false); onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message }); @@ -288,7 +294,10 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps) listenOnly, }); } catch (err) { - const message = err instanceof Error ? err.message : t('skills.meetingBots.failedToStart'); + const raw = err instanceof Error ? err.message : t('skills.meetingBots.failedToStart'); + const message = isCapacityGateMessage(raw) + ? t('skills.meetingBots.serverOverloaded') + : raw; setError(message); setSubmitting(false); hasSubmittedRef.current = false; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index e423c5dec7..cdd0566a58 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -5033,6 +5033,8 @@ const messages: TranslationMap = { 'skills.meetingBots.platforms.teams': 'Microsoft Teams', 'skills.meetingBots.platforms.zoom': 'تكبير', 'skills.meetingBots.sendTo': 'إرسال إلى', + 'skills.meetingBots.serverOverloaded': + 'يشهد OpenHuman ضغطًا كبيرًا في الوقت الحالي. يُرجى المحاولة مرة أخرى بعد بضع دقائق.', 'skills.meetingBots.soonSuffix': 'قريبًا', 'skills.meetingBots.starting': 'جارٍ البدء…', 'skills.meetingBots.recentCallsAriaLabel': 'مكالمات الاجتماعات الأخيرة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index d78c723df6..00a2bb0161 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -5136,6 +5136,8 @@ const messages: TranslationMap = { 'skills.meetingBots.platforms.teams': 'মাইক্রোসফট টিম', 'skills.meetingBots.platforms.zoom': 'জুম', 'skills.meetingBots.sendTo': 'পাঠান', + 'skills.meetingBots.serverOverloaded': + 'OpenHuman এই মুহূর্তে অত্যধিক চাপের মধ্যে রয়েছে। অনুগ্রহ করে কয়েক মিনিট পরে আবার চেষ্টা করুন।', 'skills.meetingBots.soonSuffix': 'শীঘ্রই', 'skills.meetingBots.starting': 'শুরু হচ্ছে…', 'skills.meetingBots.recentCallsAriaLabel': 'সাম্প্রতিক মিটিং কল', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 9f8c94d95a..cd2271232d 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -5267,6 +5267,8 @@ const messages: TranslationMap = { 'skills.meetingBots.platforms.teams': 'Microsoft Teams', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Senden an', + 'skills.meetingBots.serverOverloaded': + 'OpenHuman ist gerade stark ausgelastet. Bitte versuche es in ein paar Minuten erneut.', 'skills.meetingBots.soonSuffix': 'bald', 'skills.meetingBots.starting': 'Beginnend mit …', 'skills.meetingBots.recentCallsAriaLabel': 'Letzte Meeting-Anrufe', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 7f643d22d5..c8cc0944ee 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -5783,6 +5783,8 @@ const en: TranslationMap = { 'skills.meetingBots.platforms.teams': 'Microsoft Teams', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Send to {label}', + 'skills.meetingBots.serverOverloaded': + 'OpenHuman is under heavy load right now. Please try again in a few minutes.', 'skills.meetingBots.soonSuffix': 'soon', 'skills.meetingBots.starting': 'Starting…', 'skills.meetingBots.recentCallsAriaLabel': 'Recent meeting calls', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 54d425ab87..16d93e083f 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -5231,6 +5231,8 @@ const messages: TranslationMap = { 'skills.meetingBots.platforms.teams': 'Equipos de Microsoft', 'skills.meetingBots.platforms.zoom': 'Ampliar', 'skills.meetingBots.sendTo': 'Enviar a', + 'skills.meetingBots.serverOverloaded': + 'OpenHuman tiene una carga muy alta en este momento. Inténtalo de nuevo en unos minutos.', 'skills.meetingBots.soonSuffix': 'pronto', 'skills.meetingBots.starting': 'Iniciando…', 'skills.meetingBots.recentCallsAriaLabel': 'Llamadas de reunión recientes', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 79da3501b5..954d809867 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -5252,6 +5252,8 @@ const messages: TranslationMap = { 'skills.meetingBots.platforms.teams': 'Microsoft Teams', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Envoyer à', + 'skills.meetingBots.serverOverloaded': + 'OpenHuman est actuellement très sollicité. Veuillez réessayer dans quelques minutes.', 'skills.meetingBots.soonSuffix': 'bientôt', 'skills.meetingBots.starting': 'Démarrage…', 'skills.meetingBots.recentCallsAriaLabel': 'Appels de réunion récents', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index d760158c4d..8c760b5cfe 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -5139,6 +5139,8 @@ const messages: TranslationMap = { 'skills.meetingBots.platforms.teams': 'माइक्रोसॉफ्ट टीमें', 'skills.meetingBots.platforms.zoom': 'ज़ूम करें', 'skills.meetingBots.sendTo': 'भेजें', + 'skills.meetingBots.serverOverloaded': + 'OpenHuman अभी भारी लोड में है। कृपया कुछ मिनटों में फिर से प्रयास करें।', 'skills.meetingBots.soonSuffix': 'जल्द ही', 'skills.meetingBots.starting': 'शुरू हो रहा है…', 'skills.meetingBots.recentCallsAriaLabel': 'हाल की मीटिंग कॉल', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 0318f0604f..409091f0a6 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -5154,6 +5154,8 @@ const messages: TranslationMap = { 'skills.meetingBots.platforms.teams': 'Microsoft Teams', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Kirim ke', + 'skills.meetingBots.serverOverloaded': + 'OpenHuman sedang menerima beban tinggi saat ini. Silakan coba lagi dalam beberapa menit.', 'skills.meetingBots.soonSuffix': 'segera', 'skills.meetingBots.starting': 'Memulai...', 'skills.meetingBots.recentCallsAriaLabel': 'Panggilan rapat terbaru', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 9aafd277af..6890038368 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -5220,6 +5220,8 @@ const messages: TranslationMap = { 'skills.meetingBots.platforms.teams': 'Microsoft Teams', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Invia a', + 'skills.meetingBots.serverOverloaded': + 'OpenHuman è sottoposto a un carico elevato in questo momento. Riprova tra qualche minuto.', 'skills.meetingBots.soonSuffix': 'presto', 'skills.meetingBots.starting': 'Avvio…', 'skills.meetingBots.recentCallsAriaLabel': 'Chiamate recenti delle riunioni', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index c64bcae8d5..e787c158c8 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -5089,6 +5089,8 @@ const messages: TranslationMap = { 'skills.meetingBots.platforms.teams': 'Microsoft Teams', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': '보내기', + 'skills.meetingBots.serverOverloaded': + '현재 OpenHuman의 부하가 매우 높습니다. 몇 분 후에 다시 시도해 주세요.', 'skills.meetingBots.soonSuffix': '곧', 'skills.meetingBots.starting': '시작 중…', 'skills.meetingBots.recentCallsAriaLabel': '최근 회의 통화', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 09087fa9e5..720fb8c38d 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -5208,6 +5208,8 @@ const messages: TranslationMap = { 'skills.meetingBots.platforms.teams': 'Microsoft Teams', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Wyślij do', + 'skills.meetingBots.serverOverloaded': + 'OpenHuman jest teraz mocno obciążony. Spróbuj ponownie za kilka minut.', 'skills.meetingBots.soonSuffix': 'wkrótce', 'skills.meetingBots.starting': 'Uruchamianie…', 'skills.meetingBots.recentCallsAriaLabel': 'Ostatnie rozmowy na spotkaniach', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 00e4c583eb..52c428d680 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -5223,6 +5223,8 @@ const messages: TranslationMap = { 'skills.meetingBots.platforms.teams': 'Microsoft Teams', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Enviar para', + 'skills.meetingBots.serverOverloaded': + 'O OpenHuman está sob carga intensa no momento. Tente novamente em alguns minutos.', 'skills.meetingBots.soonSuffix': 'em breve', 'skills.meetingBots.starting': 'Iniciando…', 'skills.meetingBots.recentCallsAriaLabel': 'Chamadas de reunião recentes', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 55d2f418b1..854c3af840 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -5182,6 +5182,8 @@ const messages: TranslationMap = { 'skills.meetingBots.platforms.teams': 'Microsoft Teams', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Отправить', + 'skills.meetingBots.serverOverloaded': + 'OpenHuman сейчас сильно загружен. Пожалуйста, повторите попытку через несколько минут.', 'skills.meetingBots.soonSuffix': 'скоро', 'skills.meetingBots.starting': 'Запуск…', 'skills.meetingBots.recentCallsAriaLabel': 'Недавние звонки на встречах', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 68c058d038..1600cd63f3 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4883,6 +4883,7 @@ const messages: TranslationMap = { 'skills.meetingBots.platforms.teams': '微软团队', 'skills.meetingBots.platforms.zoom': '变焦', 'skills.meetingBots.sendTo': '发送到会议', + 'skills.meetingBots.serverOverloaded': 'OpenHuman 当前负载过高,请几分钟后重试。', 'skills.meetingBots.soonSuffix': '很快', 'skills.meetingBots.starting': '启动中…', 'skills.meetingBots.recentCallsAriaLabel': '最近的会议通话', diff --git a/app/src/services/__tests__/joinMeetingViaMascotBot.test.ts b/app/src/services/__tests__/joinMeetingViaMascotBot.test.ts index aa444564c7..1ef9d844fa 100644 --- a/app/src/services/__tests__/joinMeetingViaMascotBot.test.ts +++ b/app/src/services/__tests__/joinMeetingViaMascotBot.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + isCapacityGateMessage, joinMeetingViaMascotBot, type MascotJoinMeetingError, SERVER_OVERLOADED_MESSAGE, @@ -49,8 +50,14 @@ describe('joinMeetingViaMascotBot', () => { }); }); - it('flags SERVER_OVERLOADED responses with isCapacityGated=true', async () => { - postMock.mockRejectedValueOnce({ success: false, error: SERVER_OVERLOADED_MESSAGE }); + it('flags the real backend SERVER_OVERLOADED wording and shows the tailored copy (#4151)', async () => { + // The VERBATIM backend message (backend src/utils/paidPlan.ts) — NOT the + // frontend's friendly constant. Mocking the real wire string is what makes + // this test catch the prior exact-equality mismatch that leaked the generic + // "…try again later." text to users. + const BACKEND_CAPACITY_MESSAGE = + 'Mascot streaming capacity is exhausted. Please try again later.'; + postMock.mockRejectedValueOnce({ success: false, error: BACKEND_CAPACITY_MESSAGE }); let caught: MascotJoinMeetingError | undefined; try { await joinMeetingViaMascotBot({ platform: 'gmeet', meetUrl: 'https://meet.google.com/abc' }); @@ -58,6 +65,7 @@ describe('joinMeetingViaMascotBot', () => { caught = e as MascotJoinMeetingError; } expect(caught?.isCapacityGated).toBe(true); + // The tailored, actionable copy is surfaced — not the raw backend string. expect(caught?.message).toBe(SERVER_OVERLOADED_MESSAGE); }); @@ -75,3 +83,21 @@ describe('joinMeetingViaMascotBot', () => { ).rejects.toMatchObject({ isCapacityGated: false, message: 'network down' }); }); }); + +describe('isCapacityGateMessage', () => { + it('matches the real backend capacity wording (and case/wording drift)', () => { + expect( + isCapacityGateMessage('Mascot streaming capacity is exhausted. Please try again later.') + ).toBe(true); + expect(isCapacityGateMessage('MASCOT STREAMING CAPACITY IS EXHAUSTED.')).toBe(true); + expect(isCapacityGateMessage('Streaming capacity reached — retry soon')).toBe(true); + }); + + it('does not match unrelated errors or empty input', () => { + expect(isCapacityGateMessage('Bad Request')).toBe(false); + expect(isCapacityGateMessage('network down')).toBe(false); + expect(isCapacityGateMessage('')).toBe(false); + expect(isCapacityGateMessage(null)).toBe(false); + expect(isCapacityGateMessage(undefined)).toBe(false); + }); +}); diff --git a/app/src/services/meetCallService.ts b/app/src/services/meetCallService.ts index 24115d7a72..c68cd3a61d 100644 --- a/app/src/services/meetCallService.ts +++ b/app/src/services/meetCallService.ts @@ -334,13 +334,34 @@ export interface MascotJoinMeetingResult { } /** - * The 429 capacity-gate message the backend emits for free users. Treated - * as the canonical user-facing copy so the UI can show a tailored notice - * without leaking the underlying paid-plan rule. + * Tailored, actionable user-facing copy shown when the backend's capacity gate + * trips — replaces the backend's terse "…Please try again later." with retry + * guidance, without leaking the underlying paid-plan rule. */ export const SERVER_OVERLOADED_MESSAGE = 'OpenHuman is under heavy load right now. Please try again in a few minutes.'; +/** + * Recognize the backend's free-user capacity-gate response (`SERVER_OVERLOADED`, + * backend `paidPlan.ts` → `"Mascot streaming capacity is exhausted. Please try + * again later."`). + * + * The shared `apiClient` drops `errorCode` from error bodies (`apiClient.ts` + * only forwards `error` + `message`), so the message text is the only signal + * that survives. Detection therefore MUST key on the backend wording — it used + * to be compared for exact equality against [`SERVER_OVERLOADED_MESSAGE`], but + * that constant was changed to friendlier copy and no longer matches the + * backend string, so the check silently never fired and the raw generic + * "…try again later." leaked to the user instead of the tailored notice + * (#4151). Match a stable substring, case-insensitively, so minor wording drift + * on either side still resolves to the actionable message. + */ +export function isCapacityGateMessage(text: string | null | undefined): boolean { + if (!text) return false; + const t = text.toLowerCase(); + return t.includes('streaming capacity') || t.includes('capacity is exhausted'); +} + export interface MascotJoinMeetingError { /** User-safe error text. Falls back to a generic message. */ message: string; @@ -379,8 +400,13 @@ export async function joinMeetingViaMascotBot( : err instanceof Error ? err.message : 'Failed to start meeting bot.'; - const isCapacityGated = text === SERVER_OVERLOADED_MESSAGE; - const wrapped: MascotJoinMeetingError = { message: text, isCapacityGated }; + const isCapacityGated = isCapacityGateMessage(text); + // When capacity-gated, surface the tailored, actionable copy instead of the + // backend's raw "…try again later." string (#4151). + const wrapped: MascotJoinMeetingError = { + message: isCapacityGated ? SERVER_OVERLOADED_MESSAGE : text, + isCapacityGated, + }; throw wrapped; } }