v2.1: add SMS module + SMS Log doctype + fixtures: sms.py

This commit is contained in:
Zac Gaetano 2026-05-12 00:11:36 -04:00
parent 6c2cb4a55b
commit 05dc1c0dce

View file

@ -0,0 +1,325 @@
"""
voxtelesys_integration.api.sms
==============================
Voxtelesys SMS API integration. Two surfaces:
1. send_sms(to, body) whitelisted RPC for the desk UI / scripts.
2. handle_inbound_sms() public webhook for inbound MO messages and
delivery receipts posted from Voxtelesys.
All SMS activity is persisted to the Voxtelesys SMS Log doctype, mirroring how
voxtelesys.py persists calls to Voxtelesys Call Log. When the message is sent
or received in the context of an HD Ticket / Contact / Lead, a Dynamic Link
row is attached so the message shows up on those forms.
Endpoint shape (per Voxtelesys docs):
POST https://smsapi.voxtelesys.net/api/v1/sms
Authorization: Bearer <token>
Content-Type: application/json
Body: { "to": ["+15551234567"], "from": "+15557654321", "body": "hi" }
"""
from __future__ import annotations
import hashlib
import hmac
import json
from typing import Any
import frappe
import requests
from frappe import _
from frappe.utils import now_datetime
SMS_API_BASE = "https://smsapi.voxtelesys.net/api/v1"
# -----------------------------------------------------------------------------
# Outbound
# -----------------------------------------------------------------------------
@frappe.whitelist()
def send_sms(
to: str,
body: str,
from_: str = "",
reference_doctype: str = "",
reference_name: str = "",
) -> dict[str, Any]:
"""Send a single SMS via Voxtelesys.
Args:
to: E.164 destination (e.g. "+12025551234"). Voxtelesys accepts an
array but we keep it one-to-one to match how the desk UI calls us.
body: Message text (<=1600 chars; longer is auto-segmented by Voxtelesys).
from_: Override sender DID; defaults to Voxtelesys Settings.caller_id.
reference_doctype/name: Optional Frappe doc to link this SMS to so it
appears on that form's history sidebar.
"""
from voxtelesys_integration.voxtelesys_integration.doctype.voxtelesys_settings.voxtelesys_settings import (
VoxtelesysSettings,
)
if not VoxtelesysSettings.is_enabled():
frappe.throw(_("Voxtelesys integration is not enabled."))
to = (to or "").strip()
body = (body or "").strip()
if not to:
frappe.throw(_("Destination number is required."))
if not body:
frappe.throw(_("Message body is required."))
sender = (from_ or VoxtelesysSettings.get_caller_id() or "").strip()
if not sender:
frappe.throw(_("No sender number configured. Set caller_id in Voxtelesys Settings."))
token = VoxtelesysSettings.get_api_token()
payload = {"to": [to], "from": sender, "body": body}
try:
resp = requests.post(
f"{SMS_API_BASE}/sms",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
json=payload,
timeout=15,
)
resp.raise_for_status()
result = resp.json() if resp.content else {}
except requests.HTTPError as exc:
frappe.log_error(
f"Status {exc.response.status_code}: {exc.response.text}",
"Voxtelesys: SMS send failed",
)
frappe.throw(
_("Voxtelesys SMS API error: {0}").format(str(exc)),
title=_("SMS Failed"),
)
message_id = result.get("id") or result.get("message_id") or result.get("sid") or ""
log = _upsert_sms_log(
message_id=message_id,
direction="Outbound",
status="Sent",
from_number=sender,
to_number=to,
body=body,
sent_at=now_datetime(),
)
if reference_doctype and reference_name:
_add_link(log, reference_doctype, reference_name)
return {"ok": True, "sms_log": log.name, "message_id": message_id}
# -----------------------------------------------------------------------------
# Inbound webhook
# -----------------------------------------------------------------------------
@frappe.whitelist(allow_guest=True)
def handle_inbound_sms():
"""Webhook target for inbound SMS and delivery receipts from Voxtelesys.
Voxtelesys' SMS webhook payload shape is documented in their portal; we
accept both their snake_case format and a Twilio-compatible mapping so the
same endpoint can serve as a fallback / multi-provider relay.
"""
if frappe.request.method != "POST":
frappe.throw(_("Method not allowed"), frappe.PermissionError)
raw = frappe.request.data
sig = frappe.request.headers.get("X-Voxtelesys-Signature", "")
if not _verify_signature(raw, sig):
frappe.throw(_("Invalid webhook signature"), frappe.PermissionError)
data = _parse_webhook_body(raw)
message_id = (
data.get("id")
or data.get("message_id")
or data.get("MessageSid")
or data.get("sid")
or ""
)
from_number = data.get("from") or data.get("From") or ""
to_number = data.get("to") or data.get("To") or ""
body = data.get("body") or data.get("Body") or data.get("text") or ""
raw_status = (data.get("status") or data.get("MessageStatus") or "").lower()
# Heuristic: if there's a body and no status field, treat as a new MO message.
# Otherwise treat as a delivery receipt for an existing outbound row.
is_status_update = bool(raw_status) and not body
status_map = {
"queued": "Queued",
"sending": "Sending",
"sent": "Sent",
"delivered": "Delivered",
"undelivered": "Undelivered",
"failed": "Failed",
"received": "Received",
}
status = status_map.get(raw_status, "Received" if not is_status_update else "Sent")
fields: dict[str, Any] = {
"status": status,
"from_number": from_number,
"to_number": to_number,
}
if body and not is_status_update:
fields["body"] = body
fields["direction"] = "Inbound"
fields["received_at"] = now_datetime()
log = _upsert_sms_log(message_id=message_id, **fields)
# Inbound MO: link to contact and (optionally) auto-open / create an HD Ticket
if not is_status_update and body:
_link_contact(log, from_number)
_maybe_attach_to_ticket(log, from_number, body)
frappe.publish_realtime(
"voxtelesys_sms_received",
{
"sms_log": log.name,
"message_id": message_id,
"from": from_number,
"to": to_number,
"body": body[:200],
},
)
frappe.local.response["http_status_code"] = 200
return {"ok": True, "sms_log": log.name}
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
def _verify_signature(body: bytes, signature: str) -> bool:
secret = frappe.db.get_single_value("Voxtelesys Settings", "webhook_secret")
if not secret:
return True
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
def _parse_webhook_body(raw: bytes) -> dict[str, Any]:
try:
return json.loads(raw)
except (json.JSONDecodeError, ValueError):
return dict(frappe.form_dict)
def _upsert_sms_log(message_id: str, **fields):
if message_id and frappe.db.exists("Voxtelesys SMS Log", {"message_id": message_id}):
name = frappe.db.get_value("Voxtelesys SMS Log", {"message_id": message_id})
doc = frappe.get_doc("Voxtelesys SMS Log", name)
for k, v in fields.items():
if v is not None:
doc.set(k, v)
doc.save(ignore_permissions=True)
else:
doc = frappe.new_doc("Voxtelesys SMS Log")
doc.message_id = message_id
for k, v in fields.items():
doc.set(k, v)
doc.insert(ignore_permissions=True)
frappe.db.commit()
return doc
def _link_contact(doc, phone_number: str):
if not phone_number:
return
normalised = phone_number.replace("+", "").replace(" ", "").replace("-", "")
contact = frappe.db.sql(
"""
SELECT parent FROM `tabContact Phone`
WHERE REPLACE(REPLACE(REPLACE(phone,'+',''),' ',''),'-','') LIKE %(num)s
LIMIT 1
""",
{"num": f"%{normalised}"},
as_dict=True,
)
if contact:
_add_link(doc, "Contact", contact[0].parent)
def _add_link(doc, link_doctype: str, link_name: str):
for row in doc.get("links") or []:
if row.link_doctype == link_doctype and row.link_name == link_name:
return
doc.append("links", {"link_doctype": link_doctype, "link_name": link_name})
doc.save(ignore_permissions=True)
def _maybe_attach_to_ticket(doc, from_number: str, body: str):
"""If the sender has an open HD Ticket, attach this SMS as a Communication;
otherwise leave it on the Contact (or unattached) agents can convert it
to a ticket manually from the SMS Log form."""
if not from_number:
return
normalised = from_number.replace("+", "").replace(" ", "").replace("-", "")
contact = frappe.db.sql(
"""
SELECT parent FROM `tabContact Phone`
WHERE REPLACE(REPLACE(REPLACE(phone,'+',''),' ',''),'-','') LIKE %(num)s
LIMIT 1
""",
{"num": f"%{normalised}"},
as_dict=True,
)
if not contact:
return
open_ticket = frappe.db.get_value(
"HD Ticket",
{"contact": contact[0].parent, "status": ["not in", ["Closed", "Resolved"]]},
"name",
order_by="creation desc",
)
if not open_ticket:
return
_add_link(doc, "HD Ticket", open_ticket)
# Also create a Communication row so the SMS body shows up in the ticket thread.
try:
comm = frappe.new_doc("Communication")
comm.communication_type = "Communication"
comm.communication_medium = "SMS"
comm.sent_or_received = "Received"
comm.subject = f"Inbound SMS from {from_number}"
comm.content = body
comm.reference_doctype = "HD Ticket"
comm.reference_name = open_ticket
comm.insert(ignore_permissions=True)
except Exception:
frappe.log_error(frappe.get_traceback(), "Voxtelesys: Failed to create SMS Communication")
@frappe.whitelist()
def get_linked_sms_logs(doctype: str, name: str, **kwargs):
"""Return SMS history linked to the given document, for the form sidebar."""
rows = frappe.db.sql(
"""
SELECT vsl.name, vsl.direction, vsl.status, vsl.from_number, vsl.to_number,
vsl.body, vsl.sent_at, vsl.received_at
FROM `tabVoxtelesys SMS Log` vsl
INNER JOIN `tabDynamic Link` dl
ON dl.parent = vsl.name AND dl.parenttype = 'Voxtelesys SMS Log'
AND dl.link_doctype = %(doctype)s AND dl.link_name = %(name)s
ORDER BY COALESCE(vsl.received_at, vsl.sent_at) DESC
LIMIT 50
""",
{"doctype": doctype, "name": name},
as_dict=True,
)
for r in rows:
r["doctype"] = "Voxtelesys SMS Log"
return rows