fix(meet): recognize backend capacity gate and surface an actionable message (#4221)

This commit is contained in:
Mega Mind
2026-06-28 21:28:01 -07:00
committed by GitHub
parent 4dd5126120
commit 3cc9e35d8f
17 changed files with 97 additions and 9 deletions
+11 -2
View File
@@ -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;
+2
View File
@@ -5047,6 +5047,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': 'مكالمات الاجتماعات الأخيرة',
+2
View File
@@ -5151,6 +5151,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': 'সাম্প্রতিক মিটিং কল',
+2
View File
@@ -5283,6 +5283,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',
+2
View File
@@ -5798,6 +5798,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',
+2
View File
@@ -5247,6 +5247,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',
+2
View File
@@ -5268,6 +5268,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',
+2
View File
@@ -5155,6 +5155,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': 'हाल की मीटिंग कॉल',
+2
View File
@@ -5169,6 +5169,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',
+2
View File
@@ -5236,6 +5236,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',
+2
View File
@@ -5103,6 +5103,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': '최근 회의 통화',
+2
View File
@@ -5223,6 +5223,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',
+2
View File
@@ -5239,6 +5239,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',
+2
View File
@@ -5198,6 +5198,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': 'Недавние звонки на встречах',
+1
View File
@@ -4897,6 +4897,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': '最近的会议通话',
@@ -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);
});
});
+31 -5
View File
@@ -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;
}
}