scripts/telephony.py
scripts/telephony.pyBrowse 2 files
11,185 tokens
46,410 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""Telephony helper for the Hermes optional telephony skill.3 4Capabilities:5- Persist telephony provider credentials to the Hermes .env file ($HERMES_HOME/.env)6- Search for, buy, and remember Twilio phone numbers7- Make direct Twilio calls (TwiML <Say> or <Play>)8- Send SMS / MMS via Twilio9- Poll inbound SMS for an owned Twilio number using only this script + state10- Import a Twilio number into Vapi and persist the returned Vapi phone_number_id11- Make outbound AI voice calls via Bland.ai or Vapi12 13This file intentionally uses Python stdlib HTTP clients so the skill can run in a14minimal environment with no extra pip installs.15"""16 17from __future__ import annotations18 19import argparse20import base6421import json22import os23import re24import sys25import urllib.error26import urllib.parse27import urllib.request28from dataclasses import dataclass29from datetime import datetime, timezone30from email.utils import parsedate_to_datetime31from html import escape as xml_escape32from pathlib import Path33from typing import Any34 35TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts"36VAPI_API_BASE = "https://api.vapi.ai"37BLAND_API_BASE = "https://api.bland.ai/v1"38 39BLAND_DEFAULT_VOICE = "mason"40BLAND_DEFAULT_MODEL = "enhanced"41BLAND_VOICES = {42 "mason": "Male, natural, friendly (recommended)",43 "josh": "Male, conversational",44 "ryan": "Male, professional",45 "matt": "Male, casual",46 "evelyn": "Female, natural, warm (recommended)",47 "tina": "Female, warm, friendly",48 "june": "Female, conversational",49}50 51VAPI_DEFAULT_VOICE_PROVIDER = "11labs"52VAPI_DEFAULT_VOICE_ID = "cjVigY5qzO86Huf0OWal" # ElevenLabs "Eric"53VAPI_DEFAULT_MODEL = "gpt-4o"54TWILIO_DEFAULT_TTS_VOICE = "Polly.Joanna"55DEFAULT_AI_PROVIDER = "bland"56STATE_VERSION = 157 58 59class TelephonyError(RuntimeError):60 """Domain-specific failure surfaced to the skill/user."""61 62 63@dataclass64class OwnedTwilioNumber:65 sid: str66 phone_number: str67 friendly_name: str68 capabilities: dict[str, Any]69 70 71def _hermes_home() -> Path:72 return Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser()73 74 75def _env_path() -> Path:76 return _hermes_home() / ".env"77 78 79def _config_path() -> Path:80 return _hermes_home() / "config.yaml"81 82 83def _state_path() -> Path:84 return _hermes_home() / "telephony_state.json"85 86 87def _load_root_config() -> dict[str, Any]:88 path = _config_path()89 if not path.exists():90 return {}91 try:92 import yaml # optional dependency; Hermes already ships PyYAML93 except Exception:94 return {}95 try:96 with path.open("r", encoding="utf-8") as handle:97 data = yaml.safe_load(handle) or {}98 return data if isinstance(data, dict) else {}99 except Exception:100 return {}101 102 103def _config_lookup(*paths: tuple[str, ...], default: str = "") -> str:104 root = _load_root_config()105 for path in paths:106 node: Any = root107 for key in path:108 if not isinstance(node, dict):109 node = None110 break111 node = node.get(key)112 if node not in {None, ""} and not isinstance(node, dict):113 return str(node)114 return default115 116 117def _load_dotenv_values(path: Path | None = None) -> dict[str, str]:118 env_file = path or _env_path()119 if not env_file.exists():120 return {}121 values: dict[str, str] = {}122 for raw_line in env_file.read_text(encoding="utf-8").splitlines():123 line = raw_line.strip()124 if not line or line.startswith("#") or "=" not in line:125 continue126 key, _, value = raw_line.partition("=")127 key = key.strip()128 value = value.strip()129 if value.startswith('"') and value.endswith('"') and len(value) >= 2:130 value = value[1:-1].replace('\\"', '"').replace('\\\\', '\\')131 values[key] = value132 return values133 134 135def _env_or_config(env_key: str, *config_paths: tuple[str, ...], default: str = "") -> str:136 value = os.environ.get(env_key, "")137 if value:138 return value139 dotenv_value = _load_dotenv_values().get(env_key, "")140 if dotenv_value:141 return dotenv_value142 return _config_lookup(*config_paths, default=default)143 144 145def _load_state(path: Path | None = None) -> dict[str, Any]:146 state_file = path or _state_path()147 if not state_file.exists():148 return {"version": STATE_VERSION}149 try:150 data = json.loads(state_file.read_text(encoding="utf-8"))151 if isinstance(data, dict):152 data.setdefault("version", STATE_VERSION)153 return data154 except Exception:155 pass156 return {"version": STATE_VERSION}157 158 159def _save_state(state: dict[str, Any], path: Path | None = None) -> Path:160 state_file = path or _state_path()161 state_file.parent.mkdir(parents=True, exist_ok=True)162 state_file.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")163 return state_file164 165 166def _quote_env_value(value: str) -> str:167 if re.fullmatch(r"[A-Za-z0-9_./:+@-]+", value):168 return value169 escaped = value.replace("\\", "\\\\").replace('"', '\\"')170 return f'"{escaped}"'171 172 173def _upsert_env_file(updates: dict[str, str], env_path: Path | None = None) -> Path:174 path = env_path or _env_path()175 path.parent.mkdir(parents=True, exist_ok=True)176 if path.exists():177 lines = path.read_text(encoding="utf-8").splitlines()178 else:179 lines = []180 181 seen: set[str] = set()182 new_lines: list[str] = []183 for line in lines:184 stripped = line.strip()185 if not stripped or stripped.startswith("#") or "=" not in line:186 new_lines.append(line)187 continue188 key, _, _rest = line.partition("=")189 key = key.strip()190 if key in updates:191 new_lines.append(f"{key}={_quote_env_value(str(updates[key]))}")192 seen.add(key)193 else:194 new_lines.append(line)195 196 if new_lines and new_lines[-1].strip():197 new_lines.append("")198 for key, value in updates.items():199 if key not in seen:200 new_lines.append(f"{key}={_quote_env_value(str(value))}")201 202 path.write_text("\n".join(new_lines).rstrip() + "\n", encoding="utf-8")203 return path204 205 206def _normalize_phone(number: str) -> str:207 if not number:208 raise TelephonyError("Phone number is required")209 trimmed = number.strip()210 if not trimmed.startswith("+"):211 raise TelephonyError(212 f"Phone number must be E.164 format (for example +15551234567), got: {number}"213 )214 digits = "+" + re.sub(r"\D", "", trimmed)215 if len(digits) < 8:216 raise TelephonyError(f"Phone number looks too short: {number}")217 return digits218 219 220def _mask_phone(number: str) -> str:221 digits = re.sub(r"\D", "", number or "")222 if len(digits) < 4:223 return "***"224 return f"***-***-{digits[-4:]}"225 226 227def _parse_twilio_date(value: str | None) -> datetime | None:228 if not value:229 return None230 try:231 dt = parsedate_to_datetime(value)232 return dt.astimezone(timezone.utc) if dt.tzinfo else dt.replace(tzinfo=timezone.utc)233 except Exception:234 return None235 236 237def _json_request(238 method: str,239 url: str,240 *,241 headers: dict[str, str] | None = None,242 params: dict[str, Any] | None = None,243 form: dict[str, Any] | None = None,244 json_body: dict[str, Any] | None = None,245) -> dict[str, Any]:246 if params:247 query = urllib.parse.urlencode(params, doseq=True)248 url = f"{url}?{query}"249 250 request_headers = dict(headers or {})251 body: bytes | None = None252 if json_body is not None:253 body = json.dumps(json_body).encode("utf-8")254 request_headers.setdefault("Content-Type", "application/json")255 elif form is not None:256 body = urllib.parse.urlencode(form, doseq=True).encode("utf-8")257 request_headers.setdefault("Content-Type", "application/x-www-form-urlencoded")258 259 req = urllib.request.Request(url, data=body, headers=request_headers, method=method.upper())260 try:261 with urllib.request.urlopen(req, timeout=30) as resp:262 payload = resp.read().decode("utf-8")263 return json.loads(payload) if payload else {}264 except urllib.error.HTTPError as exc:265 body_text = exc.read().decode("utf-8", errors="replace") if exc.fp else ""266 try:267 parsed = json.loads(body_text) if body_text else {}268 except Exception:269 parsed = {"raw": body_text}270 raise TelephonyError(f"HTTP {exc.code} from {url}: {parsed or exc.reason}") from exc271 except urllib.error.URLError as exc:272 raise TelephonyError(f"Connection error for {url}: {exc.reason}") from exc273 274 275def _twilio_creds() -> tuple[str, str]:276 sid = _env_or_config(277 "TWILIO_ACCOUNT_SID",278 ("telephony", "twilio", "account_sid"),279 ("phone", "twilio", "account_sid"),280 )281 token = _env_or_config(282 "TWILIO_AUTH_TOKEN",283 ("telephony", "twilio", "auth_token"),284 ("phone", "twilio", "auth_token"),285 )286 if not sid or not token:287 raise TelephonyError(288 "Twilio credentials are not configured. Use 'save-twilio' or set "289 f"TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN in {_env_path()}."290 )291 return sid, token292 293 294def _twilio_basic_headers() -> dict[str, str]:295 sid, token = _twilio_creds()296 auth = base64.b64encode(f"{sid}:{token}".encode("utf-8")).decode("ascii")297 return {"Authorization": f"Basic {auth}"}298 299 300def _twilio_request(method: str, path: str, *, params=None, form=None) -> dict[str, Any]:301 sid, _token = _twilio_creds()302 return _json_request(303 method,304 f"{TWILIO_API_BASE}/{sid}/{path.lstrip('/')}",305 headers=_twilio_basic_headers(),306 params=params,307 form=form,308 )309 310 311def _twilio_owned_numbers(limit: int = 50) -> list[OwnedTwilioNumber]:312 payload = _twilio_request("GET", "IncomingPhoneNumbers.json", params={"PageSize": limit})313 items = payload.get("incoming_phone_numbers", []) or []314 results: list[OwnedTwilioNumber] = []315 for item in items:316 if not isinstance(item, dict):317 continue318 caps = item.get("capabilities") if isinstance(item.get("capabilities"), dict) else {}319 results.append(320 OwnedTwilioNumber(321 sid=str(item.get("sid", "")),322 phone_number=str(item.get("phone_number", "")),323 friendly_name=str(item.get("friendly_name", "")),324 capabilities=caps,325 )326 )327 return results328 329 330def _remember_twilio_number(331 *,332 phone_number: str,333 phone_sid: str = "",334 save_env: bool = False,335 state_path: Path | None = None,336 env_path: Path | None = None,337) -> dict[str, Any]:338 state = _load_state(state_path)339 twilio_state = state.setdefault("twilio", {})340 twilio_state["default_phone_number"] = phone_number341 if phone_sid:342 twilio_state["default_phone_sid"] = phone_sid343 _save_state(state, state_path)344 345 saved_env_keys: list[str] = []346 if save_env:347 updates = {"TWILIO_PHONE_NUMBER": phone_number}348 if phone_sid:349 updates["TWILIO_PHONE_NUMBER_SID"] = phone_sid350 _upsert_env_file(updates, env_path)351 saved_env_keys = sorted(updates)352 353 return {354 "state_path": str(state_path or _state_path()),355 "saved_env_keys": saved_env_keys,356 }357 358 359def _remember_vapi_number(360 *,361 phone_number_id: str,362 save_env: bool = False,363 state_path: Path | None = None,364 env_path: Path | None = None,365) -> dict[str, Any]:366 state = _load_state(state_path)367 vapi_state = state.setdefault("vapi", {})368 vapi_state["phone_number_id"] = phone_number_id369 _save_state(state, state_path)370 371 saved_env_keys: list[str] = []372 if save_env:373 _upsert_env_file({"VAPI_PHONE_NUMBER_ID": phone_number_id}, env_path)374 saved_env_keys = ["VAPI_PHONE_NUMBER_ID"]375 376 return {377 "state_path": str(state_path or _state_path()),378 "saved_env_keys": saved_env_keys,379 }380 381 382def _resolve_twilio_number(identifier: str | None = None) -> OwnedTwilioNumber:383 if identifier:384 wanted = identifier.strip()385 normalized = None386 if wanted.startswith("+"):387 normalized = _normalize_phone(wanted)388 for item in _twilio_owned_numbers(limit=100):389 if item.sid == wanted or item.phone_number == normalized:390 return item391 raise TelephonyError(f"Could not find an owned Twilio number matching {identifier}")392 393 env_number = _env_or_config(394 "TWILIO_PHONE_NUMBER",395 ("telephony", "twilio", "phone_number"),396 ("phone", "twilio", "phone_number"),397 )398 env_sid = _env_or_config(399 "TWILIO_PHONE_NUMBER_SID",400 ("telephony", "twilio", "phone_number_sid"),401 ("phone", "twilio", "phone_number_sid"),402 )403 state = _load_state()404 twilio_state = state.get("twilio", {}) if isinstance(state.get("twilio"), dict) else {}405 preferred_number = env_number or str(twilio_state.get("default_phone_number", ""))406 preferred_sid = env_sid or str(twilio_state.get("default_phone_sid", ""))407 408 owned = _twilio_owned_numbers(limit=100)409 if preferred_sid:410 for item in owned:411 if item.sid == preferred_sid:412 return item413 if preferred_number:414 normalized = _normalize_phone(preferred_number)415 for item in owned:416 if item.phone_number == normalized:417 return item418 if len(owned) == 1:419 return owned[0]420 421 raise TelephonyError(422 "No default Twilio phone number is set. Use 'twilio-buy --save-env', "423 f"'twilio-set-default', or set TWILIO_PHONE_NUMBER in {_env_path()}."424 )425 426 427def _vapi_api_key() -> str:428 return _env_or_config(429 "VAPI_API_KEY",430 ("telephony", "vapi", "api_key"),431 ("phone", "vapi", "api_key"),432 )433 434 435def _vapi_phone_number_id() -> str:436 state = _load_state()437 vapi_state = state.get("vapi", {}) if isinstance(state.get("vapi"), dict) else {}438 return _env_or_config(439 "VAPI_PHONE_NUMBER_ID",440 ("telephony", "vapi", "phone_number_id"),441 ("phone", "vapi", "phone_number_id"),442 default=str(vapi_state.get("phone_number_id", "")),443 )444 445 446def _bland_api_key() -> str:447 return _env_or_config(448 "BLAND_API_KEY",449 ("telephony", "bland", "api_key"),450 ("phone", "bland", "api_key"),451 )452 453 454def _ai_provider(default: str = DEFAULT_AI_PROVIDER) -> str:455 return _env_or_config(456 "PHONE_PROVIDER",457 ("telephony", "provider"),458 ("phone", "provider"),459 default=default,460 ).lower().strip()461 462 463def _twilio_search_numbers(464 *,465 country: str = "US",466 area_code: str | None = None,467 contains: str | None = None,468 limit: int = 10,469 sms_enabled: bool = True,470 voice_enabled: bool = True,471) -> dict[str, Any]:472 params: dict[str, Any] = {473 "PageSize": max(1, min(limit, 20)),474 "SmsEnabled": str(bool(sms_enabled)).lower(),475 "VoiceEnabled": str(bool(voice_enabled)).lower(),476 }477 if area_code:478 params["AreaCode"] = str(area_code)479 if contains:480 params["Contains"] = str(contains)481 482 payload = _twilio_request(483 "GET",484 f"AvailablePhoneNumbers/{country.upper()}/Local.json",485 params=params,486 )487 items = payload.get("available_phone_numbers", []) or []488 return {489 "success": True,490 "country": country.upper(),491 "count": len(items),492 "numbers": [493 {494 "phone_number": item.get("phone_number"),495 "friendly_name": item.get("friendly_name"),496 "locality": item.get("locality"),497 "region": item.get("region"),498 "postal_code": item.get("postal_code"),499 "iso_country": item.get("iso_country"),500 "capabilities": {501 "voice": item.get("voice_enabled"),502 "sms": item.get("sms_enabled"),503 "mms": item.get("mms_enabled"),504 },505 }506 for item in items507 if isinstance(item, dict)508 ],509 }510 511 512def _twilio_buy_number(513 phone_number: str,514 *,515 save_env: bool = False,516 state_path: Path | None = None,517 env_path: Path | None = None,518) -> dict[str, Any]:519 normalized = _normalize_phone(phone_number)520 payload = _twilio_request("POST", "IncomingPhoneNumbers.json", form={"PhoneNumber": normalized})521 purchased = {522 "success": True,523 "provider": "twilio",524 "phone_number": payload.get("phone_number", normalized),525 "phone_sid": payload.get("sid"),526 "friendly_name": payload.get("friendly_name"),527 "capabilities": payload.get("capabilities", {}),528 "message": "Twilio number purchased successfully.",529 }530 purchased.update(531 _remember_twilio_number(532 phone_number=str(purchased["phone_number"]),533 phone_sid=str(purchased.get("phone_sid") or ""),534 save_env=save_env,535 state_path=state_path,536 env_path=env_path,537 )538 )539 return purchased540 541 542def _twilio_list_owned() -> dict[str, Any]:543 owned = _twilio_owned_numbers(limit=100)544 return {545 "success": True,546 "provider": "twilio",547 "count": len(owned),548 "numbers": [549 {550 "phone_number": item.phone_number,551 "phone_sid": item.sid,552 "friendly_name": item.friendly_name,553 "capabilities": item.capabilities,554 }555 for item in owned556 ],557 }558 559 560def _twilio_set_default(identifier: str, *, save_env: bool = False) -> dict[str, Any]:561 owned = _resolve_twilio_number(identifier)562 result = {563 "success": True,564 "provider": "twilio",565 "phone_number": owned.phone_number,566 "phone_sid": owned.sid,567 "message": "Default Twilio number updated.",568 }569 result.update(570 _remember_twilio_number(571 phone_number=owned.phone_number,572 phone_sid=owned.sid,573 save_env=save_env,574 )575 )576 return result577 578 579def _twiml_say(message: str, voice: str) -> str:580 return f"<Response><Say voice=\"{xml_escape(voice)}\">{xml_escape(message)}</Say></Response>"581 582 583def _twiml_play(audio_url: str) -> str:584 return f"<Response><Play>{xml_escape(audio_url)}</Play></Response>"585 586 587def _twilio_call(588 to_number: str,589 *,590 message: str | None = None,591 audio_url: str | None = None,592 voice: str = TWILIO_DEFAULT_TTS_VOICE,593 send_digits: str | None = None,594 from_identifier: str | None = None,595 record: bool = False,596) -> dict[str, Any]:597 destination = _normalize_phone(to_number)598 source = _resolve_twilio_number(from_identifier)599 if bool(message) == bool(audio_url):600 raise TelephonyError("Provide exactly one of 'message' or 'audio_url' for twilio-call")601 602 twiml = _twiml_play(audio_url) if audio_url else _twiml_say(message or "", voice)603 form: dict[str, Any] = {604 "To": destination,605 "From": source.phone_number,606 "Twiml": twiml,607 }608 if send_digits:609 form["SendDigits"] = send_digits610 if record:611 form["Record"] = "true"612 613 payload = _twilio_request("POST", "Calls.json", form=form)614 return {615 "success": True,616 "provider": "twilio",617 "call_sid": payload.get("sid"),618 "status": payload.get("status"),619 "from_phone_number": source.phone_number,620 "to_phone_number_masked": _mask_phone(destination),621 "mode": "play" if audio_url else "say",622 "recording_requested": record,623 "message": "Twilio call initiated.",624 }625 626 627def _twilio_call_status(call_sid: str) -> dict[str, Any]:628 payload = _twilio_request("GET", f"Calls/{call_sid}.json")629 return {630 "success": True,631 "provider": "twilio",632 "call_sid": payload.get("sid"),633 "status": payload.get("status"),634 "direction": payload.get("direction"),635 "duration": payload.get("duration"),636 "from_phone_number": payload.get("from"),637 "to_phone_number_masked": _mask_phone(str(payload.get("to") or "")),638 "start_time": payload.get("start_time"),639 "end_time": payload.get("end_time"),640 "answered_by": payload.get("answered_by"),641 }642 643 644def _twilio_send_sms(645 to_number: str,646 body: str,647 *,648 media_urls: list[str] | None = None,649 from_identifier: str | None = None,650) -> dict[str, Any]:651 destination = _normalize_phone(to_number)652 source = _resolve_twilio_number(from_identifier)653 if not body.strip():654 raise TelephonyError("SMS body cannot be empty")655 form: dict[str, Any] = {656 "To": destination,657 "From": source.phone_number,658 "Body": body,659 }660 if media_urls:661 form["MediaUrl"] = media_urls662 payload = _twilio_request("POST", "Messages.json", form=form)663 return {664 "success": True,665 "provider": "twilio",666 "message_sid": payload.get("sid"),667 "status": payload.get("status"),668 "from_phone_number": source.phone_number,669 "to_phone_number_masked": _mask_phone(destination),670 "media_count": len(media_urls or []),671 "message": "SMS/MMS queued via Twilio.",672 }673 674 675def _checkpoint_for_messages(messages: list[dict[str, Any]]) -> tuple[str, str]:676 if not messages:677 return "", ""678 newest = messages[0]679 return str(newest.get("sid") or ""), str(newest.get("date_sent") or newest.get("date_created") or "")680 681 682def _messages_after_checkpoint(messages: list[dict[str, Any]], last_sid: str) -> list[dict[str, Any]]:683 if not last_sid:684 return messages685 filtered: list[dict[str, Any]] = []686 for message in messages:687 if str(message.get("sid") or "") == last_sid:688 break689 filtered.append(message)690 return filtered691 692 693def _twilio_inbox(694 *,695 limit: int = 20,696 since_last: bool = False,697 mark_seen: bool = False,698 phone_identifier: str | None = None,699 state_path: Path | None = None,700) -> dict[str, Any]:701 owned = _resolve_twilio_number(phone_identifier)702 payload = _twilio_request(703 "GET",704 "Messages.json",705 params={"To": owned.phone_number, "PageSize": max(1, min(limit, 100))},706 )707 raw_messages = payload.get("messages", []) or []708 messages = [m for m in raw_messages if isinstance(m, dict)]709 710 state = _load_state(state_path)711 twilio_state = state.setdefault("twilio", {})712 last_sid = str(twilio_state.get("last_inbound_message_sid", ""))713 if since_last:714 messages = _messages_after_checkpoint(messages, last_sid)715 716 message_rows = [717 {718 "sid": msg.get("sid"),719 "direction": msg.get("direction"),720 "status": msg.get("status"),721 "from_phone_number": msg.get("from"),722 "to_phone_number": msg.get("to"),723 "date_sent": msg.get("date_sent"),724 "body": msg.get("body"),725 "num_media": msg.get("num_media"),726 }727 for msg in messages728 ]729 730 if mark_seen and message_rows:731 last_seen_sid, last_seen_date = _checkpoint_for_messages(message_rows)732 twilio_state["last_inbound_message_sid"] = last_seen_sid733 twilio_state["last_inbound_message_date"] = last_seen_date734 _save_state(state, state_path)735 736 return {737 "success": True,738 "provider": "twilio",739 "phone_number": owned.phone_number,740 "count": len(message_rows),741 "messages": message_rows,742 "since_last": since_last,743 "marked_seen": bool(mark_seen and message_rows),744 "state_path": str(state_path or _state_path()),745 "last_seen_message_sid": twilio_state.get("last_inbound_message_sid", ""),746 }747 748 749def _vapi_import_twilio_number(750 *,751 phone_identifier: str | None = None,752 save_env: bool = False,753 state_path: Path | None = None,754 env_path: Path | None = None,755) -> dict[str, Any]:756 api_key = _vapi_api_key()757 if not api_key:758 raise TelephonyError(759 f"Vapi is not configured. Use 'save-vapi' or set VAPI_API_KEY in {_env_path()} first."760 )761 owned = _resolve_twilio_number(phone_identifier)762 sid, token = _twilio_creds()763 payload = _json_request(764 "POST",765 f"{VAPI_API_BASE}/phone-number",766 headers={"Authorization": f"Bearer {api_key}"},767 json_body={768 "provider": "twilio",769 "number": owned.phone_number,770 "twilioAccountSid": sid,771 "twilioAuthToken": token,772 },773 )774 phone_number_id = str(payload.get("id") or "")775 if not phone_number_id:776 raise TelephonyError(f"Vapi did not return a phone number id: {payload}")777 result = {778 "success": True,779 "provider": "vapi",780 "phone_number_id": phone_number_id,781 "phone_number": owned.phone_number,782 "message": "Twilio number imported into Vapi.",783 }784 result.update(785 _remember_vapi_number(786 phone_number_id=phone_number_id,787 save_env=save_env,788 state_path=state_path,789 env_path=env_path,790 )791 )792 return result793 794 795def _bland_call(796 phone_number: str,797 task: str,798 *,799 voice: str | None = None,800 first_sentence: str | None = None,801 max_duration: int = 3,802) -> dict[str, Any]:803 api_key = _bland_api_key()804 if not api_key:805 raise TelephonyError(806 f"Bland.ai is not configured. Use 'save-bland' or set BLAND_API_KEY in {_env_path()}."807 )808 normalized = _normalize_phone(phone_number)809 if voice is None:810 voice = _env_or_config(811 "BLAND_DEFAULT_VOICE",812 ("telephony", "bland", "default_voice"),813 ("phone", "bland", "default_voice"),814 default=BLAND_DEFAULT_VOICE,815 )816 payload = _json_request(817 "POST",818 f"{BLAND_API_BASE}/calls",819 headers={"authorization": api_key},820 json_body={821 "phone_number": normalized,822 "task": task,823 "voice": voice,824 "model": BLAND_DEFAULT_MODEL,825 "max_duration": max_duration,826 "record": True,827 "wait_for_greeting": True,828 **({"first_sentence": first_sentence} if first_sentence else {}),829 },830 )831 call_id = str(payload.get("call_id") or "")832 if not call_id:833 raise TelephonyError(f"Bland.ai returned no call_id: {payload}")834 return {835 "success": True,836 "provider": "bland",837 "call_id": call_id,838 "voice": voice,839 "max_duration_minutes": max_duration,840 "to_phone_number_masked": _mask_phone(normalized),841 "message": "AI call queued with Bland.ai.",842 }843 844 845def _bland_status(call_id: str, analyze: str | None = None) -> dict[str, Any]:846 api_key = _bland_api_key()847 if not api_key:848 raise TelephonyError("Bland.ai is not configured.")849 payload = _json_request("GET", f"{BLAND_API_BASE}/calls/{call_id}", headers={"authorization": api_key})850 result = {851 "success": True,852 "provider": "bland",853 "call_id": call_id,854 "status": payload.get("status"),855 "answered_by": payload.get("answered_by"),856 "duration_minutes": payload.get("call_length"),857 "transcript": payload.get("concatenated_transcript", ""),858 "recording_url": payload.get("recording_url"),859 }860 if analyze and payload.get("status") == "completed":861 questions = [[q.strip(), "string"] for q in analyze.split(",") if q.strip()]862 if questions:863 analysis = _json_request(864 "POST",865 f"{BLAND_API_BASE}/calls/{call_id}/analyze",866 headers={"authorization": api_key},867 json_body={"questions": questions},868 )869 result["analysis"] = analysis870 return result871 872 873def _vapi_call(874 phone_number: str,875 task: str,876 *,877 voice_id: str | None = None,878 first_sentence: str | None = None,879 max_duration: int = 3,880) -> dict[str, Any]:881 api_key = _vapi_api_key()882 if not api_key:883 raise TelephonyError(884 f"Vapi is not configured. Use 'save-vapi' or set VAPI_API_KEY in {_env_path()}."885 )886 phone_number_id = _vapi_phone_number_id()887 if not phone_number_id:888 raise TelephonyError(889 "No Vapi phone number id is configured. Import an owned Twilio number with "890 f"'vapi-import-twilio --save-env' or set VAPI_PHONE_NUMBER_ID in {_env_path()}."891 )892 normalized = _normalize_phone(phone_number)893 voice_provider = _env_or_config(894 "VAPI_VOICE_PROVIDER",895 ("telephony", "vapi", "default_voice_provider"),896 ("phone", "vapi", "default_voice_provider"),897 default=VAPI_DEFAULT_VOICE_PROVIDER,898 )899 if voice_id is None:900 voice_id = _env_or_config(901 "VAPI_VOICE_ID",902 ("telephony", "vapi", "default_voice_id"),903 ("phone", "vapi", "default_voice_id"),904 default=VAPI_DEFAULT_VOICE_ID,905 )906 model = _env_or_config(907 "VAPI_MODEL",908 ("telephony", "vapi", "model"),909 ("phone", "vapi", "model"),910 default=VAPI_DEFAULT_MODEL,911 )912 assistant = {913 "model": {914 "provider": "openai",915 "model": model,916 "messages": [{"role": "system", "content": task}],917 },918 "voice": {"provider": voice_provider, "voiceId": voice_id},919 "maxDurationSeconds": max_duration * 60,920 }921 if first_sentence:922 assistant["firstMessage"] = first_sentence923 payload = _json_request(924 "POST",925 f"{VAPI_API_BASE}/call",926 headers={"Authorization": f"Bearer {api_key}"},927 json_body={928 "phoneNumberId": phone_number_id,929 "customer": {"number": normalized},930 "assistant": assistant,931 },932 )933 call_id = str(payload.get("id") or "")934 if not call_id:935 raise TelephonyError(f"Vapi returned no call id: {payload}")936 return {937 "success": True,938 "provider": "vapi",939 "call_id": call_id,940 "voice_provider": voice_provider,941 "voice_id": voice_id,942 "max_duration_minutes": max_duration,943 "to_phone_number_masked": _mask_phone(normalized),944 "message": "AI call queued with Vapi.",945 }946 947 948def _vapi_status(call_id: str) -> dict[str, Any]:949 api_key = _vapi_api_key()950 if not api_key:951 raise TelephonyError("Vapi is not configured.")952 payload = _json_request(953 "GET",954 f"{VAPI_API_BASE}/call/{call_id}",955 headers={"Authorization": f"Bearer {api_key}"},956 )957 return {958 "success": True,959 "provider": "vapi",960 "call_id": call_id,961 "status": payload.get("status"),962 "duration_seconds": payload.get("duration"),963 "ended_reason": payload.get("endedReason"),964 "transcript": payload.get("transcript", ""),965 "recording_url": payload.get("recordingUrl"),966 "summary": payload.get("summary"),967 "cost": payload.get("cost"),968 }969 970 971def _provider_decision_tree() -> list[dict[str, str]]:972 return [973 {974 "need": "I want the agent to own a real number for SMS, inbound polling, or future telephony identity.",975 "use": "Twilio",976 "why": "Twilio is the clearest path to provisioning numbers, sending SMS/MMS, polling inbound texts, and later webhook-based inbound telephony.",977 },978 {979 "need": "I only want the easiest outbound AI voice calls right now.",980 "use": "Bland.ai",981 "why": "Bland is the simplest outbound AI calling setup: one API key, no separate number import flow.",982 },983 {984 "need": "I want premium conversational voice quality for AI calls, ideally on my own number.",985 "use": "Twilio + Vapi",986 "why": "Buy/import the number with Twilio, then import it into Vapi for better voices and more flexible assistants.",987 },988 {989 "need": "I want to call with a prerecorded/custom voice message generated elsewhere.",990 "use": "Twilio direct call + public audio URL",991 "why": "Generate or host audio separately, then let Twilio play it with a simple outbound call.",992 },993 ]994 995 996def diagnose() -> dict[str, Any]:997 state = _load_state()998 twilio_state = state.get("twilio", {}) if isinstance(state.get("twilio"), dict) else {}999 vapi_state = state.get("vapi", {}) if isinstance(state.get("vapi"), dict) else {}1000 provider = _ai_provider()1001 1002 twilio_sid = _env_or_config(1003 "TWILIO_ACCOUNT_SID",1004 ("telephony", "twilio", "account_sid"),1005 ("phone", "twilio", "account_sid"),1006 )1007 twilio_token = _env_or_config(1008 "TWILIO_AUTH_TOKEN",1009 ("telephony", "twilio", "auth_token"),1010 ("phone", "twilio", "auth_token"),1011 )1012 twilio_phone = _env_or_config(1013 "TWILIO_PHONE_NUMBER",1014 ("telephony", "twilio", "phone_number"),1015 ("phone", "twilio", "phone_number"),1016 default=str(twilio_state.get("default_phone_number", "")),1017 )1018 1019 bland_key = _bland_api_key()1020 vapi_key = _vapi_api_key()1021 vapi_phone_id = _vapi_phone_number_id() or str(vapi_state.get("phone_number_id", ""))1022 1023 return {1024 "success": True,1025 "state_path": str(_state_path()),1026 "env_path": str(_env_path()),1027 "ai_call_provider": provider,1028 "providers": {1029 "twilio": {1030 "account_sid_configured": bool(twilio_sid),1031 "auth_token_configured": bool(twilio_token),1032 "default_phone_number": twilio_phone,1033 "default_phone_sid": twilio_state.get("default_phone_sid", ""),1034 "last_inbound_message_sid": twilio_state.get("last_inbound_message_sid", ""),1035 "last_inbound_message_date": twilio_state.get("last_inbound_message_date", ""),1036 },1037 "bland": {1038 "configured": bool(bland_key),1039 "default_voice": _env_or_config(1040 "BLAND_DEFAULT_VOICE",1041 ("telephony", "bland", "default_voice"),1042 ("phone", "bland", "default_voice"),1043 default=BLAND_DEFAULT_VOICE,1044 ),1045 },1046 "vapi": {1047 "configured": bool(vapi_key),1048 "phone_number_id": vapi_phone_id,1049 "voice_provider": _env_or_config(1050 "VAPI_VOICE_PROVIDER",1051 ("telephony", "vapi", "default_voice_provider"),1052 ("phone", "vapi", "default_voice_provider"),1053 default=VAPI_DEFAULT_VOICE_PROVIDER,1054 ),1055 "voice_id": _env_or_config(1056 "VAPI_VOICE_ID",1057 ("telephony", "vapi", "default_voice_id"),1058 ("phone", "vapi", "default_voice_id"),1059 default=VAPI_DEFAULT_VOICE_ID,1060 ),1061 "model": _env_or_config(1062 "VAPI_MODEL",1063 ("telephony", "vapi", "model"),1064 ("phone", "vapi", "model"),1065 default=VAPI_DEFAULT_MODEL,1066 ),1067 },1068 },1069 "decision_tree": _provider_decision_tree(),1070 "notes": [1071 "Twilio is the best path for owning a durable phone number, texting, and polling inbound SMS.",1072 "Bland is the easiest path for outbound AI calls only.",1073 "Vapi is best when you want better AI voice quality, usually backed by a Twilio-owned number.",1074 "VoIP numbers are not guaranteed to work for every third-party 2FA flow.",1075 ],1076 }1077 1078 1079def save_twilio(account_sid: str, auth_token: str, phone_number: str = "", phone_sid: str = "") -> dict[str, Any]:1080 updates = {1081 "TWILIO_ACCOUNT_SID": account_sid.strip(),1082 "TWILIO_AUTH_TOKEN": auth_token.strip(),1083 }1084 if phone_number:1085 updates["TWILIO_PHONE_NUMBER"] = _normalize_phone(phone_number)1086 if phone_sid:1087 updates["TWILIO_PHONE_NUMBER_SID"] = phone_sid.strip()1088 env_file = _upsert_env_file(updates)1089 result = {1090 "success": True,1091 "provider": "twilio",1092 "saved_env_keys": sorted(updates),1093 "env_path": str(env_file),1094 "message": f"Twilio credentials saved to {env_file}.",1095 }1096 if phone_number:1097 result.update(_remember_twilio_number(phone_number=updates["TWILIO_PHONE_NUMBER"], phone_sid=phone_sid.strip(), save_env=False))1098 return result1099 1100 1101def save_bland(api_key: str, voice: str = BLAND_DEFAULT_VOICE) -> dict[str, Any]:1102 env_file = _upsert_env_file(1103 {1104 "BLAND_API_KEY": api_key.strip(),1105 "BLAND_DEFAULT_VOICE": voice.strip() or BLAND_DEFAULT_VOICE,1106 "PHONE_PROVIDER": "bland",1107 }1108 )1109 return {1110 "success": True,1111 "provider": "bland",1112 "saved_env_keys": ["BLAND_API_KEY", "BLAND_DEFAULT_VOICE", "PHONE_PROVIDER"],1113 "env_path": str(env_file),1114 "message": f"Bland.ai configuration saved to {env_file}.",1115 }1116 1117 1118def save_vapi(1119 api_key: str,1120 *,1121 phone_number_id: str = "",1122 voice_provider: str = VAPI_DEFAULT_VOICE_PROVIDER,1123 voice_id: str = VAPI_DEFAULT_VOICE_ID,1124 model: str = VAPI_DEFAULT_MODEL,1125) -> dict[str, Any]:1126 updates = {1127 "VAPI_API_KEY": api_key.strip(),1128 "VAPI_VOICE_PROVIDER": voice_provider.strip() or VAPI_DEFAULT_VOICE_PROVIDER,1129 "VAPI_VOICE_ID": voice_id.strip() or VAPI_DEFAULT_VOICE_ID,1130 "VAPI_MODEL": model.strip() or VAPI_DEFAULT_MODEL,1131 "PHONE_PROVIDER": "vapi",1132 }1133 if phone_number_id:1134 updates["VAPI_PHONE_NUMBER_ID"] = phone_number_id.strip()1135 env_file = _upsert_env_file(updates)1136 result = {1137 "success": True,1138 "provider": "vapi",1139 "saved_env_keys": sorted(updates),1140 "env_path": str(env_file),1141 "message": f"Vapi configuration saved to {env_file}.",1142 }1143 if phone_number_id:1144 result.update(_remember_vapi_number(phone_number_id=phone_number_id.strip(), save_env=False))1145 return result1146 1147 1148def _build_parser() -> argparse.ArgumentParser:1149 parser = argparse.ArgumentParser(description="Hermes telephony helper")1150 sub = parser.add_subparsers(dest="command", required=True)1151 1152 sub.add_parser("diagnose", help="Show saved telephony state and provider readiness")1153 1154 p = sub.add_parser("save-twilio", help="Save Twilio credentials to the Hermes .env file")1155 p.add_argument("account_sid")1156 p.add_argument("auth_token")1157 p.add_argument("--phone-number", default="")1158 p.add_argument("--phone-sid", default="")1159 1160 p = sub.add_parser("save-bland", help="Save Bland.ai settings to the Hermes .env file")1161 p.add_argument("api_key")1162 p.add_argument("--voice", default=BLAND_DEFAULT_VOICE)1163 1164 p = sub.add_parser("save-vapi", help="Save Vapi settings to the Hermes .env file")1165 p.add_argument("api_key")1166 p.add_argument("--phone-number-id", default="")1167 p.add_argument("--voice-provider", default=VAPI_DEFAULT_VOICE_PROVIDER)1168 p.add_argument("--voice-id", default=VAPI_DEFAULT_VOICE_ID)1169 p.add_argument("--model", default=VAPI_DEFAULT_MODEL)1170 1171 p = sub.add_parser("twilio-search", help="Search Twilio numbers available for purchase")1172 p.add_argument("--country", default="US")1173 p.add_argument("--area-code", default="")1174 p.add_argument("--contains", default="")1175 p.add_argument("--limit", type=int, default=10)1176 p.add_argument("--sms-enabled", action=argparse.BooleanOptionalAction, default=True)1177 p.add_argument("--voice-enabled", action=argparse.BooleanOptionalAction, default=True)1178 1179 p = sub.add_parser("twilio-buy", help="Buy a Twilio phone number")1180 p.add_argument("phone_number")1181 p.add_argument("--save-env", action="store_true")1182 1183 sub.add_parser("twilio-owned", help="List Twilio numbers already owned by the account")1184 1185 p = sub.add_parser("twilio-set-default", help="Remember one owned Twilio number as the default")1186 p.add_argument("identifier", help="Owned phone number in E.164 or Twilio phone SID")1187 p.add_argument("--save-env", action="store_true")1188 1189 p = sub.add_parser("twilio-call", help="Place a direct Twilio call")1190 p.add_argument("to_number")1191 p.add_argument("--message", default="")1192 p.add_argument("--audio-url", default="")1193 p.add_argument("--voice", default=TWILIO_DEFAULT_TTS_VOICE)1194 p.add_argument("--send-digits", default="")1195 p.add_argument("--from-number", default="")1196 p.add_argument("--record", action="store_true")1197 1198 p = sub.add_parser("twilio-call-status", help="Check a Twilio call status")1199 p.add_argument("call_sid")1200 1201 p = sub.add_parser("twilio-send-sms", help="Send SMS or MMS via Twilio")1202 p.add_argument("to_number")1203 p.add_argument("body")1204 p.add_argument("--media-url", action="append", default=[])1205 p.add_argument("--from-number", default="")1206 1207 p = sub.add_parser("twilio-inbox", help="Poll inbound SMS for the default or specified Twilio number")1208 p.add_argument("--limit", type=int, default=20)1209 p.add_argument("--since-last", action="store_true")1210 p.add_argument("--mark-seen", action="store_true")1211 p.add_argument("--phone-number", default="")1212 1213 p = sub.add_parser("vapi-import-twilio", help="Import an owned Twilio number into Vapi")1214 p.add_argument("--phone-number", default="")1215 p.add_argument("--save-env", action="store_true")1216 1217 p = sub.add_parser("ai-call", help="Place an outbound AI voice call via Bland.ai or Vapi")1218 p.add_argument("to_number")1219 p.add_argument("task")1220 p.add_argument("--provider", choices=["bland", "vapi"], default="")1221 p.add_argument("--voice", default="")1222 p.add_argument("--first-sentence", default="")1223 p.add_argument("--max-duration", type=int, default=3)1224 1225 p = sub.add_parser("ai-status", help="Check an AI call status via Bland.ai or Vapi")1226 p.add_argument("call_id")1227 p.add_argument("--provider", choices=["bland", "vapi"], default="")1228 p.add_argument("--analyze", default="")1229 1230 return parser1231 1232 1233def _dispatch(args: argparse.Namespace) -> dict[str, Any]:1234 cmd = args.command1235 if cmd == "diagnose":1236 return diagnose()1237 if cmd == "save-twilio":1238 return save_twilio(args.account_sid, args.auth_token, phone_number=args.phone_number, phone_sid=args.phone_sid)1239 if cmd == "save-bland":1240 return save_bland(args.api_key, voice=args.voice)1241 if cmd == "save-vapi":1242 return save_vapi(1243 args.api_key,1244 phone_number_id=args.phone_number_id,1245 voice_provider=args.voice_provider,1246 voice_id=args.voice_id,1247 model=args.model,1248 )1249 if cmd == "twilio-search":1250 return _twilio_search_numbers(1251 country=args.country,1252 area_code=args.area_code or None,1253 contains=args.contains or None,1254 limit=args.limit,1255 sms_enabled=args.sms_enabled,1256 voice_enabled=args.voice_enabled,1257 )1258 if cmd == "twilio-buy":1259 return _twilio_buy_number(args.phone_number, save_env=args.save_env)1260 if cmd == "twilio-owned":1261 return _twilio_list_owned()1262 if cmd == "twilio-set-default":1263 return _twilio_set_default(args.identifier, save_env=args.save_env)1264 if cmd == "twilio-call":1265 return _twilio_call(1266 args.to_number,1267 message=args.message or None,1268 audio_url=args.audio_url or None,1269 voice=args.voice,1270 send_digits=args.send_digits or None,1271 from_identifier=args.from_number or None,1272 record=args.record,1273 )1274 if cmd == "twilio-call-status":1275 return _twilio_call_status(args.call_sid)1276 if cmd == "twilio-send-sms":1277 return _twilio_send_sms(1278 args.to_number,1279 args.body,1280 media_urls=args.media_url or None,1281 from_identifier=args.from_number or None,1282 )1283 if cmd == "twilio-inbox":1284 return _twilio_inbox(1285 limit=args.limit,1286 since_last=args.since_last,1287 mark_seen=args.mark_seen,1288 phone_identifier=args.phone_number or None,1289 )1290 if cmd == "vapi-import-twilio":1291 return _vapi_import_twilio_number(1292 phone_identifier=args.phone_number or None,1293 save_env=args.save_env,1294 )1295 if cmd == "ai-call":1296 provider = (args.provider or _ai_provider()).lower().strip()1297 if provider == "vapi":1298 return _vapi_call(1299 args.to_number,1300 args.task,1301 voice_id=args.voice or None,1302 first_sentence=args.first_sentence or None,1303 max_duration=args.max_duration,1304 )1305 if provider == "bland":1306 return _bland_call(1307 args.to_number,1308 args.task,1309 voice=args.voice or None,1310 first_sentence=args.first_sentence or None,1311 max_duration=args.max_duration,1312 )1313 raise TelephonyError(1314 f"Unsupported AI call provider '{provider}'. Use --provider bland or --provider vapi, "1315 f"or set PHONE_PROVIDER in {_env_path()}."1316 )1317 if cmd == "ai-status":1318 provider = (args.provider or _ai_provider()).lower().strip()1319 if provider == "vapi":1320 return _vapi_status(args.call_id)1321 if provider == "bland":1322 return _bland_status(args.call_id, analyze=args.analyze or None)1323 raise TelephonyError(1324 f"Unsupported AI call provider '{provider}'. Use --provider bland or --provider vapi, "1325 f"or set PHONE_PROVIDER in {_env_path()}."1326 )1327 raise TelephonyError(f"Unknown command: {cmd}")1328 1329 1330def main(argv: list[str] | None = None) -> int:1331 parser = _build_parser()1332 args = parser.parse_args(argv)1333 try:1334 result = _dispatch(args)1335 print(json.dumps(result, indent=2, ensure_ascii=False))1336 return 01337 except TelephonyError as exc:1338 print(json.dumps({"success": False, "error": str(exc)}, indent=2, ensure_ascii=False), file=sys.stderr)1339 return 11340 1341 1342if __name__ == "__main__":1343 sys.exit(main())1344