222 lines
14 KiB
Python
222 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
import os
|
|
import json
|
|
import requests
|
|
import re
|
|
import base64
|
|
from flask import Flask, request, Response, jsonify
|
|
import logging
|
|
import logging.handlers
|
|
import sys
|
|
import argparse
|
|
from dotenv import load_dotenv
|
|
|
|
# --- Configuration Loading ---
|
|
def load_env_file(filepath):
|
|
if not os.path.exists(filepath): return False
|
|
print(f"INFO: Loading environment file: {filepath}")
|
|
try:
|
|
load_dotenv(dotenv_path=filepath, override=True); return True
|
|
except Exception as e: print(f"ERROR: Failed to load env file {filepath}: {e}"); return False
|
|
|
|
env_loaded = any(load_env_file(os.path.join(os.getcwd(), f)) for f in ['.env', 'env'])
|
|
if not env_loaded: print("INFO: No .env or env file found.")
|
|
|
|
# --- Global Configuration ---
|
|
LOG_LEVEL_DEFAULT = os.environ.get("LOG_LEVEL", "INFO").upper()
|
|
LOG_FILE_NAME_DEFAULT = os.environ.get("LOG_FILE_PATH", "webhook.log")
|
|
TELNYX_API_KEY_DEFAULT = os.environ.get("TELNYX_API_KEY", None)
|
|
TELNYX_API_BASE_URL = os.environ.get("TELNYX_API_BASE_URL", "https://api.telnyx.com/v2")
|
|
DEFAULT_TTS_VOICE_DEFAULT = os.environ.get("TELNYX_DEFAULT_TTS_VOICE", "female")
|
|
DEFAULT_TTS_LANGUAGE_DEFAULT = os.environ.get("TELNYX_DEFAULT_TTS_LANGUAGE", "en-US")
|
|
CLIENT_STATE_PREFIX_DEFAULT = os.environ.get("TELNYX_CLIENT_STATE_PREFIX", "app_state")
|
|
DTMF_TIMEOUT_SECONDS_DEFAULT = int(os.environ.get("DTMF_TIMEOUT_SECONDS", 10))
|
|
INBOUND_GREETING_DEFAULT = os.environ.get("INBOUND_GREETING", "Thank you for calling Wellnuo. We are processing your request. Goodbye.")
|
|
|
|
# --- Application Setup ---
|
|
app = Flask(__name__)
|
|
app_logger = logging.getLogger("TelnyxWebhookApp_Fixed")
|
|
|
|
# --- Helper Functions ---
|
|
def setup_logging(level, file_path, name="TelnyxWebhookApp_Fixed"):
|
|
global app_logger; app_logger = logging.getLogger(name)
|
|
numeric_level = getattr(logging, level.upper(), logging.INFO)
|
|
app_logger.setLevel(numeric_level); app_logger.propagate = False
|
|
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - [%(funcName)s:%(lineno)d] - %(message)s')
|
|
if app_logger.hasHandlers(): app_logger.handlers.clear()
|
|
console_handler = logging.StreamHandler(sys.stdout); console_handler.setFormatter(formatter); app_logger.addHandler(console_handler)
|
|
try:
|
|
file_handler = logging.handlers.RotatingFileHandler(file_path, maxBytes=10*1024*1024, backupCount=5, encoding='utf-8')
|
|
file_handler.setFormatter(formatter); app_logger.addHandler(file_handler)
|
|
app_logger.info(f"Logging configured. Level: {level}. File: {os.path.abspath(file_path)}")
|
|
except Exception as e: app_logger.error(f"Failed to config file logger at {file_path}: {e}")
|
|
|
|
def find_custom_header(headers, name):
|
|
if not headers: return None
|
|
for header in headers:
|
|
if header.get('name', '').lower() == name.lower(): return header.get('value')
|
|
return None
|
|
|
|
def encode_state(parts):
|
|
plain_state = "|".join(map(str, parts))
|
|
base64_state = base64.b64encode(plain_state.encode('utf-8')).decode('ascii')
|
|
app_logger.debug(f"Encoded state: '{plain_state}' -> '{base64_state}'")
|
|
return base64_state
|
|
|
|
def decode_state(b64_state):
|
|
if not b64_state: return []
|
|
try:
|
|
decoded_plain = base64.b64decode(b64_state).decode('utf-8')
|
|
parts = decoded_plain.split('|'); app_logger.debug(f"Decoded state: '{b64_state}' -> '{decoded_plain}' -> {parts}"); return parts
|
|
except Exception as e: app_logger.error(f"Failed to decode client_state '{b64_state}': {e}"); return []
|
|
|
|
def send_telnyx_command(action_path, params, api_key):
|
|
if not api_key: app_logger.error(f"CMDFAIL ('{action_path}'): API_KEY not set."); return None
|
|
ccid = params.get("call_control_id");
|
|
if not ccid: app_logger.error(f"CMDFAIL ('{action_path}'): call_control_id missing."); return None
|
|
endpoint = f"{TELNYX_API_BASE_URL}/calls/{ccid}/{action_path}";
|
|
body = {k: v for k, v in params.items() if k != 'call_control_id'}
|
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json"}
|
|
app_logger.info(f"SENDCMD ('{action_path}')"); app_logger.debug(f" Endpoint: POST {endpoint}"); app_logger.debug(f" JSON Payload: {json.dumps(body, indent=2)}")
|
|
try:
|
|
r = requests.post(endpoint, json=body, headers=headers, timeout=10)
|
|
r.raise_for_status()
|
|
app_logger.info(f"CMDOK ('{action_path}'): Telnyx accepted. Status: {r.status_code}"); return r.json()
|
|
except requests.exceptions.HTTPError as e:
|
|
app_logger.error(f"CMDFAIL ('{action_path}'): Telnyx rejected. Status: {e.response.status_code}")
|
|
try: app_logger.error(f" Telnyx Err Detail: {json.dumps(e.response.json(), indent=2)}")
|
|
except json.JSONDecodeError: app_logger.error(f" Raw Err Body: {e.response.text[:500]}")
|
|
except requests.exceptions.RequestException as e: app_logger.exception(f"CMDFAIL ('{action_path}'): Network error")
|
|
return None
|
|
|
|
# --- Webhook Route Handler ---
|
|
@app.route('/<path:webhook_path_received>', methods=['POST'])
|
|
def handle_telnyx_webhook(webhook_path_received):
|
|
global app_args
|
|
if f"/{webhook_path_received}" != app_args.webhook_path: app_logger.warning(f"REQ Unknown Path: '/{webhook_path_received}'"); return "Not found", 404
|
|
app_logger.info(f"REQ <<< Path: '/{webhook_path_received}', From: {request.remote_addr}")
|
|
try:
|
|
webhook_data = request.get_json(); app_logger.debug(f"REQ Payload Full: {json.dumps(webhook_data, indent=2)}")
|
|
data, payload = webhook_data.get('data', {}), webhook_data.get('data', {}).get('payload', {})
|
|
event_type, record_type, ccid = data.get('event_type'), data.get('record_type'), payload.get('call_control_id')
|
|
app_logger.info(f"EVENT '{event_type}' ({record_type})" + (f", CCID: {ccid}" if ccid else ""))
|
|
|
|
if record_type == 'message':
|
|
app_logger.info(f" -> SMS Event received. From: {payload.get('from',{}).get('phone_number')}, Text: '{payload.get('text','')}'")
|
|
return Response(status=204)
|
|
elif record_type != 'event':
|
|
app_logger.warning(f" Unknown Record Type '{record_type}'. Ignoring."); return Response(status=204)
|
|
|
|
b64_client_state = payload.get("client_state"); decoded_parts = decode_state(b64_client_state)
|
|
state_name = decoded_parts[0] if decoded_parts else None
|
|
if state_name: app_logger.info(f" State Name Received: '{state_name}'")
|
|
current_api_key = app_args.api_key
|
|
|
|
# --- State Machine Logic ---
|
|
if event_type == 'call.answered':
|
|
if payload.get('direction') == 'incoming':
|
|
app_logger.info(" -> Inbound call detected. Playing generic greeting and hanging up.")
|
|
next_state = encode_state(['INBOUND_GREETING_HUP'])
|
|
speak_params = {"payload": app_args.inbound_greeting, "voice": app_args.default_tts_voice, "language": app_args.default_tts_language, "call_control_id": ccid, "client_state": next_state}
|
|
send_telnyx_command("actions/speak", speak_params, current_api_key)
|
|
else: # Outgoing call
|
|
audio_url = find_custom_header(payload.get('custom_headers'), 'X-Audio-Url')
|
|
tts_payload = find_custom_header(payload.get('custom_headers'), 'X-TTS-Payload')
|
|
media_type = "audio" if audio_url else "tts" if tts_payload else "none"
|
|
media_value = audio_url or tts_payload
|
|
if media_value:
|
|
app_logger.info(f" -> Outbound call answered. Playing main message directly.")
|
|
next_state = encode_state(['MAIN_MEDIA_PLAYED', media_type, media_value])
|
|
if media_type == "audio":
|
|
send_telnyx_command("actions/playback_start", {"audio_url": media_value, "call_control_id": ccid, "client_state": next_state}, current_api_key)
|
|
elif media_type == "tts":
|
|
speak_params = {"payload": media_value, "voice": app_args.default_tts_voice, "language": app_args.default_tts_language, "call_control_id": ccid, "client_state": next_state}
|
|
send_telnyx_command("actions/speak", speak_params, current_api_key)
|
|
else:
|
|
app_logger.warning(" -> Outbound call, but no audio/tts payload. Hanging up.")
|
|
send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key)
|
|
|
|
elif event_type in ['call.speak.ended', 'call.playback.ended']:
|
|
app_logger.info(f" Playback/Speak Ended: Status='{payload.get('status')}'")
|
|
if state_name == 'INBOUND_GREETING_HUP':
|
|
app_logger.info(" -> Inbound greeting finished. Hanging up.")
|
|
send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key)
|
|
elif state_name in ['MAIN_MEDIA_PLAYED', 'REPLAYING_MEDIA']:
|
|
app_logger.info(f" -> Main message finished. Playing options menu.")
|
|
_, media_type, media_value = decoded_parts
|
|
next_state = encode_state(['WAITING_DTMF', media_type, media_value])
|
|
options_prompt = "press 0 to repeat the message or press pound to hang up."
|
|
gather_params = {
|
|
"payload": options_prompt, "voice": app_args.default_tts_voice, "language": app_args.default_tts_language,
|
|
"valid_digits": "0#", "max_digits": 1, "timeout_millis": app_args.dtmf_timeout_seconds * 1000, "terminating_digit": "#",
|
|
"call_control_id": ccid, "client_state": next_state
|
|
}
|
|
send_telnyx_command("actions/gather_using_speak", gather_params, current_api_key)
|
|
else:
|
|
app_logger.warning(f" -> Playback/Speak ended with unhandled state '{state_name}'. Hanging up.")
|
|
send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key)
|
|
|
|
elif event_type == 'call.dtmf.received':
|
|
digit = payload.get('digit')
|
|
app_logger.info(f" DTMF Received: Digit='{digit}'")
|
|
if digit == '#':
|
|
app_logger.info(" -> '#' received. Terminating call immediately.")
|
|
send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key)
|
|
|
|
elif event_type == 'call.gather.ended':
|
|
app_logger.info(f" -> Gather ended. Digits received: '{payload.get('digits')}', Status: '{payload.get('status')}'")
|
|
if state_name == 'WAITING_DTMF':
|
|
digits = payload.get('digits')
|
|
_, media_type, media_value = decoded_parts
|
|
if digits == "0":
|
|
app_logger.info(" -> '0' pressed. Replaying main message.")
|
|
# Note: No silence buffer on replay for responsiveness.
|
|
next_state = encode_state(['REPLAYING_MEDIA', media_type, media_value])
|
|
if media_type == "audio":
|
|
send_telnyx_command("actions/playback_start", {"audio_url": media_value, "call_control_id": ccid, "client_state": next_state}, current_api_key)
|
|
elif media_type == "tts":
|
|
speak_params = {"payload": media_value, "voice": app_args.default_tts_voice, "language": app_args.default_tts_language, "call_control_id": ccid, "client_state": next_state}
|
|
send_telnyx_command("actions/speak", speak_params, current_api_key)
|
|
else: # Includes '#' having already triggered hangup, timeout, or other hangup condition
|
|
app_logger.info(" -> Gather ended with non-repeat condition. Hanging up.")
|
|
send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key)
|
|
else:
|
|
app_logger.warning(f" -> Gather ended with unhandled state '{state_name}'.")
|
|
|
|
elif event_type == 'call.hangup':
|
|
app_logger.info(f" Call Hangup Event: Cause='{payload.get('cause')}'")
|
|
else:
|
|
app_logger.info(f" -> Unhandled Voice Event: '{event_type}' with state '{state_name}'.")
|
|
|
|
return Response(status=204)
|
|
except Exception as e: app_logger.exception("REQFAIL Unhandled Ex"); return "Internal Server Error", 500
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser(description="Telnyx IVR Webhook (Fixed): Async, Repeat, DTMF Hangup.", formatter_class=argparse.RawTextHelpFormatter)
|
|
parser.add_argument('--port', type=int, default=1998, help='Port (default: 1998).')
|
|
parser.add_argument('--host', default='0.0.0.0', help='Host address (default: 0.0.0.0).')
|
|
parser.add_argument('--webhook-path', default='/telnyx-webhook', help="URL path (default: /telnyx-webhook).")
|
|
parser.add_argument('--log-level', default=LOG_LEVEL_DEFAULT, choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], help=f'Log level (default: {LOG_LEVEL_DEFAULT}).')
|
|
parser.add_argument('--log-file', default=LOG_FILE_NAME_DEFAULT, help=f'Log file path (default: {LOG_FILE_NAME_DEFAULT}).')
|
|
parser.add_argument('--api-key', default=TELNYX_API_KEY_DEFAULT, help='Telnyx API Key (default: from env).')
|
|
parser.add_argument('--dtmf-timeout-seconds', type=int, default=DTMF_TIMEOUT_SECONDS_DEFAULT, help=f'Timeout for DTMF (default: {DTMF_TIMEOUT_SECONDS_DEFAULT}s).')
|
|
parser.add_argument('--default-tts-voice', default=DEFAULT_TTS_VOICE_DEFAULT, help=f'Default TTS voice (default: {DEFAULT_TTS_VOICE_DEFAULT}).')
|
|
parser.add_argument('--default-tts-language', default=DEFAULT_TTS_LANGUAGE_DEFAULT, help=f'Default TTS language (default: {DEFAULT_TTS_LANGUAGE_DEFAULT}).')
|
|
parser.add_argument('--client-state-prefix', default=CLIENT_STATE_PREFIX_DEFAULT, help=f'Prefix for client_state (default: {CLIENT_STATE_PREFIX_DEFAULT}).')
|
|
parser.add_argument('--inbound-greeting', default=INBOUND_GREETING_DEFAULT, help=f'TTS greeting for inbound calls.')
|
|
parser.add_argument('--debug', action='store_true', help='Run Flask in debug mode.')
|
|
app_args = parser.parse_args()
|
|
|
|
if not app_args.webhook_path.startswith('/'): app_args.webhook_path = '/' + app_args.webhook_path
|
|
if not app_args.api_key: print("CRITICAL WARNING: Telnyx API Key not set. Voice commands will FAIL.")
|
|
|
|
setup_logging("DEBUG" if app_args.debug else app_args.log_level.upper(), app_args.log_file, name="TelnyxWebhookApp_Final")
|
|
if not app_args.debug: logging.getLogger('werkzeug').setLevel(logging.WARNING)
|
|
|
|
app_logger.info("--- Telnyx Webhook Listener Starting (Final) ---")
|
|
app_logger.info(f" Configuration: {vars(app_args)}")
|
|
app_logger.info("------------------------------------------------")
|
|
|
|
app.run(host=app_args.host, port=app_args.port, debug=app_args.debug, use_reloader=app_args.debug)
|
|
|
|
|