311 lines
9 KiB
TypeScript
311 lines
9 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import PlayIcon from "@material-ui/icons/PlayCircleFilled";
|
|
import {
|
|
TextInput,
|
|
SelectInput,
|
|
required,
|
|
useTranslate,
|
|
useNotify,
|
|
ReferenceInput,
|
|
ReferenceField,
|
|
TextField,
|
|
} from "react-admin";
|
|
import { IconButton, CircularProgress } from "@material-ui/core";
|
|
import absoluteUrl from "../../../lib/absolute-url";
|
|
import TwilioLanguages from "./twilio-languages";
|
|
|
|
type TTSProvider = (voice: any, language: any, prompt: any) => Promise<void>;
|
|
|
|
const tts = async (providerId: any): Promise<TTSProvider> => {
|
|
const r = await fetch(
|
|
`/api/v1/voice/twilio/text-to-speech-token/${providerId}`
|
|
);
|
|
const { token } = await r.json();
|
|
const twilioClient = await import("twilio-client");
|
|
return (voice, language, prompt): Promise<void> =>
|
|
new Promise((resolve) => {
|
|
if (!voice || !language || !prompt) resolve();
|
|
const Device = twilioClient.Device;
|
|
const device = new Device();
|
|
const silence = `${absoluteUrl().origin}/static/silence.mp3`;
|
|
device.setup(token, {
|
|
codecPrefences: ["opus", "pcmu"],
|
|
enableRingingState: false,
|
|
fakeLocalDTMF: true,
|
|
disableAudioContextSounds: true,
|
|
sounds: {
|
|
disconnect: silence,
|
|
incoming: silence,
|
|
outgoing: silence,
|
|
},
|
|
});
|
|
device.on("ready", function (device: any) {
|
|
device.connect({ language, voice, prompt });
|
|
});
|
|
device.on("disconnect", () => resolve());
|
|
device.on("error", () => resolve());
|
|
});
|
|
};
|
|
|
|
export const TextToSpeechButton = ({ form }: any) => {
|
|
const { providerId, language, voice, promptText: prompt } = form.formData;
|
|
const [loading, setLoading] = useState<boolean>(false);
|
|
const [ttsProvider, setTTSProvider] = useState<
|
|
undefined | { provider: TTSProvider }
|
|
>(undefined);
|
|
const [playText, setPlayText] = useState<
|
|
undefined | { func: () => Promise<void> }
|
|
>(undefined);
|
|
useEffect(() => {
|
|
(async () => {
|
|
if (providerId) {
|
|
setLoading(true);
|
|
setTTSProvider({ provider: await tts(providerId) });
|
|
setLoading(false);
|
|
}
|
|
})();
|
|
}, [providerId]);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
setPlayText({
|
|
func: async () => {
|
|
setLoading(true);
|
|
if (ttsProvider) await ttsProvider.provider(voice, language, prompt);
|
|
setLoading(false);
|
|
},
|
|
});
|
|
})();
|
|
}, [prompt, language, voice, ttsProvider?.provider]);
|
|
|
|
const disabled = !(providerId && prompt?.length >= 2 && voice && language);
|
|
/* TODO add this back to IconButtonwhen we know how to extend MUI theme and appease typescript
|
|
variant="contained"
|
|
*/
|
|
return (
|
|
<IconButton onClick={playText?.func} disabled={disabled} color="primary">
|
|
{!loading && <PlayIcon />}
|
|
{loading && <CircularProgress size={20} />}
|
|
</IconButton>
|
|
);
|
|
};
|
|
|
|
export const PromptInput = (form: any, ...rest: any[]) => {
|
|
return (
|
|
<TextInput
|
|
source="promptText"
|
|
multiline
|
|
options={{ fullWidth: true }}
|
|
InputProps={{ endAdornment: <TextToSpeechButton form={form} /> }}
|
|
{...rest}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const validateVoice = (args: any, values: any) => {
|
|
if (!values.language) return "validation.language";
|
|
if (!values.voice) return "validation.voice";
|
|
// @ts-expect-error
|
|
const availableVoices = TwilioLanguages.voices[values.language];
|
|
const found =
|
|
availableVoices.filter((v: any) => v.id === values.voice).length === 1;
|
|
if (!found) return "validation.voice";
|
|
|
|
return undefined;
|
|
};
|
|
|
|
export const VoiceInput = (form: any, ...rest: any[]) => {
|
|
// @ts-expect-error
|
|
const voice = TwilioLanguages.voices[form.formData.language] || [];
|
|
return (
|
|
// @ts-expect-error
|
|
<SelectInput
|
|
source="voice"
|
|
choices={voice}
|
|
validate={[required(), validateVoice]}
|
|
{...rest}
|
|
/>
|
|
);
|
|
};
|
|
|
|
let noAvailableNumbers = false;
|
|
let availableNumbers: any[] = [];
|
|
|
|
const getAvailableNumbers = async (providerId: string) => {
|
|
try {
|
|
const r = await fetch(`/api/v1/voice/providers/${providerId}/freeNumbers`);
|
|
availableNumbers = await r.json();
|
|
noAvailableNumbers = availableNumbers.length === 0;
|
|
return availableNumbers;
|
|
} catch (error) {
|
|
console.error(
|
|
`Could not fetch available numbers for provider ${providerId} - ${error}`
|
|
);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const sidToNumber = (sid: any) => {
|
|
return availableNumbers
|
|
.filter(({ id }) => id === sid)
|
|
.map(({ name }) => name)[0];
|
|
};
|
|
|
|
export const populateNumber = (data: any) => {
|
|
return {
|
|
...data,
|
|
number: sidToNumber(data.providerLineSid),
|
|
};
|
|
};
|
|
|
|
const hasNumbers = (
|
|
args: any,
|
|
value: any,
|
|
values: any,
|
|
translate: any,
|
|
...props: any[]
|
|
) => {
|
|
if (noAvailableNumbers) return "validation.noAvailableNumbers";
|
|
|
|
return undefined;
|
|
};
|
|
|
|
export const AvailableNumbersInput = (form: any, ...rest: any[]) => {
|
|
const {
|
|
// @ts-expect-error: non-existent property
|
|
meta: { touched, error } = {},
|
|
// @ts-expect-error: non-existent property
|
|
input: { ...inputProps },
|
|
...props
|
|
} = rest;
|
|
const translate = useTranslate();
|
|
const notify = useNotify();
|
|
const [loading, setLoading] = useState(false);
|
|
const [choices, setChoices] = useState({});
|
|
// @ts-expect-error: Invalid return type
|
|
useEffect(async () => {
|
|
if (form && form.formData && form.formData.providerId) {
|
|
setLoading(true);
|
|
const choices = await getAvailableNumbers(form.formData.providerId);
|
|
setChoices({
|
|
choices,
|
|
helperText: noAvailableNumbers
|
|
? translate("validation.noAvailableNumbers")
|
|
: "",
|
|
});
|
|
if (noAvailableNumbers)
|
|
notify("validation.noAvailableNumbers", { type: "error" });
|
|
setLoading(false);
|
|
}
|
|
}, [form && form.formData ? form.formData.providerId : undefined]);
|
|
|
|
return (
|
|
<>
|
|
<SelectInput
|
|
label="Number"
|
|
source="providerLineSid"
|
|
// @ts-expect-error: non-existent property
|
|
choices={choices.choices}
|
|
disabled={loading}
|
|
validate={[hasNumbers, required()]}
|
|
// @ts-expect-error: non-existent property
|
|
error={Boolean(touched && error) || Boolean(choices.helperText)}
|
|
// @ts-expect-error: non-existent property
|
|
helperText={choices.helperText}
|
|
{...inputProps}
|
|
{...props}
|
|
/>
|
|
{loading && <CircularProgress />}
|
|
</>
|
|
);
|
|
};
|
|
|
|
/*
|
|
const voiceLineName = voiceLine => {
|
|
return voiceLine.number
|
|
}
|
|
const getVoiceLineChoices = async ():Promise<any[]> => {
|
|
try {
|
|
const r = await fetch(`/api/v1/voice/voice-line`);
|
|
const lines = await r.json();
|
|
if(lines.data?.length > 0) {
|
|
return lines.data.map(voiceLine => ({"id": voiceLine.id, "name": voiceLineName(voiceLine)}))
|
|
}
|
|
return [];
|
|
} catch (error) {
|
|
console.error(
|
|
`Could not fetch voice lines error: ${error}`
|
|
);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export const AsyncSelectInput = (choiceLoader: () => Promise<any[]>, label, source, translationEmpty,) => (form, ...rest) => {
|
|
const {
|
|
meta: { touched, error } = {},
|
|
input: { ...inputProps },
|
|
...props
|
|
} = rest;
|
|
|
|
const translate = useTranslate();
|
|
const notify = useNotify();
|
|
const [loading, setLoading] = useState(false);
|
|
const [choices, setChoices] = useState({choices: []});
|
|
useEffect(() => {
|
|
(async () => {
|
|
setLoading(true);
|
|
//const items = await choiceLoader()
|
|
const items = [{"id": "testing", "name": "OMG"}]
|
|
setChoices({
|
|
choices: items,
|
|
helperText: items.length === 0
|
|
? translate(translationEmpty)
|
|
: "",
|
|
});
|
|
if (items.length === 0) notify(translationEmpty, "error");
|
|
setLoading(false);
|
|
})()}, [form && form.formData ? form.formData.providerId : undefined]);
|
|
|
|
const isNotEmpty = () => {
|
|
if (choices.choices.length === 0) return translationEmpty;
|
|
return undefined;
|
|
};
|
|
return (
|
|
<>
|
|
{choices.choices.length > 0 &&
|
|
<SelectInput
|
|
label={label}
|
|
source={source}
|
|
choices={choices.choices}
|
|
disabled={loading}
|
|
validate={[isNotEmpty, required()]}
|
|
error={Boolean(touched && error) || Boolean(choices.helperText)}
|
|
helperText={choices.helperText}
|
|
{...inputProps}
|
|
{...props}
|
|
/>}
|
|
{loading && <CircularProgress />}
|
|
</>
|
|
)
|
|
}
|
|
export const VoiceLineSelectInput = AsyncSelectInput(getVoiceLineChoices, "Voice Line", "backendId", "validation.noVoiceLines" )
|
|
*/
|
|
|
|
export const VoiceLineSelectInput = (source: string) => () =>
|
|
(
|
|
<ReferenceInput
|
|
label="Voice Line"
|
|
source={source}
|
|
reference="voiceLines"
|
|
validate={[required()]}
|
|
>
|
|
<SelectInput optionText="number" />
|
|
</ReferenceInput>
|
|
);
|
|
|
|
export const VoiceLineField = (source: string) => () =>
|
|
(
|
|
<ReferenceField label="Voice Line" source={source} reference="voiceLines">
|
|
<TextField source="number" />
|
|
</ReferenceField>
|
|
);
|