well-svc-alert/wh1998v2.py
2025-06-25 15:53:03 -07:00

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)