Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions twilio-voice-app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@ TWILIO_ACCOUNT_SID=ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
TWILIO_AUTH_TOKEN=your_auth_token
TWILIO_PHONE_NUMBER=+1234567890

# Publicly accessible URL (https://...) that Twilio can reach.
# When running locally use a tunneling tool such as ngrok and paste the HTTPS URL.
PUBLIC_SERVER_URL=https://your-ngrok-id.ngrok-free.app

# Optional: choose a Pollinations voice for TTS playback
POLLINATIONS_VOICE=nova

Expand Down
5 changes: 2 additions & 3 deletions twilio-voice-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ A lightweight Node + Twilio companion service that lets Unity call a phone numbe
```
Required variables:
- `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PHONE_NUMBER`
- `PUBLIC_SERVER_URL` – The **public** HTTPS URL that Twilio will call back (e.g. your ngrok tunnel).
- Optional: `POLLINATIONS_VOICE` to choose a different Pollinations voice preset.
3. Start the server:
```bash
Expand All @@ -40,10 +39,10 @@ A lightweight Node + Twilio companion service that lets Unity call a phone numbe
# Example using ngrok (server must already be running on PORT)
ngrok http 4000
```
5. Update `PUBLIC_SERVER_URL` in your `.env` with the HTTPS forwarding address printed by ngrok and restart the server if needed.

Visit [http://localhost:4000](http://localhost:4000) to load the dashboard, enter a phone number, and press **Call My Phone**. Answer the incoming call from your Twilio number to begin the voice chat.

> ℹ️ The server automatically infers its public URL from the incoming browser request when you press **Call My Phone**. Ensure you access the dashboard through the same public HTTPS address (for example, the ngrok URL) so Twilio receives a reachable callback URL.

## Twilio configuration tips

- Trial accounts can only call verified numbers. Add your mobile phone to the **Verified Caller IDs** page in the Twilio Console.
Expand Down
33 changes: 21 additions & 12 deletions twilio-voice-app/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ if (!fetchImpl) {
}

const PORT = process.env.PORT || 4000;
const PUBLIC_SERVER_URL = process.env.PUBLIC_SERVER_URL;
const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID;
const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN;
const TWILIO_PHONE_NUMBER = process.env.TWILIO_PHONE_NUMBER;
Expand All @@ -24,11 +23,8 @@ const hasTwilioCredentials =
if (!hasTwilioCredentials) {
console.warn('[WARN] Twilio credentials are not fully configured. API routes will return errors.');
}
if (!PUBLIC_SERVER_URL) {
console.warn('[WARN] PUBLIC_SERVER_URL is not set. Twilio callbacks will fail without a public URL.');
}

const app = express();
app.set('trust proxy', true);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

Expand Down Expand Up @@ -146,15 +142,15 @@ function buildVoiceResponse(session, twiml, promptMessage, gatherPrompt) {
return twiml;
}

async function startPhoneCall(session) {
async function startPhoneCall(session, baseUrl) {
if (!client) {
throw new Error('Twilio client is not configured.');
}
if (!PUBLIC_SERVER_URL) {
throw new Error('PUBLIC_SERVER_URL is not configured.');
if (!baseUrl) {
throw new Error('Unable to determine a public server URL from the request.');
}

const voiceUrl = new URL('/voice-response', PUBLIC_SERVER_URL);
const voiceUrl = new URL('/voice-response', baseUrl);
voiceUrl.searchParams.set('sessionId', session.id);

return client.calls.create({
Expand All @@ -165,6 +161,15 @@ async function startPhoneCall(session) {
});
}

function resolvePublicBaseUrl(req) {
const forwardedProto = req.headers['x-forwarded-proto'];
const forwardedHost = req.headers['x-forwarded-host'];
const protocol = forwardedProto ? forwardedProto.split(',')[0] : req.protocol;
const host = forwardedHost ? forwardedHost.split(',')[0] : req.get('host');
if (!protocol || !host) return null;
return `${protocol}://${host}`;
}

app.post('/api/start-call', async (req, res) => {
try {
const { phoneNumber, initialPrompt, voice } = req.body || {};
Expand All @@ -174,8 +179,12 @@ app.post('/api/start-call', async (req, res) => {
if (!client) {
return res.status(500).json({ error: 'Twilio credentials are missing on the server.' });
}
if (!PUBLIC_SERVER_URL) {
return res.status(500).json({ error: 'PUBLIC_SERVER_URL is not configured on the server.' });

const baseUrl = resolvePublicBaseUrl(req);
if (!baseUrl) {
return res.status(500).json({
error: 'Unable to infer the public URL from the request. Access the app via a public HTTPS address before starting a call.'
});
}

const session = createSession(phoneNumber.trim(), voice || DEFAULT_VOICE);
Expand All @@ -187,7 +196,7 @@ app.post('/api/start-call', async (req, res) => {
await fetchPollinationsResponse(session, 'Greet the caller briefly and ask how you can help.');
}

await startPhoneCall(session);
await startPhoneCall(session, baseUrl);

res.json({
sessionId: session.id,
Expand Down