mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-10-26 05:14:31 +00:00
* feat(elevenlabs): forward DTMF to the agent and support llm:update
Adds opt-in llmOptions.dtmf {enable, interDigitTimeout, finishOnKey}.
Digits are buffered until finishOnKey, the inter-digit timeout or 50
digits, then sent as a user_message with source_medium "dtmf". Each
key press also sends user_activity so the agent turn timeout does not
fire while the caller is typing.
llm:update now forwards user_message, user_activity and
contextual_update client events instead of ignoring them.
Client events escape "|" so payloads containing it are no longer
split by the api argument delimiter (this also fixes tool results).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(elevenlabs): send DTMF per digit like ultravox, drop llmOptions.dtmf
Match the ultravox adapter: DTMF is always forwarded, with no llmOptions
setting. Each digit is sent as soon as it arrives, as a user_message with
source_medium "dtmf". Digit buffering, the inter-digit timeout and
finishOnKey are left to the agent's dtmf_input_settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(elevenlabs): buffer DTMF behind llmOptions.dtmf again
Per-digit sending does not work for ElevenLabs. Over the agent
WebSocket each user_message becomes its own turn: "1234#" produced 5
turns, and the agent answered "Are you still there?". The same digits
sent as a single message produce one dtmf turn, "1234".
- restores the opt-in llmOptions.dtmf {enable, interDigitTimeout, finishOnKey}
- digits are buffered and sent as one user_message, source_medium "dtmf"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat: shared DtmfInput util with gather dtmf semantics
Any llm or agent vendor can turn caller DTMF into whole entries without
copying gather or touching it.
- lib/utils/dtmf-input.js uses gather options: finishOnKey, numDigits,
minDigits, maxDigits, interDigitTimeout, timeout (15s default, 0 = off)
- no invented defaults: no implicit "#", no 2s timeout, no 50-digit cap
- minDigits defaults to 1; in gather an unset minDigits never arms the
inter-digit timer
- stop() removes only its own dtmf listener
- elevenlabs uses it behind llmOptions.dtmf.enable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dtmf): review fixes for DtmfInput and elevenlabs
- inter-digit timer replaces the overall timeout while typing, as in gather
- warn when no option can ever complete an entry
- no DtmfInput when the session connects after kill (listener leak)
- llm:update rejects user_message/contextual_update without string text,
since an ElevenLabs error event ends the conversation
- unit tests use mock timers instead of wall-clock waits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
3.0 KiB
JavaScript
92 lines
3.0 KiB
JavaScript
/**
|
|
* Collects caller DTMF into whole entries for vendors that take keypad input as text.
|
|
* Options and termination rules follow the gather verb (seconds; timeout 15 by default, 0 = off).
|
|
*/
|
|
const DEFAULT_TIMEOUT = 15;
|
|
|
|
const seconds = (v) => (typeof v === 'number' && v >= 0 ? v : undefined);
|
|
|
|
class DtmfInput {
|
|
/**
|
|
* @param {object} logger - a pino logger
|
|
* @param {object} opts - gather-style dtmf options
|
|
* @param {object} handlers - onKey(digit) for every key press, onEntry(digits) for each completed entry
|
|
*/
|
|
constructor(logger, opts = {}, {onKey, onEntry}) {
|
|
this.logger = logger;
|
|
this.finishOnKey = opts.finishOnKey;
|
|
this.numDigits = opts.numDigits;
|
|
this.maxDigits = opts.maxDigits;
|
|
// gather never arms its inter-digit timer when minDigits is unset; 1 makes interDigitTimeout work on its own
|
|
this.minDigits = opts.minDigits || 1;
|
|
this.interDigitTimeout = (seconds(opts.interDigitTimeout) || 0) * 1000;
|
|
const timeout = seconds(opts.timeout);
|
|
this.timeout = (timeout === undefined ? DEFAULT_TIMEOUT : timeout) * 1000;
|
|
this.onKey = onKey;
|
|
this.onEntry = onEntry;
|
|
this.buffer = '';
|
|
if (!this.finishOnKey && !this.numDigits && !this.maxDigits && !this.interDigitTimeout && !this.timeout) {
|
|
this.logger.warn({opts}, 'DtmfInput: no option ever completes an entry, digits will never be sent');
|
|
}
|
|
}
|
|
|
|
start(ep) {
|
|
if (this._onDtmf) return;
|
|
this.ep = ep;
|
|
this._onDtmf = (evt) => this.process(evt.dtmf);
|
|
ep.on('dtmf', this._onDtmf);
|
|
}
|
|
|
|
stop() {
|
|
if (this._onDtmf) this.ep.removeListener('dtmf', this._onDtmf);
|
|
this._onDtmf = null;
|
|
this._clearTimers();
|
|
if (this.buffer) this.logger.info({digits: this.buffer}, 'DtmfInput: discarding an incomplete entry');
|
|
this.buffer = '';
|
|
}
|
|
|
|
process(digit) {
|
|
this.onKey?.(digit);
|
|
clearTimeout(this.interDigitTimer);
|
|
if (!this.timer && this.timeout > 0) {
|
|
this.timer = setTimeout(this._onTimeout.bind(this), this.timeout);
|
|
}
|
|
if (digit === this.finishOnKey) return this._complete();
|
|
|
|
this.buffer += digit;
|
|
const len = this.buffer.length;
|
|
if (len === this.numDigits || len === this.maxDigits) return this._complete();
|
|
if (this.interDigitTimeout > 0 && len >= this.minDigits) {
|
|
// as in gather, the inter-digit timer replaces the overall timeout while the caller keeps typing
|
|
clearTimeout(this.timer);
|
|
this.timer = null;
|
|
this.interDigitTimer = setTimeout(this._complete.bind(this), this.interDigitTimeout);
|
|
}
|
|
}
|
|
|
|
_onTimeout() {
|
|
if (this.buffer.length < this.minDigits) {
|
|
this.logger.info({digits: this.buffer}, 'DtmfInput: timeout before minDigits, discarding entry');
|
|
this._clearTimers();
|
|
this.buffer = '';
|
|
return;
|
|
}
|
|
this._complete();
|
|
}
|
|
|
|
_complete() {
|
|
this._clearTimers();
|
|
const digits = this.buffer;
|
|
this.buffer = '';
|
|
if (digits) this.onEntry(digits);
|
|
}
|
|
|
|
_clearTimers() {
|
|
clearTimeout(this.interDigitTimer);
|
|
clearTimeout(this.timer);
|
|
this.interDigitTimer = this.timer = null;
|
|
}
|
|
}
|
|
|
|
module.exports = DtmfInput;
|