well-svc-alert/alert.py
2025-06-16 10:31:41 -07:00

414 lines
22 KiB
Python

#!/usr/bin/env python3
import os
import sys
import logging
import json
import time
import argparse
from datetime import datetime, timezone
import requests # Added for voice calls
import telnyx # For SMS
from kombu import Connection, Exchange, Queue
from kombu.exceptions import KombuError, OperationalError
from kombu.simple import SimpleQueue # Using SimpleQueue for get/put ease
from dotenv import load_dotenv
# --- Configuration Loading ---
# Load environment variables from $HOME/.env then ./.env (current dir overrides home)
dotenv_path_home = os.path.join(os.environ.get("HOME", "."), '.env')
load_dotenv(dotenv_path=dotenv_path_home)
dotenv_path_cwd = os.path.join(os.getcwd(), '.env')
load_dotenv(dotenv_path=dotenv_path_cwd, override=True)
# --- Configuration ---
# General Telnyx
TELNYX_API_KEY = os.environ.get("TELNYX_API_KEY")
TELNYX_MESSAGING_PROFILE_ID = os.environ.get("TELNYX_MESSAGING_PROFILE_ID")
# SMS Specific
TELNYX_SENDER_ID = os.environ.get("TELNYX_SENDER_ID") # Sending SMS *FROM* this E.164 number
TELNYX_SENDER_ID_ALPHA = os.environ.get("TELNYX_SENDER_ID_ALPHA") # Sending SMS/Voice *FROM* this Alpha Sender ID
# Voice Call Specific
TELNYX_CONNECTION_ID_VOICE = os.environ.get("TELNYX_CONNECTION_ID_VOICE","2671409623596009055")
TELNYX_WEBHOOK_URL_VOICE = os.environ.get("TELNYX_WEBHOOK_URL_VOICE", "http://your-webhook-server.com/telnyx-webhook")
# RabbitMQ Configuration
raw_rabbitmq_url = os.environ.get("RABBITMQ_URL", "localhost")
if raw_rabbitmq_url == "localhost":
RABBITMQ_URL = "amqp://guest:guest@localhost:5672//"
else:
RABBITMQ_URL = raw_rabbitmq_url
RABBITMQ_ALERTS_QNAME = os.environ.get("RABBITMQ_ALERTS_QNAME", "alerts")
MOSQUITTO_PASSWORD_FILE = os.environ.get('MOSQUITTO_PASSWORD_FILE', '/etc/mosquitto/passwd')
MOSQUITTO_ACL_FILE = os.environ.get('MOSQUITTO_ACL_FILE', '/etc/mosquitto/acl')
# Kombu Exchange and Queue definitions
exchange = Exchange("", type='direct')
alert_queue_obj = Queue(RABBITMQ_ALERTS_QNAME, exchange=exchange, routing_key=RABBITMQ_ALERTS_QNAME, durable=True)
# --- Setup Logging ---
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(name)s - [%(funcName)s] - %(message)s",
stream=sys.stdout,
)
logger = logging.getLogger("MessengerTool")
# --- Helper Functions ---
def check_env_vars(require_telnyx_sms=False, require_telnyx_voice=False, require_rabbitmq=False):
missing_vars = []
if require_rabbitmq:
if not RABBITMQ_URL: missing_vars.append("RABBITMQ_URL")
if not RABBITMQ_ALERTS_QNAME: missing_vars.append("RABBITMQ_ALERTS_QNAME")
if require_telnyx_sms or require_telnyx_voice:
if not TELNYX_API_KEY: missing_vars.append("TELNYX_API_KEY")
if require_telnyx_sms:
if not TELNYX_SENDER_ID: missing_vars.append("TELNYX_SENDER_ID (for numeric SMS sender)")
# TELNYX_SENDER_ID_ALPHA is needed if 'alpha' or 'auto' for non-US SMS is used
if not TELNYX_SENDER_ID_ALPHA: logger.debug("TELNYX_SENDER_ID_ALPHA not set (optional for SMS if only numeric is used or for US destinations in auto mode).")
if require_telnyx_voice:
if not TELNYX_CONNECTION_ID_VOICE: missing_vars.append("TELNYX_CONNECTION_ID_VOICE")
if not TELNYX_WEBHOOK_URL_VOICE: missing_vars.append("TELNYX_WEBHOOK_URL_VOICE")
if not TELNYX_SENDER_ID: missing_vars.append("TELNYX_SENDER_ID (for numeric voice caller ID)")
if not TELNYX_SENDER_ID_ALPHA: missing_vars.append("TELNYX_SENDER_ID_ALPHA (for alphanumeric voice caller ID)")
if missing_vars:
logger.error(f"Missing required environment variables for this operation: {', '.join(missing_vars)}")
sys.exit(1)
def normalize_phone_number(phone_number_str: str) -> str:
if not phone_number_str: return ""
cleaned_number = "".join(filter(lambda char: char.isdigit() or char == '+', phone_number_str))
if not cleaned_number.startswith('+'):
if cleaned_number.startswith('1') and len(cleaned_number) >= 11:
cleaned_number = '+' + cleaned_number
else:
cleaned_number = '+' + cleaned_number
return cleaned_number
# --- Telnyx SMS Sending Function ---
def setup_telnyx_sms_client():
check_env_vars(require_telnyx_sms=True)
try:
telnyx.api_key = TELNYX_API_KEY
logger.info("Telnyx client for SMS configured.")
return True
except Exception as e:
logger.error(f"Failed to configure Telnyx client for SMS: {e}")
return False
def send_telnyx_sms(recipient_phone: str, message_body: str, caller_id_type: str = "auto") -> bool:
"""Sends an SMS using the Telnyx API, with dynamic 'from_' based on caller_id_type."""
if not setup_telnyx_sms_client():
return False
if not recipient_phone or not message_body:
logger.error("Cannot send Telnyx SMS: Recipient phone and message are required.")
return False
recipient_phone = normalize_phone_number(recipient_phone)
from_id = TELNYX_SENDER_ID # Default to numeric
if caller_id_type == "alpha":
if TELNYX_SENDER_ID_ALPHA:
from_id = TELNYX_SENDER_ID_ALPHA
else:
logger.warning("SMS Caller ID type 'alpha' requested, but TELNYX_SENDER_ID_ALPHA is not set. Falling back to numeric.")
elif caller_id_type == "numeric":
from_id = TELNYX_SENDER_ID
elif caller_id_type == "auto":
if recipient_phone.startswith("+1"): # US/Canada
from_id = TELNYX_SENDER_ID
elif TELNYX_SENDER_ID_ALPHA: # Other international, try Alpha if available
from_id = TELNYX_SENDER_ID_ALPHA
else: # Fallback to numeric if Alpha not set for international
logger.warning("SMS Caller ID type 'auto' for non-US/Canada destination, but TELNYX_SENDER_ID_ALPHA not set. Using numeric.")
from_id = TELNYX_SENDER_ID
else: # Should not happen with argparse choices
logger.warning(f"Invalid caller_id_type '{caller_id_type}' for SMS. Defaulting to numeric.")
from_id = TELNYX_SENDER_ID
logger.info(f"Attempting to send Telnyx SMS from '{from_id}' to '{recipient_phone}'")
try:
message_create_params = {
"from_": from_id,
"to": recipient_phone,
"text": message_body,
"messaging_profile_id": TELNYX_MESSAGING_PROFILE_ID,
"type": "sms"
}
if not message_create_params["messaging_profile_id"]:
del message_create_params["messaging_profile_id"]
response = telnyx.Message.create(**message_create_params)
logger.info(f"SMS submitted successfully. Message ID: {response.id}")
return True
except telnyx.error.TelnyxError as e:
logger.error(f"Telnyx API Error sending SMS to {recipient_phone} from '{from_id}': {e}")
if hasattr(e, 'json_body') and e.json_body and 'errors' in e.json_body:
for err in e.json_body['errors']:
logger.error(f" - Code: {err.get('code')}, Title: {err.get('title')}, Detail: {err.get('detail')}")
return False
except Exception as e:
logger.error(f"Unexpected error sending Telnyx SMS to {recipient_phone}: {e}")
return False
# --- Telnyx Voice Call Function ---
def make_telnyx_voice_call(to_phone: str, tts_message: str = None, audio_url: str = None,
connection_id_override: str = None, webhook_url_override: str = None,
caller_id_type: str = "auto", amd_mode: str = "disabled",
extra_custom_headers: list = None):
check_env_vars(require_telnyx_voice=True)
api_url = "https://api.telnyx.com/v2/calls"
to_phone = normalize_phone_number(to_phone)
from_id = TELNYX_SENDER_ID
if caller_id_type == "alpha":
if TELNYX_SENDER_ID_ALPHA:
from_id = TELNYX_SENDER_ID_ALPHA
else:
logger.warning("Voice Caller ID type 'alpha' requested, but TELNYX_SENDER_ID_ALPHA is not set. Falling back to numeric.")
elif caller_id_type == "numeric":
from_id = TELNYX_SENDER_ID
elif caller_id_type == "auto":
if to_phone.startswith("+1"):
from_id = TELNYX_SENDER_ID
elif TELNYX_SENDER_ID_ALPHA:
from_id = TELNYX_SENDER_ID_ALPHA
else:
logger.warning("Voice Caller ID type 'auto' for non-US/Canada destination, but TELNYX_SENDER_ID_ALPHA not set. Using numeric.")
from_id = TELNYX_SENDER_ID
telnyx_connection_id = connection_id_override or TELNYX_CONNECTION_ID_VOICE
telnyx_webhook_url = webhook_url_override or TELNYX_WEBHOOK_URL_VOICE
headers = {
"Authorization": f"Bearer {TELNYX_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json"
}
payload_custom_headers = []
if tts_message: payload_custom_headers.append({"name": "X-TTS-Payload", "value": tts_message})
if audio_url: payload_custom_headers.append({"name": "X-Audio-Url", "value": audio_url})
if extra_custom_headers:
for header_item in extra_custom_headers:
if "=" in header_item:
name, value = header_item.split("=", 1)
payload_custom_headers.append({"name": name.strip(), "value": value.strip()})
else:
logger.warning(f"Skipping malformed custom header: {header_item}. Expected NAME=VALUE")
api_payload = {
"to": to_phone, "from": from_id, "connection_id": telnyx_connection_id,
"webhook_url": telnyx_webhook_url, "webhook_url_method": "POST",
"answering_machine_detection": amd_mode
}
if payload_custom_headers: api_payload["custom_headers"] = payload_custom_headers
logger.info(f"Initiating Telnyx voice call from '{from_id}' to '{to_phone}' using connection '{telnyx_connection_id}'")
logger.debug(f"Voice Call API Payload: {json.dumps(api_payload, indent=2)}")
try:
response = requests.post(api_url, headers=headers, json=api_payload)
response.raise_for_status()
call_data = response.json().get("data", {})
logger.info("Voice call initiated successfully!")
logger.info(f" Call Control ID: {call_data.get('call_control_id')}, Session ID: {call_data.get('call_session_id')}, Leg ID: {call_data.get('call_leg_id')}")
return True
except requests.exceptions.HTTPError as e:
logger.error(f"Telnyx API HTTP Error initiating voice call: {e}")
if e.response is not None:
logger.error(f"Status Code: {e.response.status_code}")
try:
error_details = e.response.json()
if "errors" in error_details:
for err in error_details["errors"]: logger.error(f" - Code: {err.get('code')}, Title: {err.get('title')}, Detail: {err.get('detail')}")
else: logger.error(f"Response Body: {json.dumps(error_details, indent=2)}")
except json.JSONDecodeError: logger.error(f"Response Body (raw): {e.response.text}")
return False
except requests.exceptions.RequestException as e:
logger.error(f"Network error initiating voice call: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error initiating voice call: {e}")
return False
# --- RabbitMQ Interaction Functions ---
def publish_to_rmq(payload: dict):
check_env_vars(require_rabbitmq=True)
try:
with Connection(RABBITMQ_URL) as connection:
logger.info(f"Connecting to RabbitMQ at {connection.as_uri(hide_password=True)} to publish...")
producer = connection.Producer(serializer='json')
producer.publish(payload, exchange=exchange, routing_key=RABBITMQ_ALERTS_QNAME, declare=[alert_queue_obj], retry=True, retry_policy={'interval_start': 0, 'interval_step': 2, 'interval_max': 30, 'max_retries': 3}, delivery_mode='persistent')
logger.info(f"Message published successfully to queue '{RABBITMQ_ALERTS_QNAME}'.")
return True
except (KombuError, OperationalError, ConnectionRefusedError, Exception) as e:
logger.error(f"Failed to publish message to RabbitMQ ({RABBITMQ_URL}): {e}")
return False
def peek_messages_rmq(limit=5):
check_env_vars(require_rabbitmq=True)
messages_peeked = []
try:
with Connection(RABBITMQ_URL) as connection:
logger.info(f"Connecting to RabbitMQ at {connection.as_uri(hide_password=True)} to peek...")
alert_queue_obj(connection.channel()).declare(passive=True)
queue = connection.SimpleQueue(RABBITMQ_ALERTS_QNAME)
logger.info(f"Peeking up to {limit} messages from queue '{RABBITMQ_ALERTS_QNAME}'...")
count = 0
while count < limit:
try:
message = queue.get(block=False)
print("-" * 20 + f" Message {count + 1} " + "-" * 20)
print(json.dumps(message.payload, indent=2))
messages_peeked.append(message.payload)
message.requeue()
count += 1
except queue.Empty: logger.info("Queue is empty or no more messages."); break
except Exception as get_err:
logger.error(f"Error during message get/requeue: {get_err}")
if 'message' in locals() and hasattr(message, 'delivery_tag') and message.delivery_tag:
try: message.requeue()
except Exception as req_err: logger.error(f"Failed to requeue msg after error: {req_err}")
break
queue.close()
if not messages_peeked: logger.info(f"No messages found in queue '{RABBITMQ_ALERTS_QNAME}'.")
return messages_peeked
except (KombuError, OperationalError, ConnectionRefusedError) as e: logger.error(f"Failed to peek from RMQ: {e}"); return []
except Exception as e: logger.error(f"Unexpected error peeking: {e}"); return []
def get_and_process_one_rmq_sms(target_phone: str):
check_env_vars(require_rabbitmq=True, require_telnyx_sms=True)
message = None
try:
with Connection(RABBITMQ_URL) as connection:
logger.info(f"Connecting to RMQ for get_and_process_one_rmq_sms...")
alert_queue_obj(connection.channel()).declare(passive=True)
queue = connection.SimpleQueue(RABBITMQ_ALERTS_QNAME)
try:
message = queue.get(block=True, timeout=1)
logger.info(f"Got message from queue (Tag: {message.delivery_tag}).")
payload = message.payload
if not isinstance(payload, dict): raise TypeError("Msg payload not dict.")
body = payload.get("body")
if not body: raise ValueError("Msg payload missing 'body'.")
# For this function, caller_id_type for SMS from RMQ will be 'auto' by default
# as it's not specified in the RMQ message itself.
sms_sent = send_telnyx_sms(recipient_phone=target_phone, message_body=body, caller_id_type="auto")
if sms_sent:
logger.info(f"Telnyx SMS sent. Acking RMQ msg (Tag: {message.delivery_tag}).")
message.ack()
else:
logger.error(f"Failed to send SMS. Rejecting RMQ msg (Tag: {message.delivery_tag}).")
message.reject(requeue=False)
queue.close()
return sms_sent
except queue.Empty: logger.info(f"Queue '{RABBITMQ_ALERTS_QNAME}' empty."); queue.close(); return False
except (KombuError, OperationalError, ConnectionRefusedError, TypeError, ValueError, json.JSONDecodeError) as e:
logger.error(f"RMQ/Payload error: {e}.")
if message and hasattr(message, 'delivery_tag'):
try: message.reject(requeue=False)
except Exception as reject_e: logger.error(f"Failed to reject msg after error: {reject_e}")
return False
except Exception as e: logger.error(f"Unexpected error in get_and_process_one_rmq_sms: {e}"); return False
def clear_rmq_messages(mode='one'):
check_env_vars(require_rabbitmq=True)
try:
with Connection(RABBITMQ_URL) as connection:
logger.info(f"Connecting to RMQ to clear messages (mode: {mode})...")
channel = connection.channel()
alert_queue_obj(channel).declare(passive=True)
if mode == 'one':
method_frame, _, _ = channel.basic_get(RABBITMQ_ALERTS_QNAME)
if method_frame: channel.basic_ack(method_frame.delivery_tag); logger.info("One msg acked.")
else: logger.info(f"Queue '{RABBITMQ_ALERTS_QNAME}' empty.")
elif mode == 'all':
confirm = input("PURGE ALL messages from queue? (yes/no): ").lower()
if confirm == 'yes': msg_count = channel.queue_purge(RABBITMQ_ALERTS_QNAME); logger.info(f"Queue purged. Approx {msg_count} msgs removed.")
else: logger.info("Purge cancelled.")
else: logger.error(f"Invalid clear mode '{mode}'.")
channel.close(); return True
except (KombuError, OperationalError, ConnectionRefusedError) as e: logger.error(f"Failed to clear RMQ: {e}"); return False
except Exception as e: logger.error(f"Unexpected error clearing RMQ: {e}"); return False
# --- Main Execution ---
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Messenger Tool: Send SMS/Voice via Telnyx and interact with RabbitMQ.", formatter_class=argparse.RawTextHelpFormatter)
subparsers = parser.add_subparsers(dest='command', help='Available actions', required=True)
# --- Subparser: sms ---
parser_sms = subparsers.add_parser('sms', help='Send an SMS directly using Telnyx.')
parser_sms.add_argument('--to', required=True, help='Recipient phone number (E.164 format, e.g., +14155552671).')
parser_sms.add_argument('--message', required=True, help='The content of the SMS message.')
parser_sms.add_argument( # ADDED for SMS
'--caller-id-type', choices=['auto', 'alpha', 'numeric'], default='auto',
help="Choose SMS caller ID type: 'auto' (numeric for +1, alpha otherwise), 'alpha', or 'numeric'. Default: auto."
)
# --- Subparser: voice-tts-call ---
parser_voice = subparsers.add_parser('voice-tts-call', help='Initiate a voice call with TTS or audio playback using Telnyx.', description=("Initiates a voice call. Your webhook needs to be configured in Telnyx\nto handle `call.answered` and issue `speak_text` or `play_audio`."))
parser_voice.add_argument('--to', default='+14082397258', help='Recipient phone for voice call (default: +14082397258).')
parser_voice.add_argument('--message', default='Test message from Telnyx.', help='TTS message for call (default: "Test message from Telnyx."). Sent via X-TTS-Payload.')
parser_voice.add_argument('--audio-url', default=None, help='URL of audio file (MP3/WAV) to play. Sent via X-Audio-Url.')
parser_voice.add_argument('--connection-id', default=None, help='Override Telnyx Connection ID for the call.')
parser_voice.add_argument('--webhook-url', default=None, help='Override webhook URL for call events.')
parser_voice.add_argument('--caller-id-type', choices=['auto', 'alpha', 'numeric'], default='auto', help="Choose voice caller ID type: 'auto', 'alpha', or 'numeric'. Default: auto.")
parser_voice.add_argument('--amd', choices=['disabled', 'detect', 'detect_beep', 'detect_words', 'greeting_end'], default='disabled', help="Answering Machine Detection mode (default: disabled).")
parser_voice.add_argument('--custom-header', action='append', help='Add custom header. Format: NAME=VALUE. Multiple allowed.')
# --- Subparser: send-rmq ---
parser_rmq = subparsers.add_parser('send-rmq', help="Send formatted message to RabbitMQ 'alerts' queue.")
parser_rmq.add_argument('--method', choices=['sms', 'email', 'phone'], default='sms', help='Method for consumer (default: sms).')
parser_rmq.add_argument('--to', required=True, help='Destination (phone/email).')
parser_rmq.add_argument('--body', required=True, help='Main content of message.')
parser_rmq.add_argument('--subject', default='Notification', help='Subject line.')
parser_rmq.add_argument('--timestamp', default=None, help='ISO 8601 timestamp (optional, defaults to now UTC).')
# --- Subparser: peek-rmq ---
parser_peek = subparsers.add_parser('peek-rmq', help="View messages in 'alerts' queue.")
parser_peek.add_argument('--limit', type=int, default=5, help='Max messages to peek (default: 5).')
# --- Subparser: process-one ---
parser_process = subparsers.add_parser('process-one', help="Get message from RMQ, send via Telnyx SMS to specified number, then ACK.")
parser_process.add_argument('--to', required=True, help='Phone number (E.164) to send SMS TO.')
# --- Subparser: clear-rmq ---
parser_clear = subparsers.add_parser('clear-rmq', help="Remove messages from 'alerts' queue.")
parser_clear.add_argument('--mode', choices=['one', 'all'], required=True, help="'one': next message, 'all': ALL messages (confirm).")
args = parser.parse_args()
success = False
if args.command == 'sms':
logger.info("Action: Send Telnyx SMS")
success = send_telnyx_sms(recipient_phone=args.to, message_body=args.message, caller_id_type=args.caller_id_type) # Pass new arg
elif args.command == 'voice-tts-call':
logger.info("Action: Initiate Telnyx Voice Call")
success = make_telnyx_voice_call(to_phone=args.to, tts_message=args.message, audio_url=args.audio_url, connection_id_override=args.connection_id, webhook_url_override=args.webhook_url, caller_id_type=args.caller_id_type, amd_mode=args.amd, extra_custom_headers=args.custom_header)
elif args.command == 'send-rmq':
logger.info("Action: Send Message to RabbitMQ")
timestamp = args.timestamp if args.timestamp else datetime.now(timezone.utc).isoformat()
payload = {"send_method": args.method, "subject": args.subject, "body": args.body, "timestamp": timestamp, "destination": args.to}
success = publish_to_rmq(payload)
elif args.command == 'peek-rmq':
logger.info("Action: Peek RabbitMQ Messages")
peek_messages_rmq(limit=args.limit); success = True
elif args.command == 'process-one':
logger.info("Action: Process One RMQ Message as Telnyx SMS")
success = get_and_process_one_rmq_sms(target_phone=args.to)
elif args.command == 'clear-rmq':
logger.info(f"Action: Clear RabbitMQ Messages (Mode: {args.mode})")
success = clear_rmq_messages(mode=args.mode)
else:
logger.error(f"Unknown command: {args.command}"); parser.print_help(); sys.exit(1)
if success: logger.info(f"Command '{args.command}' completed successfully."); sys.exit(0)
else: logger.error(f"Command '{args.command}' failed."); sys.exit(1)