#!/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('/', 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)