|
/* |
|
Unofficial Async-Vox library by Mike Gorünóv, |
|
a sole developer not affiliated with Voximplant®. |
|
https://gist.github.com/Miha-x64/d068f8ae0cc7b0123eff1b466578adba |
|
|
|
Licensed under the Apache License, Version 2.0 (the "License"); |
|
you may not use this file except in compliance with the License. |
|
You may obtain a copy of the License at |
|
|
|
http://www.apache.org/licenses/LICENSE-2.0 |
|
|
|
Unless required by applicable law or agreed to in writing, software |
|
distributed under the License is distributed on an "AS IS" BASIS, |
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|
See the License for the specific language governing permissions and |
|
limitations under the License. |
|
*/ |
|
import '../voxengine'; |
|
|
|
/** |
|
* A cancelable Promise. |
|
*/ |
|
export interface Future<T> extends PromiseLike<T> { |
|
|
|
/** |
|
* Cancels this Future. |
|
* If in progress, it will be immediately rejected with {@link CanceledError}. |
|
*/ |
|
cancel(): void; |
|
|
|
/** |
|
* Enqueues a computation after this Future resolution. |
|
* Canceling the returned Future will lead to cancellation of the source Future, too. |
|
* @param mapRes a function called after resolution |
|
* @param mapRej a function called after rejection (including cancellation) |
|
* @return Future with return type of mapper |
|
*/ |
|
fMap<R1 = T, R2 = never>( |
|
mapRes?: (value: T) => PromiseLike<R1> | R1, |
|
mapRej?: (reason: any) => R2 | PromiseLike<R2>, |
|
): Future<R1 | R2>; |
|
|
|
/** |
|
* Enqueues a side-effect and ignores its return type. |
|
* @param source a function called after resolution |
|
*/ |
|
also(source: (value: T) => any): Future<T> |
|
|
|
/** |
|
* Adds an error handler. |
|
* @param type error type |
|
* @param mapRej mapper function |
|
*/ |
|
catching<E, R2 = void>( |
|
type: { new(...args: any[]): E }, mapRej?: (reason: E) => R2 | PromiseLike<R2> |
|
): Future<T | R2> |
|
|
|
/** |
|
* Current state of this Future. |
|
* true: fulfilled |
|
* false: rejected |
|
* undefined: in progress |
|
*/ |
|
state(): boolean | undefined |
|
|
|
/** |
|
* Adds this Future to a bag, so its owner could cancel it. |
|
*/ |
|
with(bag: DisposeBag): this |
|
} |
|
|
|
/** |
|
* Contains a number of Futures intended for cancellation propagation. |
|
*/ |
|
export interface DisposeBag {} |
|
|
|
export type CallConnectedEvent = [CallEvents.Connected, _ConnectedEvent]; |
|
export type CallPlaybackStartedEvent = [CallEvents.PlaybackStarted, _PlaybackStartedEvent]; |
|
export type CallPlaybackFinishedEvent = [CallEvents.PlaybackFinished, _PlaybackFinishedEvent]; |
|
export type CallToneReceivedEvent = [CallEvents.ToneReceived, _ToneReceivedEvent]; |
|
export type CallRecordStarted = [CallEvents.RecordStarted, _RecordStartedEvent] |
|
export type CallEvent = CallConnectedEvent |
|
| CallPlaybackStartedEvent |
|
| CallPlaybackFinishedEvent |
|
| CallToneReceivedEvent |
|
| CallRecordStarted; |
|
export type CallPlaybackEvent = CallPlaybackStartedEvent | CallPlaybackFinishedEvent |
|
|
|
declare global { |
|
/*extension*/ interface Call { |
|
|
|
// input |
|
|
|
/** |
|
* Create a Future that completes when the requested event happens. |
|
* The result is a tuple of event type and value. |
|
* @see CallEvent |
|
*/ |
|
when<EV extends keyof _CallEvents>(event: EV): Future<[EV, _CallEvents[EV]]>; |
|
|
|
/** |
|
* Create a Future which |
|
* gets fulfilled if this call is {@link CallEvents.Connected | connected}, |
|
* gets rejected with {@link CallFailedError} if the call is {@link CallEvents.Failed | failed}, |
|
* {@link Call.hangup | hangs the call up} if canceled. |
|
*/ |
|
connect(): Future<CallConnectedEvent> |
|
|
|
/** |
|
* Creates a Future which gets resolved when {@link CallEvents.ToneReceived | a DTMF tone is received}. |
|
* @param enable enable tone handling (pass false if handling tones is already enabled) |
|
* @param disable disable tone handling when received (pass false to leave it enabled) |
|
* @param allow a set of allowed tones (unlisted tones will be ignored) |
|
*/ |
|
receiveTone(enable: boolean, disable: boolean, allow: readonly string[]): Future<CallToneReceivedEvent> |
|
|
|
/** |
|
* Starts recording this call. The returned Future |
|
* gets fulfilled if the {@link CallEvents.RecordStarted | record is started}, |
|
* gets rejected if {@link CallEvents.RecordError | failed} before starting, |
|
* does nothing if canceled. |
|
* @param params VoxEngine recorder params |
|
*/ |
|
capture(params?: VoxEngine.RecorderParameters): Future<CallRecordStarted> |
|
|
|
// TODO detect voicemail, transfer, detect tone?.. |
|
|
|
// output |
|
|
|
/** |
|
* Plays sound and creates a Future which completes |
|
* — after the requested duration, if requested; |
|
* — after {@link CallEvents.PlaybackFinished | playback finished} event otherwise. |
|
*/ |
|
playSound(url: string, durationMillis?: number, progressive?: boolean): Future<CallPlaybackEvent>; |
|
|
|
/** |
|
* Says something to a call and creates a Future which completes |
|
* after {@link CallEvents.PlaybackFinished | playback finished} event. |
|
*/ |
|
speak(text: string, voice: VoiceList.Voice, options?: VoxEngine.TTSOptions): Future<CallPlaybackFinishedEvent> |
|
} |
|
} |
|
|
|
// TODO WS: receive message, maybe? |
|
|
|
export const Future = { |
|
/** |
|
* Creates a Future which completes after the requested timeout. |
|
*/ |
|
delay: function (millis: number): Future<undefined> { |
|
const future = _avf_new<undefined>(); |
|
// @ts-ignore |
|
const resolve = future.resolve as (value: undefined) => void; |
|
const id = setTimeout(resolve, millis); |
|
const cancel = future.cancel; |
|
future.cancel = () => { |
|
clearTimeout(id); |
|
cancel(); |
|
}; |
|
return future; |
|
}, |
|
|
|
/** |
|
* Creates a Future that resolves when any of passed Futures is resolved, |
|
* or rejected with error array when all of them are rejected. |
|
*/ |
|
any: function <T>(...futures: readonly Future<T>[]): Future<T> { |
|
return _avf_merge<T, T>(futures, _avf_Promise_any, true); |
|
}, |
|
|
|
/** |
|
* Creates a Future that resolves when all of passed Futures are resolved, |
|
* or rejected if any of passed Futures are rejected. |
|
*/ |
|
all: function <T>(...futures: readonly Future<T>[]): Future<T[]> { |
|
return _avf_merge<T, T[]>(futures, Promise.all, false); |
|
}, |
|
|
|
/** |
|
* Creates a Future that completes when any of passed Futures is completed. |
|
*/ |
|
race: function <T>(...futures: readonly Future<T>[]): Future<T> { |
|
return _avf_merge<T, T>(futures, Promise.race, true); |
|
}, |
|
|
|
/** |
|
* Creates a DisposeBag to pass around and a dispose function. |
|
*/ |
|
newBag: function (): [DisposeBag, () => void] { |
|
// noinspection JSMismatchedCollectionQueryUpdate: wat? I return it! |
|
const bag: Future<unknown>[] = []; |
|
return [bag as DisposeBag, _avf_cancelFuturesFunc(bag)]; |
|
}, |
|
}; |
|
|
|
export class CanceledError extends Error { |
|
cancellationSignal?: any |
|
|
|
constructor(cancellationSignal?: any) { |
|
super('' + cancellationSignal); |
|
this.cancellationSignal = cancellationSignal; |
|
} |
|
} |
|
|
|
export class CallError<EV extends _CallEvent> extends Error { |
|
event: EV |
|
constructor(event: EV, message: string) { |
|
super(message); |
|
this.event = event; |
|
} |
|
} |
|
export class CallFailedError extends CallError<_FailedEvent> { |
|
constructor(event: _FailedEvent) { |
|
super(event, `${event.code}: ${event.reason}`); |
|
} |
|
} |
|
export class CallRecordError extends CallError<_RecordErrorEvent> { |
|
constructor(event: _RecordErrorEvent) { |
|
super(event, event.error); |
|
} |
|
} |
|
|
|
//====================================================================================================================== |
|
|
|
const _AV_DEBUG = false; |
|
let _avf_futureCount = 0; |
|
|
|
function _avf_isFuture<T>(maybeFuture: PromiseLike<T> | T): maybeFuture is Future<T> { |
|
return maybeFuture instanceof Promise && |
|
// @ts-ignore |
|
typeof maybeFuture.cancel === 'function'; |
|
} |
|
|
|
const _avf_just = value => () => value; |
|
const _avf_undefined = _avf_just(undefined); |
|
const _avf_true = _avf_just(true); |
|
const _avf_false = _avf_just(false); |
|
|
|
function _avf_withFunctions<T>(future: Future<T>, debug?: string): Future<T> { |
|
future.fMap = <R1 = T, R2 = never>( |
|
mapRes?: (value: T) => PromiseLike<R1> | R1, |
|
mapRej?: (reason: any) => R2 | PromiseLike<R2>, |
|
) => { |
|
let mappedFuture: Future<R1 | R2> | undefined; |
|
// @ts-ignore |
|
const combined: Future<R1 | R2> = |
|
future.then(value => { |
|
let ret: any = value; |
|
if (mapRes && _avf_isFuture(ret = mapRes(value))) mappedFuture = ret; |
|
return ret; |
|
}, reason => { |
|
let err: any = reason; |
|
if (mapRej) { |
|
if (_avf_isFuture(err = mapRej(reason))) mappedFuture = err; |
|
return err; |
|
} |
|
throw err; |
|
}); |
|
combined.cancel = () => { |
|
future.cancel() |
|
if (mappedFuture) mappedFuture.cancel(); |
|
}; |
|
return _avf_withFunctions(combined, !_AV_DEBUG ? undefined |
|
// @ts-ignore |
|
: ` = ${future}.fMap(${mapRes.name || '<anonymous>'}, ${mapRej ? (mapRej.name || '<anonymous>') : undefined})`); |
|
}; |
|
future.also = (source: (value: T) => any) => { |
|
let value; |
|
return future.fMap(v => source(value = v)).fMap(() => value); |
|
}; |
|
future.catching = <E, R2 = void>(type: { new(...args: any[]): E }, mapRej?: (reason: E) => R2 | PromiseLike<R2>) => future |
|
.fMap(_avf_identity, e => { if (e instanceof type) return mapRej ? mapRej(e) : undefined; else throw e; }); |
|
future.state = () => { |
|
future.state = _avf_undefined; |
|
future.then(() => future.state = _avf_true, () => future.state = _avf_false); |
|
return future.state(); |
|
}; |
|
future.with = ((bag: Future<unknown>[]) => { |
|
// removeIf borrowed from https://stackoverflow.com/a/15996017/3050249 |
|
let i = bag.length; |
|
while (i--) { |
|
const f = bag[i]; |
|
if (f.state() !== undefined) |
|
bag.splice(i, 1); |
|
if (f === future) |
|
return future; |
|
} |
|
|
|
if (future.state() === undefined) |
|
bag.push(future); |
|
|
|
return future; |
|
}) as (DisposeBag) => Future<T>; |
|
if (_AV_DEBUG) { |
|
const id = ++_avf_futureCount; |
|
future.toString = () => `Future#${id}`; |
|
Logger.write(`create ${future}${debug} at ${Error().stack || "<no stack trace available>"}`); |
|
let canceled = false; |
|
future.then( |
|
res => Logger.write( |
|
`${future} resolved with ${res}` |
|
), |
|
err => Logger.write( |
|
`${future} ${canceled ? 'canceled' : 'rejected'} with ${err?.NAME || err?.name || err}` |
|
), |
|
); |
|
const cancel = future.cancel; |
|
future.cancel = () => { |
|
canceled = true; |
|
cancel(); |
|
}; |
|
} |
|
return future; |
|
} |
|
|
|
function _avf_new<T>(cancellationSignal?: any): Future<T> { |
|
let resolve: (value: T) => void, reject: (reason?: any) => void; |
|
// @ts-ignore |
|
const future: Future<T> = |
|
new Promise<T>((res, rej) => { resolve = res; reject = rej; }); |
|
// @ts-ignore |
|
future.resolve = resolve; future.reject = reject; // for private use only |
|
future.cancel = () => reject(new CanceledError(cancellationSignal)); |
|
return _avf_withFunctions(future, _AV_DEBUG ? `, cancellationSignal=${cancellationSignal}` : undefined); |
|
} |
|
|
|
const _avf_cancelFuture = (future: Future<unknown>) => future.cancel(); |
|
const _avf_cancelFuturesFunc = (futures: readonly Future<unknown>[]) => () => futures.forEach(_avf_cancelFuture); |
|
function _avf_merge<T, U>( |
|
futures: readonly Future<T>[], |
|
combine: (promises: readonly PromiseLike<T>[]) => Promise<U>, |
|
thenCancelSrc: boolean, |
|
): Future<U> { |
|
const cancel = _avf_cancelFuturesFunc(futures); |
|
// @ts-ignore |
|
const future: Future<U> = combine.call(Promise, futures); // We pass detached member functions of Promise here |
|
if (thenCancelSrc) _avf_finally(future, cancel); |
|
future.cancel = cancel; // ^^^^- cancel if fulfilled (useful both for `race` and `any`) and if rejected (for `race`) |
|
return _avf_withFunctions(future, !_AV_DEBUG ? undefined |
|
// @ts-ignore |
|
: ` = Promise.${combine.name}(${futures.join(', ')})`); |
|
} |
|
|
|
// borrowed from https://dev.to/sinxwal/looking-for-promise-any-let-s-quickly-implement-a-polyfill-for-it-1kga |
|
// and https://github.com/es-shims/Promise.any/blob/master/implementation.js |
|
const _avf_identity = obj => obj; |
|
const _avf_Promise_reject = e => Promise.reject(e); |
|
function _avf_Promise_any<T>(promises: readonly PromiseLike<T>[]): Promise<T> { |
|
return Promise |
|
.all(promises.map(promise => promise.then(_avf_Promise_reject, _avf_identity))) |
|
.then(_avf_Promise_reject, _avf_identity); |
|
} |
|
|
|
function _avf_finally<R>(future: Future<R>, cleanup: () => void): Future<R> { |
|
future.then(cleanup, cleanup); |
|
return future; |
|
} |
|
function _av_c2f<EV extends keyof _CallEvents, R>( |
|
call: Call, future: Future<R>, event: EV, callback: (e: _CallEvents[EV]) => void, |
|
): typeof future { |
|
call.addEventListener(event, callback); |
|
return _avf_finally(future, () => call.removeEventListener(event, callback)); |
|
} |
|
Call.prototype.when = function <EV extends keyof _CallEvents>(event: EV): Future<[EV, _CallEvents[EV]]> { |
|
const future = _avf_new<[EV, _CallEvents[EV]]>( |
|
// @ts-ignore | events are functions with a NAME property |
|
event.NAME || event); |
|
return _av_c2f<EV, [EV, _CallEvents[EV]]>( |
|
this, future, event, |
|
(e: _CallEvents[EV]) => // @ts-ignore |
|
(future.resolve as (value: [EV, _CallEvents[EV]]) => void) |
|
([event, e]), |
|
); |
|
}; |
|
Call.prototype.connect = function () { |
|
const future = this.when(CallEvents.Connected); |
|
return _av_c2f<CallEvents.Failed, CallConnectedEvent>( |
|
this, future, CallEvents.Failed, |
|
(e: _FailedEvent) => |
|
// @ts-ignore |
|
(future.reject as (a: any) => void) |
|
(new CallFailedError(e)) |
|
).catching(CanceledError, e => { this.hangup(); throw e; }); |
|
} |
|
const _AV_ALL_TONES = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#']; |
|
Call.prototype.receiveTone = function ( |
|
enable: boolean = true, |
|
disable: boolean = true, |
|
allow: readonly string[] = _AV_ALL_TONES, |
|
): Future<CallToneReceivedEvent> { |
|
if (enable) this.handleTones(true); |
|
const future = this.when(CallEvents.ToneReceived) |
|
.fMap((ev: CallToneReceivedEvent) => allow.includes(ev[1].tone) ? ev : this.receiveTone(false, false, allow)); |
|
return disable ? _avf_finally(future, () => this.handleTones(false)) : future; |
|
}; |
|
Call.prototype.capture = function (params?: VoxEngine.RecorderParameters): Future<CallRecordStarted> { |
|
// @ts-ignore: params are actually optional, trust me |
|
this.record(params); |
|
const future = this.when(CallEvents.RecordStarted); |
|
return _av_c2f<CallEvents.RecordError, CallRecordStarted>( |
|
this, future, CallEvents.RecordError, |
|
(e: _RecordErrorEvent) => // @ts-ignore |
|
(future.reject as (a: any) => void) |
|
(new CallRecordError(e)) |
|
); |
|
} |
|
|
|
Call.prototype.playSound = function ( |
|
url: string, durationMillis?: number, progressive: boolean = true, |
|
): Future<CallPlaybackEvent> { |
|
this.startPlayback(url, {loop: durationMillis !== undefined, progressivePlayback: progressive}); |
|
return durationMillis !== undefined |
|
? this.when(CallEvents.PlaybackStarted).also(() => Future.delay(durationMillis)) |
|
: this.when(CallEvents.PlaybackFinished); |
|
}; |
|
Call.prototype.speak = function ( |
|
text: string, voice: VoiceList.Voice, options?: VoxEngine.TTSOptions |
|
): Future<CallPlaybackFinishedEvent> { |
|
this.say(text, {language: voice, progressivePlayback: true, ttsOptions: options}); |
|
return this.when(CallEvents.PlaybackFinished); |
|
}; |