Files
jambonz-feature-server/lib/utils/dtmf-input.js
T
Hoan Luu HuuandClaude Opus 5.5 287883c148 feat(elevenlabs): forward DTMF to the agent and support llm:update (#1592)
* 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>
2026-10-11 21:23:11 -04:00

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;