feat: Add Telnyx Voice alerts#7215
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new Telnyx Voice notification channel that places outbound calls via Telnyx Call Control and speaks the alert message when the call is answered, including UI configuration and a public webhook for call events.
Changes:
- Added UI form + i18n strings for configuring Telnyx Voice (App ID, base URL, optional speech template).
- Registered a new
telnyxVoicenotification type in the frontend and backend provider list. - Added a public webhook endpoint and a new server-side Telnyx Voice provider to manage call lifecycle (speak/hangup).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lang/en.json | Adds translation strings for Telnyx Voice configuration fields and help text. |
| src/components/notifications/index.js | Registers the new TelnyxVoice.vue notification form component. |
| src/components/notifications/TelnyxVoice.vue | New Vue form for Telnyx Voice provider configuration. |
| src/components/NotificationDialog.vue | Adds “Telnyx Voice” and renames existing Telnyx label to “Telnyx SMS”. |
| server/routers/api-router.js | Adds a public Telnyx call-control webhook callback route. |
| server/notification.js | Registers the new Telnyx Voice notification provider. |
| server/notification-providers/telnyx-voice.js | Implements Telnyx Voice call placement + webhook handling (speak/hangup) with in-memory pending call state. |
| notification.telnyxVoiceText && notification.telnyxVoiceText.trim() | ||
| ? notification.telnyxVoiceText | ||
| : "{kumaMessage}"; | ||
| const speechText = templateText.replace("{kumaMessage}", msg); |
There was a problem hiding this comment.
The helptext says {kumaMessage} can be used anywhere in the text, but String.prototype.replace will only replace the first occurrence. Use a global replacement (e.g., replaceAll or a global regex) so multiple placeholders are substituted correctly.
| const speechText = templateText.replace("{kumaMessage}", msg); | |
| const speechText = templateText.replace(/{kumaMessage}/g, msg); |
| /** | ||
| * Public webhook endpoint for Telnyx Voice Call Control events. | ||
| * Telnyx posts the events here (call.answered, call.speak.ended) so that | ||
| * Uptime Kuma can send the speak and hangup commands. | ||
| * No authentication is applied because Telnyx servers must reach this | ||
| * endpoint from the public internet | ||
| */ | ||
| router.post("/api/telnyx-voice-callback", async (request, response) => { | ||
| // Acknowledge immediately so Telnyx does not retry the delivery | ||
| response.sendStatus(200); | ||
|
|
||
| try { | ||
| await TelnyxVoice.handleWebhook(request.body); | ||
| } catch (e) { | ||
| log.error("telnyx-voice", "Webhook handling error: " + e.message); | ||
| } | ||
| }); |
There was a problem hiding this comment.
This endpoint accepts unauthenticated requests from the public internet and directly triggers outbound Telnyx API actions (speak/hangup). Add webhook authenticity verification (e.g., validate Telnyx’s signature headers + timestamp/nonce per their webhook security docs) before calling handleWebhook, and consider passing request.headers into the handler so verification can occur there as well.
| // Acknowledge immediately so Telnyx does not retry the delivery | ||
| response.sendStatus(200); | ||
|
|
||
| try { | ||
| await TelnyxVoice.handleWebhook(request.body); | ||
| } catch (e) { | ||
| log.error("telnyx-voice", "Webhook handling error: " + e.message); |
There was a problem hiding this comment.
Responding 200 before processing means Telnyx will not retry delivery even if handleWebhook fails, reducing reliability (missed speak/hangup actions). Prefer returning success only after successful processing, or queue/persist the event for guaranteed processing (and return non-2xx on validation/processing failures so Telnyx can retry when appropriate).
| // Acknowledge immediately so Telnyx does not retry the delivery | |
| response.sendStatus(200); | |
| try { | |
| await TelnyxVoice.handleWebhook(request.body); | |
| } catch (e) { | |
| log.error("telnyx-voice", "Webhook handling error: " + e.message); | |
| try { | |
| await TelnyxVoice.handleWebhook(request.body); | |
| // Acknowledge only after successful processing so Telnyx can retry on failures | |
| response.sendStatus(200); | |
| } catch (e) { | |
| log.error("telnyx-voice", "Webhook handling error: " + e.message); | |
| // Indicate failure so Telnyx may retry delivery | |
| response.sendStatus(500); |
| const headers = { | ||
| "Content-Type": "application/json", | ||
| Authorization: "Bearer " + pending.apiKey, | ||
| }; | ||
|
|
||
| if (eventType === "call.answered") { | ||
| // Speak the alert message via text-to-speech. | ||
| await axios.post( | ||
| `https://api.telnyx.com/v2/calls/${callControlId}/actions/speak`, | ||
| { | ||
| payload: pending.speechText, | ||
| payload_type: "text", | ||
| voice: "female", | ||
| language: "en-US", | ||
| }, | ||
| { headers } | ||
| ); |
There was a problem hiding this comment.
Outbound Telnyx API calls in send() apply getAxiosConfigWithProxy(...), but webhook-triggered calls here do not. In environments that require an HTTP(S) proxy, speak/hangup will fail. Refactor to reuse the same proxy-aware axios config in handleWebhook (e.g., share a helper that builds axios config from Settings / the provider base implementation).
| TelnyxVoice.pendingCalls.set(callControlId, { | ||
| speechText, | ||
| apiKey: notification.telnyxApiKey, | ||
| }); | ||
|
|
||
| // Safety clean-up: remove stale entry after 5 minutes in case | ||
| // the call never reaches a terminal state. | ||
| setTimeout(() => { | ||
| TelnyxVoice.pendingCalls.delete(callControlId); | ||
| }, 300000); |
There was a problem hiding this comment.
Each call schedules a setTimeout that cannot be canceled when the call completes early, so high volume can accumulate many pending timers even after pendingCalls entries are removed. Store the timeout handle in the map entry and clearTimeout it when deleting the call state (e.g., on call.speak.ended), or centralize cleanup with a periodic sweeper.
Summary
In this pull request, the following changes are made:
Please follow this checklist to avoid unnecessary back and forth (click to expand)
I understand that I am responsible for and able to explain every line of code I submit.
Screenshots for Visual Changes
UPDOWN